mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge origin/main (v1.44.2) into fix/v145-sheet-motion
next stays on main's 16.4.2 (the RCE and Server Actions advisories) with its two companions; release's newer dev tools and its own additions are kept. The lockfile is release's, re-resolved. pt-BR, which main does not have, takes the one key main added.
This commit is contained in:
commit
9c8972bc96
86 files changed
+5051
-487
No files matched your search
@@ -135,6 +135,9 @@ export default function CommandPalette() {
|
||||
<Sheet open={open} onOpenChange={setCommandPaletteOpen}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
// The palette fills the window: no backdrop to pull it towards,
|
||||
// and its own Cancel and Escape close it.
|
||||
draggable={false}
|
||||
showCloseButton={false}
|
||||
data-slot="command-palette"
|
||||
data-variant="sheet"
|
||||
|
||||
@@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
*
|
||||
* - opening adds exactly one entry, so Back closes the layer;
|
||||
* - opening another day while one is open swaps it in place;
|
||||
* - stepping to the neighbouring day never adds an entry, however often;
|
||||
* - stepping to the neighbouring day never adds an entry, however often,
|
||||
* starts from the day in the URL and never passes today;
|
||||
* - closing a day the layer opened goes Back (the next Back leaves the
|
||||
* page); closing a deep link only drops the parameter and stays.
|
||||
*/
|
||||
@@ -57,6 +58,8 @@ function installFakeBrowser(initialUrl: string) {
|
||||
|
||||
let browser: ReturnType<typeof installFakeBrowser>;
|
||||
|
||||
const TODAY = "2026-01-10";
|
||||
|
||||
beforeEach(() => {
|
||||
browser = installFakeBrowser("/insights/blood-pressure?range=90");
|
||||
});
|
||||
@@ -88,18 +91,63 @@ describe("day layer history", () => {
|
||||
it("steps without adding history entries", async () => {
|
||||
const { openDay, stepDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
stepDay("2026-01-03", -1);
|
||||
stepDay("2026-01-02", -1);
|
||||
stepDay("2026-01-01", 1);
|
||||
stepDay(-1, TODAY);
|
||||
stepDay(-1, TODAY);
|
||||
stepDay(1, TODAY);
|
||||
expect(browser.length()).toBe(2);
|
||||
expect(browser.current()).toContain("day=2026-01-02");
|
||||
});
|
||||
|
||||
it("steps from the day in the URL, so quick steps in a row all count", async () => {
|
||||
// Nothing republishes the open day between these calls, which is what a
|
||||
// fast key repeat looks like while the layer is still rendering the
|
||||
// previous step. Each step must still move from where the last one went.
|
||||
const { openDay, stepDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
expect(stepDay(1, TODAY)).toBe("2026-01-04");
|
||||
expect(stepDay(1, TODAY)).toBe("2026-01-05");
|
||||
expect(browser.current()).toContain("day=2026-01-05");
|
||||
});
|
||||
|
||||
it("holds a step while the layer still shows another day, then makes it", async () => {
|
||||
// The layer publishes the day it shows only once Next has adopted the
|
||||
// URL. A step written before that can be put back by Next's own write,
|
||||
// so it waits for the layer and goes in one write when it catches up.
|
||||
const { openDay, publishOpenDay, stepDay } =
|
||||
await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
publishOpenDay("2026-01-03");
|
||||
expect(stepDay(-1, TODAY)).toBe("2026-01-02");
|
||||
// The layer still shows the 3rd: these two wait.
|
||||
expect(stepDay(1, TODAY)).toBeNull();
|
||||
expect(stepDay(1, TODAY)).toBeNull();
|
||||
expect(browser.current()).toContain("day=2026-01-02");
|
||||
publishOpenDay("2026-01-02");
|
||||
expect(browser.current()).toContain("day=2026-01-04");
|
||||
expect(browser.length()).toBe(2);
|
||||
publishOpenDay(null);
|
||||
});
|
||||
|
||||
it("never steps past today, however often it is asked", async () => {
|
||||
const { openDay, stepDay } = await import("../day-layer-controller");
|
||||
openDay("2026-01-09");
|
||||
expect(stepDay(1, TODAY)).toBe(TODAY);
|
||||
expect(stepDay(1, TODAY)).toBeNull();
|
||||
expect(stepDay(1, TODAY)).toBeNull();
|
||||
expect(browser.current()).toContain(`day=${TODAY}`);
|
||||
});
|
||||
|
||||
it("does nothing while no day is open", async () => {
|
||||
const { stepDay } = await import("../day-layer-controller");
|
||||
expect(stepDay(-1, TODAY)).toBeNull();
|
||||
expect(browser.current()).toBe("/insights/blood-pressure?range=90");
|
||||
});
|
||||
|
||||
it("closes a pushed day with Back, landing on the page without it", async () => {
|
||||
const { closeDay, openDay, stepDay } =
|
||||
await import("../day-layer-controller");
|
||||
openDay("2026-01-03");
|
||||
stepDay("2026-01-03", 1);
|
||||
stepDay(1, TODAY);
|
||||
closeDay();
|
||||
expect(browser.current()).toBe("/insights/blood-pressure?range=90");
|
||||
});
|
||||
@@ -107,7 +155,7 @@ describe("day layer history", () => {
|
||||
it("closes a deep link in place: the parameter goes, the page stays", async () => {
|
||||
browser = installFakeBrowser("/?day=2026-01-03");
|
||||
const { closeDay, stepDay } = await import("../day-layer-controller");
|
||||
stepDay("2026-01-03", -1);
|
||||
stepDay(-1, TODAY);
|
||||
expect(browser.current()).toBe("/?day=2026-01-02");
|
||||
closeDay();
|
||||
expect(browser.current()).toBe("/");
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
||||
|
||||
import {
|
||||
dayHistoryState,
|
||||
historyEntryOwnedByLayer,
|
||||
parseDayParam,
|
||||
shiftDateKey,
|
||||
withDayHref,
|
||||
} from "./day-url";
|
||||
@@ -100,6 +101,13 @@ export function publishOpenDay(date: DateKey | null): void {
|
||||
if (memory.open === date) return;
|
||||
memory.open = date;
|
||||
emit();
|
||||
// The layer has caught up with the URL: the steps that waited for it go
|
||||
// now, in one write, from the day it shows.
|
||||
const waiting = pendingStep;
|
||||
pendingStep = null;
|
||||
if (waiting !== null && date !== null) {
|
||||
stepDay(waiting.delta, waiting.today);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,9 +163,50 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Move the open day by `delta` calendar days, without a new history entry. */
|
||||
export function stepDay(from: DateKey, delta: number): DateKey {
|
||||
const next = shiftDateKey(from, delta);
|
||||
/**
|
||||
* Steps asked for while the layer had not yet shown the previous one. Kept
|
||||
* as one sum, with the `today` of the latest request.
|
||||
*/
|
||||
let pendingStep: { delta: number; today: DateKey } | null = null;
|
||||
|
||||
/**
|
||||
* Move the open day by `delta` calendar days, without a new history entry,
|
||||
* and never past `today`. Null when nothing is written now: no day is open,
|
||||
* the step would pass today, or it waits for the previous one.
|
||||
*
|
||||
* Two things keep quick steps (a key held down, `]` pressed twice) from
|
||||
* losing one.
|
||||
*
|
||||
* The step starts from the day in the URL, not from the day a caller last
|
||||
* rendered, which reaches it through `useSearchParams`, a render and an
|
||||
* effect and can be a step behind.
|
||||
*
|
||||
* And a step does not write while the layer still shows a different day
|
||||
* than the URL. The write goes past Next's router, which adopts it a render
|
||||
* later, and since Next 16.4 that render can land after a second write and
|
||||
* put the first one's URL back: `[` then `]` pressed quickly ended a day
|
||||
* short. So a step that finds the layer behind waits, and `publishOpenDay`
|
||||
* makes it as soon as the layer shows the day the URL names.
|
||||
*/
|
||||
export function stepDay(delta: number, today: DateKey): DateKey | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const from = parseDayParam(
|
||||
new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM),
|
||||
today,
|
||||
);
|
||||
if (from === null) {
|
||||
pendingStep = null;
|
||||
return null;
|
||||
}
|
||||
const total = (pendingStep?.delta ?? 0) + delta;
|
||||
if (memory.open !== null && memory.open !== from) {
|
||||
pendingStep = { delta: total, today };
|
||||
return null;
|
||||
}
|
||||
pendingStep = null;
|
||||
const target = shiftDateKey(from, total);
|
||||
const next = target > today ? today : target;
|
||||
if (next === from) return null;
|
||||
jumpDay(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -348,8 +348,7 @@ function DayLayer() {
|
||||
const onStep = useCallback(
|
||||
(delta: number) => {
|
||||
if (date === null) return;
|
||||
if (delta > 0 && date >= today) return;
|
||||
stepDay(date, delta);
|
||||
stepDay(delta, today);
|
||||
},
|
||||
[date, today],
|
||||
);
|
||||
@@ -630,9 +629,10 @@ function returnFocus(fallback?: HTMLElement | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The modal frames. The bottom sheet opens at a bit over half the height and
|
||||
* grows to full height from its handle: drag it up or tap it. Dragging down
|
||||
* shrinks a full sheet, then closes it.
|
||||
* The modal frames. On a phone the bottom sheet opens at a bit over half the
|
||||
* height, so the chart stays in view above it, and grows to the full 90 dvh
|
||||
* when it is pulled up or its grip is tapped (the sheet primitive's preview
|
||||
* step). Pulled down it closes, from either height.
|
||||
*/
|
||||
function DaySheet({
|
||||
shell,
|
||||
@@ -650,51 +650,8 @@ function DaySheet({
|
||||
render: (extra: Partial<DayViewProps>) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [full, setFull] = useState(false);
|
||||
const drag = useRef<{ y: number; moved: boolean } | null>(null);
|
||||
const bottom = shell === "bottom";
|
||||
|
||||
const grabber = bottom ? (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="day-sheet-handle"
|
||||
aria-label={full ? t("day.sheetShrink") : t("day.sheetExpand")}
|
||||
aria-expanded={full}
|
||||
onPointerDown={(event) => {
|
||||
drag.current = { y: event.clientY, moved: false };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const start = drag.current;
|
||||
if (!start) return;
|
||||
if (Math.abs(event.clientY - start.y) > 8) start.moved = true;
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const start = drag.current;
|
||||
drag.current = null;
|
||||
if (!start) return;
|
||||
const dy = event.clientY - start.y;
|
||||
if (!start.moved) return;
|
||||
if (dy < -40) setFull(true);
|
||||
else if (dy > 60) {
|
||||
if (full) setFull(false);
|
||||
else onDismiss();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
// A drag ends in a click as well; only a tap toggles.
|
||||
if (drag.current?.moved) return;
|
||||
setFull((value) => !value);
|
||||
}}
|
||||
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
className="bg-muted-foreground/40 h-1.5 w-9 rounded-full"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open
|
||||
@@ -705,10 +662,15 @@ function DaySheet({
|
||||
<SheetContent
|
||||
ref={panelRef as React.Ref<HTMLDivElement>}
|
||||
side={bottom ? "bottom" : "right"}
|
||||
preview={bottom ? "58dvh" : undefined}
|
||||
previewLabels={
|
||||
bottom
|
||||
? { expand: t("day.sheetExpand"), shrink: t("day.sheetShrink") }
|
||||
: undefined
|
||||
}
|
||||
showCloseButton={false}
|
||||
data-slot="day-panel"
|
||||
data-shell={shell}
|
||||
data-full={bottom && full ? "true" : undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
focusTitle();
|
||||
@@ -730,30 +692,18 @@ function DaySheet({
|
||||
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.
|
||||
"bg-card gap-0 p-0 data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none",
|
||||
"bg-card gap-0 p-0",
|
||||
bottom
|
||||
? cn(
|
||||
"rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none",
|
||||
// Full height leaves a strip of the page above the
|
||||
// sheet, and never less than the status bar of an
|
||||
// installed app plus a margin: a 2.5rem strip put the
|
||||
// handle under a 59 px Dynamic Island.
|
||||
full
|
||||
? "h-[calc(100dvh-max(2.5rem,env(safe-area-inset-top,0px)+0.75rem))]"
|
||||
: "h-[58dvh]",
|
||||
)
|
||||
: "w-105 max-w-[90vw] sm:max-w-105",
|
||||
? "rounded-t-2xl"
|
||||
: // 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.
|
||||
"w-105 max-w-[90vw] data-[state=closed]:duration-200 data-[state=open]:duration-300 motion-reduce:animate-none sm:max-w-105",
|
||||
)}
|
||||
>
|
||||
<SheetDescription className="sr-only">
|
||||
{t("day.description")}
|
||||
</SheetDescription>
|
||||
{render({
|
||||
above: grabber,
|
||||
headerClassName: bottom ? undefined : "py-3",
|
||||
})}
|
||||
{render({ headerClassName: bottom ? undefined : "py-3" })}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
@@ -75,8 +75,6 @@ export interface DayViewProps {
|
||||
}>;
|
||||
titleId: string;
|
||||
titleRef?: Ref<HTMLHeadingElement>;
|
||||
/** Shell chrome above the header (the bottom sheet's grabber). */
|
||||
above?: ReactNode;
|
||||
/** Classes for the header row, which the docked shell aligns to the top bar. */
|
||||
headerClassName?: string;
|
||||
}
|
||||
@@ -103,7 +101,6 @@ export function DayView({
|
||||
Title,
|
||||
titleId,
|
||||
titleRef,
|
||||
above,
|
||||
headerClassName,
|
||||
}: DayViewProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -162,16 +159,20 @@ export function DayView({
|
||||
<div
|
||||
data-slot="day-view"
|
||||
data-day={date}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
// `bg-inherit` passes the sheet's surface down to the footer, which
|
||||
// paints it while it stands over the body (see `day-footer`).
|
||||
className={cn("flex min-h-0 flex-1 flex-col", compact && "bg-inherit")}
|
||||
>
|
||||
{above}
|
||||
<div
|
||||
data-slot="day-header"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1",
|
||||
// The date starts on the body's reading edge (`px-4` / `px-6`).
|
||||
compact
|
||||
? "pt-0.5 pr-2 pb-2 pl-4"
|
||||
? // Under the sheet's grip: the title row's centre lands where
|
||||
// every bottom sheet puts it, 52 px down. The header drags
|
||||
// the sheet, so it never pans the page.
|
||||
"touch-none pt-1.5 pr-2 pb-2 pl-4"
|
||||
: "border-border border-b pr-3 pl-6",
|
||||
headerClassName,
|
||||
)}
|
||||
@@ -257,6 +258,7 @@ export function DayView({
|
||||
|
||||
<div
|
||||
data-slot="day-body"
|
||||
data-sheet-body
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto overscroll-contain",
|
||||
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-4 pb-6",
|
||||
@@ -342,11 +344,16 @@ export function DayView({
|
||||
{canCapture || (timelineOn && !onTimeline) ? (
|
||||
<div
|
||||
data-slot="day-footer"
|
||||
// On the phone sheet's preview step the footer is held on the
|
||||
// window's bottom edge while the sheet moves (`bottom-sheet.tsx`),
|
||||
// over the body: it carries the sheet's own background for that.
|
||||
data-sheet-footer
|
||||
className={cn(
|
||||
"border-border flex shrink-0 items-center justify-between gap-2 border-t",
|
||||
compact
|
||||
? "px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))]"
|
||||
: "px-6 py-3.5",
|
||||
compact && "bg-inherit",
|
||||
)}
|
||||
>
|
||||
{timelineOn && !onTimeline ? (
|
||||
|
||||
@@ -31,7 +31,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
|
||||
@@ -110,6 +117,26 @@ const BULK_VISIT_OPTIONS = 12;
|
||||
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
|
||||
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
|
||||
|
||||
/**
|
||||
* The query string in the address bar, read at render time.
|
||||
*
|
||||
* The open sheet follows `?doc=`, and `useSearchParams` is not a faithful
|
||||
* copy of it: Next 16.4 applies the copy of a `pushState` later than the write,
|
||||
* and a Back pressed in between moves the address bar from `?doc=` to the
|
||||
* page without it while `useSearchParams` can read "no document" on both sides.
|
||||
* The sheet then saw no change and stayed open. `window.location` is never
|
||||
* behind; `popstate` re-reads it on every traversal, and every other render
|
||||
* (including the ones Next's own navigations cause) reads it afresh.
|
||||
*/
|
||||
function subscribeToTraversal(onChange: () => void): () => void {
|
||||
window.addEventListener("popstate", onChange);
|
||||
return () => window.removeEventListener("popstate", onChange);
|
||||
}
|
||||
|
||||
function readLocationSearch(): string {
|
||||
return window.location.search;
|
||||
}
|
||||
|
||||
export function documentSelectionHistoryState(
|
||||
documentId: string,
|
||||
): Record<string, unknown> {
|
||||
@@ -818,7 +845,13 @@ export function DocumentsView() {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const handedOffDocumentRef = useRef<string | null>(null);
|
||||
|
||||
const rawDocParam = searchParams.get("doc");
|
||||
// Before hydration the server's view of the query stands in for the URL.
|
||||
const locationSearch = useSyncExternalStore(
|
||||
subscribeToTraversal,
|
||||
readLocationSearch,
|
||||
() => searchParams.toString(),
|
||||
);
|
||||
const rawDocParam = new URLSearchParams(locationSearch).get("doc");
|
||||
const docParam =
|
||||
rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam)
|
||||
? rawDocParam
|
||||
@@ -837,14 +870,14 @@ export function DocumentsView() {
|
||||
|
||||
const openDetail = useCallback(
|
||||
(id: string) => {
|
||||
const href = documentSelectionHref(pathname, searchParams.toString(), id);
|
||||
const href = documentSelectionHref(pathname, window.location.search, id);
|
||||
if (href === null) return;
|
||||
handedOffDocumentRef.current = null;
|
||||
setDetailId(id);
|
||||
setDetailOpen(true);
|
||||
window.history.pushState(documentSelectionHistoryState(id), "", href);
|
||||
},
|
||||
[pathname, searchParams],
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const handleDetailOpenChange = useCallback((open: boolean) => {
|
||||
@@ -881,7 +914,7 @@ export function DocumentsView() {
|
||||
handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff(
|
||||
window.history,
|
||||
pathname,
|
||||
searchParams.toString(),
|
||||
window.location.search,
|
||||
docParam,
|
||||
handedOffDocumentRef.current,
|
||||
coachLaunch?.closeIntent,
|
||||
@@ -925,7 +958,6 @@ export function DocumentsView() {
|
||||
docParam,
|
||||
moduleEnabled,
|
||||
pathname,
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
// Hover/focus intent prefetches the detail METADATA (never the blob —
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* button reads as doing nothing (one half of the recurring
|
||||
* "no briefing today" chain). The read path stays a plain GET.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const apiFetchRaw = vi.fn();
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiFetchRaw: (...a: unknown[]) => apiFetchRaw(...a),
|
||||
}));
|
||||
|
||||
import { fetchAdvisor } from "../use-insights-advisor";
|
||||
import { fetchAdvisor, forceAdvisorTimeoutMs } from "../use-insights-advisor";
|
||||
|
||||
function jsonResponse(data: unknown): Response {
|
||||
return {
|
||||
@@ -74,3 +74,56 @@ describe("fetchAdvisor — explicit regenerate forces a generation", () => {
|
||||
expect(result.payload?.dailyBriefing).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAdvisor — the regenerate waits as long as the response timeout allows (#1267)", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/** A POST that only ends when its signal aborts, like a slow model. */
|
||||
function hangUntilAborted() {
|
||||
apiFetchRaw.mockImplementation(
|
||||
(_url: string, init: { signal: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () =>
|
||||
reject(new DOMException("aborted", "AbortError")),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("is still waiting at 45 s and at 10 min with a 600 s setting", async () => {
|
||||
vi.useFakeTimers();
|
||||
hangUntilAborted();
|
||||
let settled: unknown = null;
|
||||
void fetchAdvisor({ force: true, responseTimeoutMs: 600_000 }).then(
|
||||
(r) => (settled = r),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(45_001);
|
||||
expect(settled).toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||
expect(settled).toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(forceAdvisorTimeoutMs(600_000));
|
||||
expect(settled).toEqual({ payload: null, outcome: "timeout" });
|
||||
});
|
||||
|
||||
it("never waits less than the briefing's own 180 s per call", () => {
|
||||
// 60 s is what the payload publishes when nothing is set; the route still
|
||||
// gives each briefing call 180 s.
|
||||
expect(forceAdvisorTimeoutMs(60_000)).toBeGreaterThanOrEqual(2 * 180_000);
|
||||
expect(forceAdvisorTimeoutMs(undefined)).toBe(
|
||||
forceAdvisorTimeoutMs(60_000),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the read path at its fast 8 s bound", async () => {
|
||||
vi.useFakeTimers();
|
||||
hangUntilAborted();
|
||||
let settled: unknown = null;
|
||||
void fetchAdvisor({ responseTimeoutMs: 600_000 }).then(
|
||||
(r) => (settled = r),
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(8_001);
|
||||
expect(settled).toEqual({ payload: null, outcome: "timeout" });
|
||||
});
|
||||
});
|
||||
@@ -713,7 +713,7 @@ function InsightsTabStripImpl({
|
||||
//
|
||||
// Refs #786 — the edge watches the COMBINED busy state (`regenerating ||
|
||||
// regenerateSettling`): a timed-out regenerate flows straight into
|
||||
// settling, so the spinner never falls at 45 s and NO error toast fires
|
||||
// settling, so the spinner never falls at the client abort and NO error toast fires
|
||||
// while the server may still deliver. The edge fires once settling
|
||||
// resolves, with the proven outcome — `"fresh"` (success) or
|
||||
// `"settle-failed"` (its own honest error copy). The mapping is the pure
|
||||
|
||||
@@ -17,7 +17,9 @@ import type {
|
||||
} from "@/lib/ai/schema";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAiCapability, useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { clientAbortMs } from "@/lib/ai/effective-timeout";
|
||||
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
|
||||
|
||||
/**
|
||||
@@ -116,16 +118,29 @@ const ADVISOR_TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* v1.15.18 — the user-initiated regenerate (`force`) POSTs an INLINE
|
||||
* generation: a ~1500-token warm completion that routinely runs longer
|
||||
* than the 8 s read budget. Bounding the force branch at 8 s silently
|
||||
* discarded a slow-but-successful generation — the abort returned a null
|
||||
* payload, the mutation only invalidated (re-reading the OLD cache), yet
|
||||
* the server had often already written the fresh briefing. Give the force
|
||||
* branch a generous 45 s client budget so a slow success is kept, not
|
||||
* dropped. The READ path stays fast at 8 s — only the explicit user tap
|
||||
* may wait. Per `.planning/v1.15.18-daily-briefing-audit.md` Fix 1b.
|
||||
* generation that routinely runs longer than the 8 s read budget; bounding it
|
||||
* at 8 s discarded a slow-but-successful generation.
|
||||
*
|
||||
* #1267 — the budget follows the person's response-timeout setting instead of
|
||||
* a constant 45 s, which cut every regenerate on a slow local model while the
|
||||
* server still had minutes left. The route makes up to two sequential model
|
||||
* calls (the generation and its grounding retry), and a reasoning model that
|
||||
* answered nothing is asked once more inside the first, so three calls at the
|
||||
* per-call timeout, at least the briefing's own 180 s default each. Never
|
||||
* shorter than the server: the abort is only for a request nobody answers.
|
||||
*/
|
||||
const FORCE_ADVISOR_TIMEOUT_MS = 45_000;
|
||||
const FORCE_ADVISOR_MODEL_CALLS = 3;
|
||||
|
||||
/** The client budget for a forced regenerate (exported for the test). */
|
||||
export function forceAdvisorTimeoutMs(
|
||||
responseTimeoutMs: number | null | undefined,
|
||||
): number {
|
||||
return clientAbortMs(
|
||||
responseTimeoutMs,
|
||||
FORCE_ADVISOR_MODEL_CALLS,
|
||||
AI_BUDGETS.comprehensive.timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.7 — poll cadence + ceiling while the server reports
|
||||
@@ -146,7 +161,7 @@ export const ADVISOR_REVALIDATE_POLL_MAX_ATTEMPTS = 10;
|
||||
*
|
||||
* Async-truth contract, client end (the server end is documented at the head
|
||||
* of `src/app/api/insights/generate/route.ts`): the force POST generates
|
||||
* INLINE and keeps running after the client aborts at 45 s — the server
|
||||
* INLINE and keeps running after the client aborts — the server
|
||||
* usually writes the fresh cache moments later. A page that knows work is
|
||||
* running must keep saying so until it can prove an outcome, so the timeout
|
||||
* is NOT surfaced as an error. Instead the hook remembers the baseline it was
|
||||
@@ -276,12 +291,18 @@ interface AdvisorFetchResult {
|
||||
* no-briefing-today chain). The wire-contract test pins it.
|
||||
*/
|
||||
export async function fetchAdvisor(
|
||||
options: { force?: boolean } = {},
|
||||
options: {
|
||||
force?: boolean;
|
||||
/** `ai.provider.responseTimeoutMs` from the account payload. */
|
||||
responseTimeoutMs?: number | null;
|
||||
} = {},
|
||||
): Promise<AdvisorFetchResult> {
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
options.force ? FORCE_ADVISOR_TIMEOUT_MS : ADVISOR_TIMEOUT_MS,
|
||||
options.force
|
||||
? forceAdvisorTimeoutMs(options.responseTimeoutMs)
|
||||
: ADVISOR_TIMEOUT_MS,
|
||||
);
|
||||
let res: Response;
|
||||
try {
|
||||
@@ -437,6 +458,7 @@ export function useInsightsAdvisorQuery(
|
||||
enabled: boolean,
|
||||
): UseInsightsAdvisorResult {
|
||||
const briefing = useAiCapability("briefing");
|
||||
const { responseTimeoutMs } = useAiProviderState();
|
||||
const queryClient = useQueryClient();
|
||||
// Refs #786 — settling state for a timed-out force regenerate (see the
|
||||
// contract comment above `AdvisorSettleState`). `settleOutcome` carries the
|
||||
@@ -500,7 +522,7 @@ export function useInsightsAdvisorQuery(
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => fetchAdvisor({ force: true }),
|
||||
mutationFn: () => fetchAdvisor({ force: true, responseTimeoutMs }),
|
||||
onSuccess: (result) => {
|
||||
if (result.payload) {
|
||||
// A genuinely fresh generation landed — write it into the shared
|
||||
@@ -509,7 +531,7 @@ export function useInsightsAdvisorQuery(
|
||||
} else {
|
||||
// No fresh payload (timeout / no-provider / transient). The server's
|
||||
// inline POST may STILL have written a fresh briefing after the
|
||||
// client gave up at 45 s, so re-read the GET to converge — but the
|
||||
// client gave up, so re-read the GET to converge — but the
|
||||
// honest toast is gated on `regenerateOutcome` below, not on this
|
||||
// invalidate.
|
||||
//
|
||||
|
||||
@@ -274,7 +274,9 @@ export function BottomNav() {
|
||||
// The sheet auto-sizes to its content; padding-bottom respects
|
||||
// the iOS safe area so the inner buttons stay reachable above
|
||||
// the home-indicator on iPhone-X-class devices.
|
||||
className="shell-desktop:hidden mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||
// `gap-0`: the grip sits flush above the header, and the grid
|
||||
// takes the header's old 16 px gap as its own top padding.
|
||||
className="shell-desktop:hidden mx-auto max-w-md gap-0 rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
|
||||
data-testid="bottom-nav-more-sheet"
|
||||
>
|
||||
<SheetHeader>
|
||||
@@ -286,7 +288,7 @@ export function BottomNav() {
|
||||
(de "Benachrichtigungen") then have room to break onto a clean
|
||||
second line instead of clipping the last character at the tile
|
||||
edge. */}
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pb-4">
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pt-4 pb-4">
|
||||
{moreHub.map((item) => {
|
||||
const isActive = isActiveLink(item.href);
|
||||
return (
|
||||
|
||||
@@ -192,12 +192,12 @@ export function GlobalShortcuts() {
|
||||
setShortcutsHelpOpen(true);
|
||||
return;
|
||||
case "day": {
|
||||
const date = state.openDay;
|
||||
if (date === null) return;
|
||||
if (state.openDay === null) return;
|
||||
event.preventDefault();
|
||||
// No day after today: the layer refuses it, so the step does too.
|
||||
if (action.delta > 0 && date >= state.today) return;
|
||||
stepDay(date, action.delta);
|
||||
// The step reads the day from the URL, which is ahead of
|
||||
// `openDay` while a previous step is still rendering, and refuses
|
||||
// a day after today itself.
|
||||
stepDay(action.delta, state.today);
|
||||
return;
|
||||
}
|
||||
case "day-toggle":
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/lib/profile/height-unit-display";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
AnamnesisCard,
|
||||
@@ -153,6 +154,7 @@ export function BaselineForm({
|
||||
allergies: "",
|
||||
});
|
||||
const [baseAboutMe, setBaseAboutMe] = useState("");
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -229,7 +231,9 @@ export function BaselineForm({
|
||||
// collapsed card never round-trips).
|
||||
const aboutMeBody = buildAnamnesisAboutMeBody(baseAboutMe, anamnesis);
|
||||
if (aboutMeBody) {
|
||||
await apiPut("/api/coach/about-me", aboutMeBody);
|
||||
await apiPut("/api/coach/about-me", aboutMeBody, {
|
||||
signal: AbortSignal.timeout(saveTimeoutMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
await onConfirmed();
|
||||
|
||||
@@ -28,6 +28,13 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}));
|
||||
|
||||
// #1267 — the about-me save's abort reads the account payload; the
|
||||
// react-query mock above has no client to read it from.
|
||||
vi.mock("@/hooks/use-ai-capability", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/hooks/use-ai-capability")>()),
|
||||
useAboutMeSaveTimeoutMs: () => 90_000,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
@@ -32,6 +32,14 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// #1267 — the about-me save's abort reads the account payload; the
|
||||
// react-query mock above has no client to read it from.
|
||||
vi.mock("@/hooks/use-ai-capability", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/hooks/use-ai-capability")>()),
|
||||
useAboutMeSaveTimeoutMs: () => 90_000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
|
||||
@@ -38,7 +38,10 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { SettingsInfoTile } from "@/components/settings/_info-tile";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
useAboutMeSaveTimeoutMs,
|
||||
useAiCapability,
|
||||
} from "@/hooks/use-ai-capability";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -82,6 +85,7 @@ export function AboutMeNoteManager() {
|
||||
// there is a note to clear.
|
||||
const hasNote = savedNote.length > 0;
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
// Send only the note. `conditions` / `coachFocus` / `allergies` are
|
||||
@@ -93,6 +97,7 @@ export function AboutMeNoteManager() {
|
||||
return apiPut<AboutMeData>(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken(input, query.data?.updatedAt ?? undefined),
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
);
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
import {
|
||||
isConflict,
|
||||
readUpdatedAtToken,
|
||||
@@ -72,6 +73,7 @@ export function AiProfileInclusionManager() {
|
||||
(section) => draft.includes(section) !== saved.includes(section),
|
||||
);
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: (sections: HealthProfileAiSection[]) =>
|
||||
@@ -84,6 +86,7 @@ export function AiProfileInclusionManager() {
|
||||
readUpdatedAtToken(queryClient, queryKeys.coachAboutMe()) ??
|
||||
undefined,
|
||||
),
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
|
||||
|
||||
const FALLBACK_FIELD_MAX_CHARS = 500;
|
||||
@@ -75,6 +76,7 @@ export function AllergyFreeTextNote({
|
||||
const value = draft ?? savedLine;
|
||||
const dirty = value !== savedLine;
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
// Send only the allergies line — omitted fields are preserved server-side
|
||||
@@ -84,6 +86,7 @@ export function AllergyFreeTextNote({
|
||||
return apiPut<AboutMeData>(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken(input, query.data?.updatedAt ?? undefined),
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
|
||||
const FALLBACK_FIELD_MAX_CHARS = 500;
|
||||
|
||||
@@ -76,13 +77,18 @@ export function ConditionsManager() {
|
||||
value.conditions !== saved.conditions ||
|
||||
value.coachFocus !== saved.coachFocus;
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const save = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: async (input: { conditions: string; coachFocus: string }) =>
|
||||
apiPut<AboutMeData>("/api/coach/about-me", {
|
||||
conditions: input.conditions,
|
||||
coachFocus: input.coachFocus,
|
||||
}),
|
||||
apiPut<AboutMeData>(
|
||||
"/api/coach/about-me",
|
||||
{
|
||||
conditions: input.conditions,
|
||||
coachFocus: input.coachFocus,
|
||||
},
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
),
|
||||
onSuccess: () => {
|
||||
toast.success(t("records.conditions.savedToast"));
|
||||
setDrafts({ conditions: null, coachFocus: null });
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
|
||||
import { withBaseToken } from "@/lib/api/optimistic-token";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -90,12 +91,14 @@ export function FreeTextTakeOver({
|
||||
showErrorToast(t("records.life.takeOver.saveError"), { cause: error }),
|
||||
});
|
||||
|
||||
const saveTimeoutMs = useAboutMeSaveTimeoutMs();
|
||||
const clear = useMutation({
|
||||
mutationKey: queryKeys.coachAboutMe(),
|
||||
mutationFn: () =>
|
||||
apiPut(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken({ [field]: "" }, aboutMe.data?.updatedAt ?? undefined),
|
||||
{ signal: AbortSignal.timeout(saveTimeoutMs) },
|
||||
),
|
||||
onSuccess: () => {
|
||||
toastWrittenOutcome("success", t("records.life.takeOver.clearedToast"));
|
||||
|
||||
@@ -17,6 +17,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
}),
|
||||
// v1.41 — the thinking depth reads the resolved block; none here.
|
||||
useCoachReasoning: () => null,
|
||||
// #1267 — the about-me save's abort, from the response-timeout setting.
|
||||
useAboutMeSaveTimeoutMs: () => 90_000,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
|
||||
@@ -33,6 +33,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
// #1267 — the about-me save's abort, from the response-timeout setting.
|
||||
useAboutMeSaveTimeoutMs: () => 90_000,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -99,6 +99,12 @@ describe.each([
|
||||
expect(select).toMatch(/<option value="" selected="">/);
|
||||
});
|
||||
|
||||
it("says the daily briefing does not follow this setting (#1267)", () => {
|
||||
const html = render(form(provider({})));
|
||||
expect(html).toContain('data-slot="reasoning-effort-background-note"');
|
||||
expect(html).toContain(en.settings.ai.reasoning.backgroundNote);
|
||||
});
|
||||
|
||||
it("seeds the stored value", () => {
|
||||
const select = selectMarkup(render(form(provider(stored("none")))), id);
|
||||
expect(select).toMatch(/<option value="none" selected="">/);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Same Label + NativeSelect + hint layout as the model field above it.
|
||||
* The empty value is Default: nothing is sent and the model decides.
|
||||
* v1.41 — the Coach reads its own thinking depth from the Coach settings;
|
||||
* a line under the hint says so.
|
||||
* a line under the hint says so. #1267 — so do the background jobs (the
|
||||
* daily briefing among them); a second line says that too.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -66,6 +67,15 @@ export function ReasoningEffortField({
|
||||
>
|
||||
{t("settings.ai.reasoning.coachNote")}
|
||||
</p>
|
||||
{/* #1267 — background jobs resolve their own level on the server
|
||||
(`BACKGROUND_REASONING_LEVEL`, the operator's cap, the usage level);
|
||||
this select never reaches them, so say so where it is set. */}
|
||||
<p
|
||||
data-slot="reasoning-effort-background-note"
|
||||
className="text-muted-foreground mt-1 text-xs"
|
||||
>
|
||||
{t("settings.ai.reasoning.backgroundNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* The bottom sheet's anatomy, rendered statically: the grip, the 44 px X on
|
||||
* the title row, the preview step's wiring, and that every bottom sheet
|
||||
* (`<SheetContent side="bottom">`) takes this path while the other sides do
|
||||
* not. Radix portals do not render on the server, so its Dialog parts are
|
||||
* passed through as plain elements; what Radix itself does (focus, Escape,
|
||||
* the backdrop) and the drag in a browser are `e2e/bottom-sheet.spec.ts`.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock("radix-ui", () => {
|
||||
type P = { children?: ReactNode; [key: string]: unknown };
|
||||
const strip = ({ asChild: _asChild, ...rest }: P) => rest;
|
||||
return {
|
||||
Dialog: {
|
||||
Root: ({ children }: P) => <>{children}</>,
|
||||
Portal: ({ children }: P) => <>{children}</>,
|
||||
Overlay: (props: P) => <div data-mock="overlay" {...strip(props)} />,
|
||||
Content: ({ children, ...props }: P) => (
|
||||
<div role="dialog" {...strip(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Close: ({ children, ...props }: P) => (
|
||||
<button type="button" {...strip(props)}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Title: ({ children }: P) => <h2>{children}</h2>,
|
||||
Description: ({ children }: P) => <p>{children}</p>,
|
||||
Trigger: ({ children }: P) => <>{children}</>,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { BottomSheetContent, BOTTOM_SHEET_CLASS } =
|
||||
await import("../bottom-sheet");
|
||||
const { SheetContent } = await import("../sheet");
|
||||
|
||||
function attr(html: string, slot: string): string {
|
||||
const match = new RegExp(`<[^>]*data-slot="${slot}"[^>]*>`).exec(html);
|
||||
return match?.[0] ?? "";
|
||||
}
|
||||
|
||||
describe("<BottomSheetContent>", () => {
|
||||
it("draws a decorative grip and a 44 px X on the title row", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent>body</BottomSheetContent>,
|
||||
);
|
||||
const grip = attr(html, "sheet-grip");
|
||||
expect(grip).toContain('aria-hidden="true"');
|
||||
expect(grip).toContain("h-6");
|
||||
expect(grip).toContain("touch-none");
|
||||
expect(html).toContain("h-1.25 w-9 rounded-full");
|
||||
const close = attr(html, "sheet-close-button");
|
||||
expect(close).toContain("size-11");
|
||||
expect(close).toContain("top-7.5");
|
||||
expect(html).toContain("common.close");
|
||||
// The swipe closes through a Radix Close of its own, out of reach.
|
||||
const swipe = attr(html, "sheet-swipe-close");
|
||||
expect(swipe).toContain("hidden");
|
||||
expect(swipe).toContain('tabindex="-1"');
|
||||
});
|
||||
|
||||
it("keeps its backdrop out of touch panning", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent>body</BottomSheetContent>,
|
||||
);
|
||||
expect(attr(html, "sheet-overlay")).toContain("touch-none");
|
||||
});
|
||||
|
||||
it("drops the grip, the drag and the X offset when it is not draggable", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent draggable={false}>body</BottomSheetContent>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sheet-grip"');
|
||||
expect(html).not.toContain('data-slot="sheet-swipe-close"');
|
||||
expect(attr(html, "sheet-close-button")).toContain("top-3");
|
||||
});
|
||||
|
||||
it("opens a preview sheet on its preview step, with a grip that expands it", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent
|
||||
preview="58dvh"
|
||||
previewLabels={{ expand: "Expand", shrink: "Shrink" }}
|
||||
>
|
||||
body
|
||||
</BottomSheetContent>,
|
||||
);
|
||||
const content = attr(html, "sheet-content");
|
||||
expect(content).toContain('data-sheet-snap="preview"');
|
||||
expect(content).toContain("--sheet-height:90dvh");
|
||||
expect(content).toContain("--sheet-preview-height:58dvh");
|
||||
expect(content).toContain("h-(--sheet-height)");
|
||||
const grip = attr(html, "sheet-grip");
|
||||
expect(grip).toMatch(/^<button/);
|
||||
expect(grip).toContain('aria-label="Expand"');
|
||||
expect(grip).toContain('aria-expanded="false"');
|
||||
});
|
||||
|
||||
it("names its side for the keyboard rule and lets a consumer slot win", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<BottomSheetContent data-slot="mine" className="bg-card gap-0">
|
||||
body
|
||||
</BottomSheetContent>,
|
||||
);
|
||||
expect(html).toContain('data-sheet-side="bottom"');
|
||||
expect(html).toContain('data-slot="mine"');
|
||||
const content = attr(html, "mine");
|
||||
expect(content).toMatch(/\bbg-card\b/);
|
||||
expect(content).not.toContain("bg-background");
|
||||
expect(content).not.toMatch(/\bgap-4\b/);
|
||||
});
|
||||
|
||||
it("moves, opens and closes on its own timing and stays still under reduced motion", () => {
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("data-[state=open]:duration-260");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("data-[state=closed]:duration-220");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("ease-out");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("motion-reduce:animate-none");
|
||||
expect(BOTTOM_SHEET_CLASS).toContain("max-h-[90dvh]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<SheetContent>", () => {
|
||||
it("renders every bottom sheet through the bottom sheet", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetContent side="bottom">body</SheetContent>,
|
||||
);
|
||||
expect(html).toContain('data-slot="sheet-grip"');
|
||||
expect(html).toContain('data-sheet-side="bottom"');
|
||||
});
|
||||
|
||||
it("leaves the side sheets as they were", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetContent side="right" preview="58dvh">
|
||||
body
|
||||
</SheetContent>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="sheet-grip"');
|
||||
expect(html).not.toContain("data-sheet-snap");
|
||||
expect(html).toContain('data-sheet-side="right"');
|
||||
expect(attr(html, "sheet-close-button")).toContain("top-3 right-4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { useSheetDrag } from "@/hooks/use-sheet-drag";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { SheetSnap } from "@/lib/sheet-gesture";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The bottom sheet: every `<SheetContent side="bottom">` renders through
|
||||
* here, and so does `ResponsiveSheet` on a phone.
|
||||
*
|
||||
* It is Radix's Dialog with a drag on top. Focus moving in, the focus trap,
|
||||
* Escape, the backdrop tap, `role="dialog"` and its name, and the scroll
|
||||
* lock on the page behind all stay Radix's. What this adds:
|
||||
*
|
||||
* - focus goes back to whatever opened the sheet (Radix only knows a
|
||||
* `Dialog.Trigger`, which most openers are not), unless the caller's
|
||||
* `onCloseAutoFocus` places it itself; and `aria-modal`;
|
||||
* - a grip at the top edge; the grip and the header drag the sheet always,
|
||||
* the content only while it is scrolled to its very top
|
||||
* (`use-sheet-drag.ts`, rules in `lib/sheet-gesture.ts`);
|
||||
* - a release past a quarter of the height, or a flick down, closes it,
|
||||
* anything less springs back; sideways movement never counts;
|
||||
* - the backdrop lightens as the sheet goes down, and neither it nor the
|
||||
* grip and header ever scroll or pull the page behind;
|
||||
* - opt in, a preview step (`preview`): the sheet opens at that height and
|
||||
* a pull up, or a tap on the grip, takes it to the full 90 dvh. Its
|
||||
* footer (`data-sheet-footer`) stays on the window's bottom edge and its
|
||||
* scrolling body (`data-sheet-body`) ends above it on either step.
|
||||
*
|
||||
* The X sits on the title row, 44 px square; the keyboard and a screen
|
||||
* reader close the sheet through it or Escape, never through a gesture.
|
||||
*/
|
||||
|
||||
export interface BottomSheetOptions {
|
||||
/**
|
||||
* Opens the sheet at this height (a CSS length, e.g. `58dvh`) with the
|
||||
* full 90 dvh one pull up. Needs `previewLabels` for the grip, which then
|
||||
* becomes a button that toggles the two steps.
|
||||
*/
|
||||
preview?: string;
|
||||
previewLabels?: { expand: string; shrink: string };
|
||||
/**
|
||||
* False leaves out the grip and the drag, for a sheet that fills the whole
|
||||
* window (the command palette): there is no backdrop to pull it towards.
|
||||
*/
|
||||
draggable?: boolean;
|
||||
}
|
||||
|
||||
export type BottomSheetContentProps = React.ComponentProps<
|
||||
typeof SheetPrimitive.Content
|
||||
> &
|
||||
BottomSheetOptions & { showCloseButton?: boolean };
|
||||
|
||||
/**
|
||||
* No `bottom-*` here: the sheet's `bottom` comes from the keyboard rule in
|
||||
* `globals.css`, which a utility would outrank. In 260 ms, out 220 ms, both
|
||||
* easing out; no slide at all under reduced motion. The `after:` strip
|
||||
* continues the sheet's own background below its edge, so the short
|
||||
* resisting travel above the top never shows the page underneath.
|
||||
*/
|
||||
export const BOTTOM_SHEET_CLASS =
|
||||
"bg-background fixed inset-x-0 z-50 flex max-h-[90dvh] flex-col gap-4 border-t shadow-lg transition-transform ease-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom data-[state=open]:duration-260 data-[state=closed]:duration-220 motion-reduce:animate-none motion-reduce:transition-none after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-10 after:bg-inherit after:content-['']";
|
||||
|
||||
const OVERLAY_CLASS =
|
||||
"fixed inset-0 z-50 touch-none bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 ease-out data-[state=open]:duration-260 data-[state=closed]:duration-220 motion-reduce:animate-none";
|
||||
|
||||
/** Puts a preview sheet back on its preview step each time it opens. */
|
||||
function OnOpen({ run }: { run: () => void }) {
|
||||
React.useLayoutEffect(() => {
|
||||
run();
|
||||
// Once per mount, which is once per opening.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
function SheetGrip({
|
||||
snap,
|
||||
labels,
|
||||
onToggle,
|
||||
}: {
|
||||
snap: SheetSnap;
|
||||
labels?: { expand: string; shrink: string };
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const bar = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted-foreground/40 h-1.25 w-9 rounded-full"
|
||||
/>
|
||||
);
|
||||
if (labels) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="sheet-grip"
|
||||
aria-label={snap === "full" ? labels.shrink : labels.expand}
|
||||
aria-expanded={snap === "full"}
|
||||
onClick={onToggle}
|
||||
className="focus-visible:ring-ring/50 mx-auto flex h-6 w-16 shrink-0 touch-none items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{bar}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-grip"
|
||||
aria-hidden="true"
|
||||
className="flex h-6 shrink-0 touch-none items-center justify-center"
|
||||
>
|
||||
{bar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomSheetContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
preview,
|
||||
previewLabels,
|
||||
draggable = true,
|
||||
ref,
|
||||
style,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onPointerCancel,
|
||||
onClickCapture,
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
...props
|
||||
}: BottomSheetContentProps) {
|
||||
const { t } = useTranslations();
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const overlayRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const probeRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const closeRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
// What had focus when the sheet opened. Radix hands focus back only to a
|
||||
// `Dialog.Trigger`, and most bottom sheets open from a plain button (the
|
||||
// More tab, a chart's tooltip), so focus fell to the body on close.
|
||||
const openerRef = React.useRef<HTMLElement | null>(null);
|
||||
const initial: SheetSnap = preview ? "preview" : "full";
|
||||
const [snap, setSnap] = React.useState<SheetSnap>(initial);
|
||||
const [node, setNode] = React.useState<HTMLDivElement | null>(null);
|
||||
|
||||
const drag = useSheetDrag({
|
||||
enabled: draggable,
|
||||
hasPreview: !!preview,
|
||||
snap,
|
||||
onSnap: setSnap,
|
||||
onClose: () => closeRef.current?.click(),
|
||||
contentRef,
|
||||
overlayRef,
|
||||
probeRef,
|
||||
node,
|
||||
});
|
||||
|
||||
const setContent = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
contentRef.current = node;
|
||||
setNode(node);
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
return (
|
||||
<SheetPrimitive.Portal data-slot="sheet-portal">
|
||||
<SheetPrimitive.Overlay
|
||||
ref={overlayRef}
|
||||
data-slot="sheet-overlay"
|
||||
className={OVERLAY_CLASS}
|
||||
/>
|
||||
<SheetPrimitive.Content
|
||||
ref={setContent}
|
||||
data-slot="sheet-content"
|
||||
// Read by the keyboard rule in `globals.css`: a bottom sheet stands
|
||||
// on the on-screen keyboard. Its own attribute rather than
|
||||
// `data-slot`, which consumers replace with theirs.
|
||||
data-sheet-side="bottom"
|
||||
data-sheet-snap={preview ? snap : undefined}
|
||||
// Radix makes the page behind inert and traps focus; this says so
|
||||
// to assistive technology that reads the attribute rather than the
|
||||
// hidden siblings.
|
||||
aria-modal="true"
|
||||
style={
|
||||
preview
|
||||
? ({
|
||||
"--sheet-height": "90dvh",
|
||||
"--sheet-preview-height": preview,
|
||||
...style,
|
||||
} as React.CSSProperties)
|
||||
: style
|
||||
}
|
||||
className={cn(
|
||||
BOTTOM_SHEET_CLASS,
|
||||
preview && "h-(--sheet-height) max-h-(--sheet-height)",
|
||||
className,
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
onPointerDown?.(event);
|
||||
drag.onPointerDown?.(event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
onPointerMove?.(event);
|
||||
drag.onPointerMove?.(event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
onPointerUp?.(event);
|
||||
drag.onPointerUp?.(event);
|
||||
}}
|
||||
onPointerCancel={(event) => {
|
||||
onPointerCancel?.(event);
|
||||
drag.onPointerCancel?.(event);
|
||||
}}
|
||||
onClickCapture={(event) => {
|
||||
drag.onClickCapture?.(event);
|
||||
onClickCapture?.(event);
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
const active = document.activeElement;
|
||||
openerRef.current =
|
||||
active instanceof HTMLElement && active !== document.body
|
||||
? active
|
||||
: null;
|
||||
onOpenAutoFocus?.(event);
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
onCloseAutoFocus?.(event);
|
||||
const opener = openerRef.current;
|
||||
if (event.defaultPrevented || !opener?.isConnected) return;
|
||||
event.preventDefault();
|
||||
opener.focus({ preventScroll: true });
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{preview ? (
|
||||
<>
|
||||
<OnOpen run={() => setSnap(initial)} />
|
||||
<div
|
||||
ref={probeRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none invisible absolute top-0 left-0 w-px"
|
||||
style={{ height: preview }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{draggable ? (
|
||||
<SheetGrip
|
||||
snap={snap}
|
||||
labels={preview ? previewLabels : undefined}
|
||||
onToggle={() => setSnap(snap === "full" ? "preview" : "full")}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
{showCloseButton ? (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close-button"
|
||||
className={cn(
|
||||
// On the title row: the grip's 24 px plus the header's 16 px
|
||||
// top padding put the title's line centre 52 px down.
|
||||
draggable ? "top-7.5" : "top-3",
|
||||
"ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute right-2 inline-flex size-11 touch-none items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
)}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</SheetPrimitive.Close>
|
||||
) : null}
|
||||
{draggable ? (
|
||||
// What a swipe presses: the same close as the X, so the sheet's
|
||||
// `onOpenChange` hears one kind of close however it was asked.
|
||||
<SheetPrimitive.Close
|
||||
ref={closeRef}
|
||||
hidden
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
data-slot="sheet-swipe-close"
|
||||
/>
|
||||
) : null}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +195,9 @@ export function ResponsiveSheet({
|
||||
<SheetHeader
|
||||
data-slot="responsive-sheet-header"
|
||||
className={cn(
|
||||
"border-border/70 gap-1.5 border-b p-4",
|
||||
// Under the sheet's grip; the header drags the sheet, so a
|
||||
// touch on it never pans or pulls the page.
|
||||
"border-border/70 touch-none gap-1.5 border-b p-4",
|
||||
// Reserve the right gutter ONLY when the primitive paints its
|
||||
// absolute close button. With `showCloseButton={false}` the
|
||||
// consumer's own trailing control (headerAction) sits flush to
|
||||
|
||||
+41
-14
@@ -6,6 +6,10 @@ import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
BottomSheetContent,
|
||||
type BottomSheetOptions,
|
||||
} from "@/components/ui/bottom-sheet";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
@@ -45,14 +49,43 @@ function SheetOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sheet from one edge. `side="bottom"` is the bottom sheet
|
||||
* (`bottom-sheet.tsx`): a grip, the drag, and the options in
|
||||
* `BottomSheetOptions`, which the other sides ignore.
|
||||
*/
|
||||
function SheetContent({
|
||||
side = "right",
|
||||
preview,
|
||||
previewLabels,
|
||||
draggable,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> &
|
||||
BottomSheetOptions & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
if (side === "bottom") {
|
||||
return (
|
||||
<BottomSheetContent
|
||||
preview={preview}
|
||||
previewLabels={previewLabels}
|
||||
draggable={draggable}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <EdgeSheetContent side={side} {...props} />;
|
||||
}
|
||||
|
||||
function EdgeSheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
side,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
side: "top" | "right" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,14 +94,10 @@ function SheetContent({
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
// Read by the safe-area and on-screen-keyboard rules in
|
||||
// `globals.css`: a bottom sheet stands on the keyboard, the
|
||||
// full-height sides keep clear of the status bar and the notch.
|
||||
// Its own attribute rather than `data-slot`, which consumers
|
||||
// replace with theirs (`responsive-sheet-content`, `day-panel`).
|
||||
// A bottom sheet's `bottom` comes from that rule (0 until the
|
||||
// keyboard opens), so its branch below carries no `bottom-*`: a
|
||||
// utility would outrank the layered rule and pin it to the edge.
|
||||
// Read by the safe-area rules in `globals.css`: the full-height
|
||||
// sides keep clear of the status bar and the notch. Its own
|
||||
// attribute rather than `data-slot`, which consumers replace with
|
||||
// theirs.
|
||||
data-sheet-side={side}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
@@ -78,8 +107,6 @@ function SheetContent({
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -90,8 +117,8 @@ function SheetContent({
|
||||
// a 36 px floor, matching the Dialog close-X treatment from
|
||||
// MB2 (Decision I). 36 px (not 44 px) is the intentional
|
||||
// exception for fixed icon-only close affordances per the
|
||||
// dialog primitive's rationale: 44 px would crowd the sheet
|
||||
// header out of proportion on the bottom-sheet branch.
|
||||
// dialog primitive's rationale. The bottom sheet's X is 44 px
|
||||
// on its title row (`bottom-sheet.tsx`).
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close-button"
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-11 min-w-11 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none sm:min-h-9 sm:min-w-9 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
|
||||
Reference in new issue
Block a user