mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'v1422/fixes' into release/v1.42.2
This commit is contained in:
commit
3c4e053dcd
83 files changed
+1808
-258
No files matched your search
@@ -717,6 +717,18 @@ function DaySheet({
|
||||
event.preventDefault();
|
||||
returnFocus();
|
||||
}}
|
||||
onEscapeKeyDown={(event) => {
|
||||
// A dialog opened from inside the day (the date's calendar) is
|
||||
// portaled out of the panel. For a moment after it opens, until
|
||||
// Radix has re-rendered both layers, the panel still holds the
|
||||
// Escape listener: an Escape aimed at the calendar then closed the
|
||||
// whole day. Escape from inside another dialog is never the day's.
|
||||
const owner =
|
||||
event.target instanceof Element
|
||||
? event.target.closest('[role="dialog"]')
|
||||
: null;
|
||||
if (owner && owner !== panelRef.current) event.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
// Quicker than the primitive's 500 ms, which read as lag on a
|
||||
// layer opened from a tap; no slide at all under reduced motion.
|
||||
|
||||
@@ -81,3 +81,41 @@ describe("the detail sheet's original-document transport", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* "Open in new tab" hands the browser the types it renders itself, so a long
|
||||
* report gets the browser's zoom and search. It must open the object URL the
|
||||
* sheet already holds (a bare navigation to the route carries no record-session
|
||||
* assertion and is refused once the session has been inside a shared record),
|
||||
* and it must never appear for a type outside the inline serving class.
|
||||
*/
|
||||
describe("the detail sheet's open-in-new-tab control", () => {
|
||||
const control = SHEET.slice(
|
||||
SHEET.indexOf("function DocumentOpenInTabButton("),
|
||||
SHEET.indexOf("/** Inline preview for Class A documents"),
|
||||
);
|
||||
|
||||
it("exists", () => {
|
||||
expect(control.length).toBeGreaterThan(0);
|
||||
expect(SHEET).toMatch(/<DocumentOpenInTabButton\s/);
|
||||
});
|
||||
|
||||
it("renders only for the inline serving class", () => {
|
||||
expect(SHEET).toMatch(
|
||||
/doc\.servingClass === "inline" \? \(\s*\n\s*<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}/,
|
||||
);
|
||||
expect([...SHEET.matchAll(/<DocumentOpenInTabButton\s/g)]).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens the fetched object URL, never the route, without an opener", () => {
|
||||
expect(control).toContain('window.open(readyUrl, "_blank", "noopener")');
|
||||
expect(control).not.toContain("/api/documents");
|
||||
});
|
||||
|
||||
it("is disabled until the bytes are in hand, and when they failed", () => {
|
||||
expect(control).toMatch(/disabled=\{readyUrl === null \|\| unavailable\}/);
|
||||
expect(SHEET).toMatch(
|
||||
/<DocumentOpenInTabButton\s+readyUrl=\{inlineSrc\}\s+unavailable=\{inlineFailed\}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FlaskConical,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -199,6 +200,43 @@ function DocumentDownloadButton({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the stored original in a new browser tab, for the types the browser
|
||||
* renders itself (the inline serving class: PDF, JPEG, PNG, WebP, GIF), so a
|
||||
* long report gets the browser's own zoom and search.
|
||||
*
|
||||
* It opens the object URL the sheet already holds rather than the route: a
|
||||
* plain navigation carries no record-session assertion, so the fence would
|
||||
* refuse it once the session has been inside a shared record. The bytes are
|
||||
* already in hand, so the tab opens inside the click and no popup blocker
|
||||
* steps in. A type outside the inline class never gets the control.
|
||||
*/
|
||||
function DocumentOpenInTabButton({
|
||||
readyUrl,
|
||||
unavailable,
|
||||
}: {
|
||||
readyUrl: string | null;
|
||||
unavailable: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
data-slot="document-open-in-tab"
|
||||
disabled={readyUrl === null || unavailable}
|
||||
aria-label={t("documents.detail.openInNewTab")}
|
||||
onClick={() => {
|
||||
if (readyUrl) window.open(readyUrl, "_blank", "noopener");
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="size-4" aria-hidden />
|
||||
<span className="hidden sm:inline">
|
||||
{t("documents.detail.openInNewTab")}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline preview for Class A documents; skeleton until the blob paints. */
|
||||
function InlinePreview({
|
||||
src,
|
||||
@@ -759,8 +797,8 @@ export function DocumentDetailSheet({
|
||||
doc ? (
|
||||
// Delete sits bottom-LEFT, quieted to a text button: the
|
||||
// bottom-right slot reads as the primary/confirm action, and a
|
||||
// destructive control there is a footgun. Share + Download — the
|
||||
// safe, expected actions — keep the trailing edge. On phones their
|
||||
// destructive control there is a footgun. Share, open in a new tab and
|
||||
// Download, the safe, expected actions, keep the trailing edge. On phones their
|
||||
// labels collapse to icon-only (aria-labelled) so three controls
|
||||
// never overflow the sheet at 360 px; the labels return at `sm+`.
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{doc.servingClass === "inline" ? (
|
||||
<DocumentOpenInTabButton
|
||||
readyUrl={inlineSrc}
|
||||
unavailable={inlineFailed}
|
||||
/>
|
||||
) : null}
|
||||
<DocumentDownloadButton
|
||||
documentId={doc.id}
|
||||
filename={doc.filename}
|
||||
|
||||
@@ -699,7 +699,7 @@ describe("<MessageThread>", () => {
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(
|
||||
"Daily limit reached. The budget refreshes at midnight UTC.",
|
||||
"Daily limit reached. The budget refreshes at midnight in your time zone.",
|
||||
);
|
||||
// v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights
|
||||
// provider"). Both old and new strings should be absent here when
|
||||
|
||||
@@ -560,7 +560,7 @@ function ChatBubbleImpl({
|
||||
|
||||
// v1.4.25 W5 — map server-emitted error codes to specific Coach
|
||||
// i18n keys. Distinct user-quota and provider-rate-limit copy so the
|
||||
// user understands daily-cap (resets at UTC midnight) vs. transient
|
||||
// user understands daily-cap (resets at their local midnight) vs. transient
|
||||
// provider load (retry in ~5 min). Codes that have no dedicated
|
||||
// translation fall back to the generic provider-unavailable copy.
|
||||
const errorKey = errorCode ? errorCodeToI18nKey(errorCode) : null;
|
||||
|
||||
Reference in new issue
Block a user