Merge branch 'fix/v1475-mobile' into release/v1.47.5

# Conflicts:
#	src/__tests__/baselines/source-function-lines.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 22:51:30 +01:00
commit ad483fa4bc
52 files changed
+1838 -1027

No files matched your search

@@ -16,7 +16,7 @@ import { renderToStaticMarkup } from "react-dom/server";
*
* The fix is aggressive: every cell that could carry a long string
* gets `truncate` (overflow-hidden + text-ellipsis + whitespace-nowrap)
* AND a tooltip on hover/long-press so the full value is still
* AND a popover on a tap so the full value is still
* discoverable. This is the same pattern UserManagementSection uses
* — apply it consistently here.
*/
@@ -83,7 +83,7 @@ function render() {
);
}
describe("ApiTokenOverviewSection — truncate + tooltip (4th attempt)", () => {
describe("ApiTokenOverviewSection — truncate + popover", () => {
it("mobile token-name span does NOT carry the contradictory `break-all` modifier", () => {
// `truncate` already sets `white-space: nowrap`, which beats
// `word-break: break-all` per CSS spec — `break-all` was dead
@@ -125,11 +125,11 @@ describe("ApiTokenOverviewSection — truncate + tooltip (4th attempt)", () => {
);
});
it("renders shadcn tooltip wrappers around truncated cells so the full value stays discoverable", () => {
it("makes each truncated cell a button that opens the full value on a tap", () => {
const html = render();
// Tooltip primitive uses `data-slot="tooltip-content"` for the
// hover bubble. Verify at least one tooltip-content node was
// rendered next to a truncated cell.
expect(html).toContain('data-slot="tooltip-trigger"');
// A hover tooltip never opens on a phone; the popover trigger does.
expect(html).toMatch(
/<button type="button"[^>]*data-slot="popover-trigger"[^>]*\btruncate\b/,
);
});
});
@@ -5,11 +5,10 @@ import { Key, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { formatDate, formatDateTime } from "@/lib/format";
import type { Formatters } from "@/lib/format-locale";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -21,8 +20,9 @@ import { type ApiTokenInfo } from "./_shared";
import { apiGet } from "@/lib/api/api-fetch";
/**
* Wraps a string in `truncate` + tooltip so a long token name /
* username / permission cannot push the row past its bounds.
* Wraps a string in `truncate` + a popover so a long token name /
* username cannot push the row past its bounds, and a tap still shows all
* of it.
*
* v1.4.19 (4th attempt) — earlier fixes (column-hide,
* mobile card-list, mobile-strip `no-scrollbar`) cleared every
@@ -34,11 +34,10 @@ import { apiGet } from "@/lib/api/api-fetch";
* desktop table cells had no upper-bound on width — a long token
* name on a 768-1024 px viewport could spill into the next column.
*
* Switching every potentially-long cell to a hover tooltip means
* the visible row stays inside the card no matter what the data
* looks like. The native `title` attribute is a graceful fallback
* for screen readers and right-click → "view full name" on desktop;
* the radix tooltip handles hover and keyboard focus.
* Truncating every potentially-long cell means the visible row stays
* inside the card no matter what the data looks like. The full value opens
* on a tap or a key (a hover tooltip never opened on a phone); `title`
* still shows it on hover.
*/
function TruncatedCell({
value,
@@ -48,16 +47,17 @@ function TruncatedCell({
className?: string;
}) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={`block truncate ${className ?? ""}`} title={value}>
{value}
</span>
</TooltipTrigger>
<TooltipContent>{value}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Popover>
<PopoverTrigger
className={`focus-visible:ring-ring/50 block w-full truncate rounded-sm text-left focus-visible:ring-2 focus-visible:outline-none ${className ?? ""}`}
title={value}
>
{value}
</PopoverTrigger>
<PopoverContent align="start" className="break-all">
{value}
</PopoverContent>
</Popover>
);
}
+103 -81
View File
@@ -4,11 +4,10 @@ import { ArrowDown, ArrowRight, ArrowUp, Minus } from "lucide-react";
import type { TrendSlope } from "@/lib/analytics/trends";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
import {
@@ -25,6 +24,43 @@ interface SecondaryMetric {
avg30: number | null;
}
/**
* An average whose colour has a meaning: a tap shows what the colour says.
* The tile is a link to the metric's page, and this tap opens the hint, not
* the page.
*/
function HintedValue({
hint,
className,
children,
}: {
hint: React.ReactNode;
className: string;
children: React.ReactNode;
}) {
return (
<span
className="contents"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<Popover>
<PopoverTrigger
className={cn(
"focus-visible:ring-ring/50 rounded-sm underline decoration-dotted underline-offset-2 focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
{children}
</PopoverTrigger>
<PopoverContent className="space-y-1">{hint}</PopoverContent>
</Popover>
</span>
);
}
interface TrendCardProps {
label: string;
latest: number | null;
@@ -389,90 +425,76 @@ export function TrendCard({
</span>
)}
</div>
<TooltipProvider>
{/* v1.4.29 — sub-row pair clips rather than wraps at `<sm`
{/* v1.4.29 — sub-row pair clips rather than wraps at `<sm`
so a narrow tile cannot grow vertically beyond the
140-px contract. `sm:` releases back to the original
wrap behaviour. */}
<div className="text-muted-foreground mt-auto flex min-w-0 flex-nowrap items-baseline gap-x-3 overflow-hidden pt-1 text-xs leading-snug sm:flex-wrap sm:gap-y-1">
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t(avg7LabelKey)}:{" "}
{avg7Hint ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span>
</TooltipTrigger>
<TooltipContent className="bg-muted border-border text-foreground">
<div className="space-y-1 text-xs">{avg7Hint}</div>
</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span>
)}
{trend7Delta != null && (
<span
className={cn(
"ml-1 font-medium [overflow-wrap:anywhere] tabular-nums",
deltaColor,
)}
data-slot="trend7-delta"
aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
>
({formatDelta(trend7Delta)})
</span>
)}
</span>
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t("charts.avg30dShort")}:{" "}
{avg30Hint ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span>
</TooltipTrigger>
<TooltipContent className="bg-muted border-border text-foreground">
<div className="space-y-1 text-xs">{avg30Hint}</div>
</TooltipContent>
</Tooltip>
) : (
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span>
)}
</span>
{/* v1.4.28 — the all-time third sub-value retired
<div className="text-muted-foreground mt-auto flex min-w-0 flex-nowrap items-baseline gap-x-3 overflow-hidden pt-1 text-xs leading-snug sm:flex-wrap sm:gap-y-1">
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t(avg7LabelKey)}:{" "}
{avg7Hint ? (
<HintedValue
hint={avg7Hint}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</HintedValue>
) : (
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg7ColorClass,
)}
>
{avg7 !== null ? renderPair(avg7, secondary?.avg7) : "—"}
</span>
)}
{trend7Delta != null && (
<span
className={cn(
"ml-1 font-medium [overflow-wrap:anywhere] tabular-nums",
deltaColor,
)}
data-slot="trend7-delta"
aria-label={`7-day trend ${formatDelta(trend7Delta)}`}
>
({formatDelta(trend7Delta)})
</span>
)}
</span>
<span className="max-w-full min-w-0 [overflow-wrap:anywhere]">
{t("charts.avg30dShort")}:{" "}
{avg30Hint ? (
<HintedValue
hint={avg30Hint}
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</HintedValue>
) : (
<span
className={cn(
"font-medium [overflow-wrap:anywhere] tabular-nums",
avg30ColorClass,
)}
>
{avg30 !== null ? renderPair(avg30, secondary?.avg30) : "—"}
</span>
)}
</span>
{/* v1.4.28 — the all-time third sub-value retired
alongside the `avgAllTime*` props. The BD-Zielbereich
tile was the only consumer; the same number stays
visible on the Insights blood-pressure target panel with
full context. Every dashboard tile now ships the same two
sub-rows. */}
</div>
</TooltipProvider>
</div>
</TrendTile>
);
}
+13 -123
View File
@@ -3,14 +3,17 @@
/**
* Quick-entry sheets for the dashboard: measurement, mood, and medication
* intake behind the shared ResponsiveSheet primitive
* (bottom-sheet on `<md`, centred Dialog on `md+`), plus the
* confirm-before-discard guard for dirty forms.
* (bottom-sheet on `<md`, centred Dialog on `md+`), which asks before a
* dismissal drops typed input.
*
* Extracted from the dashboard page; the page keeps the open-state and
* the quick-add menu that triggers each sheet.
*/
import { useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
ResponsiveSheet,
sheetBodyHasUnsavedInput,
} from "@/components/ui/responsive-sheet";
import {
ManualWorkoutForm,
MeasurementForm,
@@ -19,16 +22,6 @@ import {
SymptomEntryForm,
} from "@/components/dashboard/quick-entry-forms.lazy";
import { LifeEventForm } from "@/components/timeline/life-event-form.lazy";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
import {
useRecordCapabilities,
@@ -87,69 +80,6 @@ export function admittedQuickEntry(
return admitted ? open : null;
}
/**
* v1.11.3 — guard the quick-entry sheets against an accidental
* dismiss (overlay tap, Esc, mobile swipe-down) discarding a partly
* filled form. The forms live behind a stable `ResponsiveSheet` body
* slot and don't expose their dirty state, so we read it from the DOM
* at dismiss time: any visible text/number input or textarea that
* carries a value, or any checked checkbox/radio that isn't a default,
* counts as unsaved input. Date/time pickers are excluded — the forms
* prefill them with the current timestamp on mount, so a pristine sheet
* would otherwise always read as dirty. Cheap, synchronous, and runs
* only on the one mounted sheet body.
*/
const PREFILLED_PICKER_TYPES = new Set([
"date",
"datetime-local",
"time",
"month",
"week",
]);
export function sheetBodyHasUnsavedInput(): boolean {
if (typeof document === "undefined") return false;
const body = document.querySelector<HTMLElement>(
'[data-slot="responsive-sheet-body"]',
);
if (!body) return false;
const fields = body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
"input, textarea",
);
for (const field of fields) {
if (field.disabled || field.type === "hidden") continue;
// Skip pickers the form prefills with "now" — an untouched default
// is not user input (v1.11.3 QA H1).
if (PREFILLED_PICKER_TYPES.has(field.type)) continue;
if (field.type === "checkbox" || field.type === "radio") {
const box = field as HTMLInputElement;
if (box.checked !== box.defaultChecked) return true;
continue;
}
if (field.value.trim() !== "") return true;
}
// v1.11.5 — the input/textarea walk misses two non-text controls a user can
// change before dismissing: the mood `role="radio"` selector and a Radix
// Select (measurement type / medication). Losing either silently on a
// backdrop-dismiss is the same data-loss class the H1 confirm-on-dirty guard
// was meant to close.
//
// Mood radios start with nothing selected (`mood = ""`), so ANY
// `aria-checked="true"` is a deliberate user choice — flag it.
if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) {
return true;
}
// Radix Select trigger renders `role="combobox"`. A trigger with
// `data-state="open"` is mid-interaction (the dropdown is up) — the user is
// actively choosing, so a dismiss should confirm rather than drop the pick.
// We deliberately do NOT treat a merely non-placeholder value as dirty: the
// type / medication selects mount with a non-placeholder DEFAULT, and
// flagging that would over-trigger the confirm on a pristine sheet.
if (body.querySelector('[role="combobox"][data-state="open"]') !== null) {
return true;
}
return false;
}
export function QuickEntrySheets({
open,
onClose,
@@ -181,21 +111,8 @@ export function QuickEntrySheets({
);
const [lifeEventFooterEl, setLifeEventFooterEl] =
useState<HTMLDivElement | null>(null);
// v1.11.3 — when an open quick-entry sheet is dismissed with
// unsaved input, hold the close in this flag and surface a confirm
// instead of nulling the dialog outright. Cleared once the user
// confirms (close) or keeps editing (dismiss the confirm).
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
// Intercept a sheet dismiss: close immediately when the form is
// clean, otherwise keep the sheet open and ask before discarding.
function handleQuickEntryOpenChange(nextOpen: boolean) {
if (nextOpen) return;
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
onClose();
if (!nextOpen) onClose();
}
return (
@@ -203,6 +120,7 @@ export function QuickEntrySheets({
<ResponsiveSheet
open={openSheet === "measurement"}
onOpenChange={handleQuickEntryOpenChange}
dirty={sheetBodyHasUnsavedInput}
title={t("dashboard.quickAddMeasurement")}
footer={<div ref={setMeasurementFooterEl} className="flex w-full" />}
>
@@ -215,6 +133,7 @@ export function QuickEntrySheets({
<ResponsiveSheet
open={openSheet === "mood"}
onOpenChange={handleQuickEntryOpenChange}
dirty={sheetBodyHasUnsavedInput}
title={t("dashboard.quickAddMood")}
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
>
@@ -231,6 +150,7 @@ export function QuickEntrySheets({
<ResponsiveSheet
open={openSheet === "medicationIntake"}
onOpenChange={handleQuickEntryOpenChange}
dirty={sheetBodyHasUnsavedInput}
title={t("dashboard.quickAddMedicationIntake")}
description={t("dashboard.medicationIntakeQuickAdd.sheetDescription")}
footer={
@@ -249,6 +169,7 @@ export function QuickEntrySheets({
<ResponsiveSheet
open={openSheet === "symptom"}
onOpenChange={handleQuickEntryOpenChange}
dirty={sheetBodyHasUnsavedInput}
title={t("dashboard.quickAddSymptom")}
description={t("symptoms.entry.sheetDescription")}
footer={<div ref={setSymptomFooterEl} className="flex w-full" />}
@@ -267,6 +188,7 @@ export function QuickEntrySheets({
<ResponsiveSheet
open={openSheet === "workout"}
onOpenChange={handleQuickEntryOpenChange}
dirty={sheetBodyHasUnsavedInput}
title={t("dashboard.quickAddWorkout")}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setWorkoutFooterEl} className="flex w-full" />}
@@ -285,6 +207,7 @@ export function QuickEntrySheets({
<ResponsiveSheet
open={openSheet === "lifeEvent"}
onOpenChange={handleQuickEntryOpenChange}
dirty={sheetBodyHasUnsavedInput}
title={t("dashboard.quickAddLifeEvent")}
footer={<div ref={setLifeEventFooterEl} className="flex w-full" />}
>
@@ -296,39 +219,6 @@ export function QuickEntrySheets({
/>
)}
</ResponsiveSheet>
{/* v1.11.3 — confirm before discarding a partly-filled
quick-entry form when the sheet is dismissed by an overlay
tap, Escape, or a mobile swipe-down. Keeps the sheet open
until the user decides. */}
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
onClose();
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -131,6 +131,8 @@ export function DocumentDetailSheet({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
// `?doc=` is this sheet's history entry already (`use-vault-detail`).
historyEntry={false}
title={title}
description={t("documents.detail.title")}
contentWidth="3xl"
@@ -92,7 +92,7 @@ export function EncounterSheet({
// varying its `key`, so a second edit never shows the first one's values and
// a create after an edit starts empty — without an effect that copies props
// into state and briefly renders the stale pair.
const [draft, setDraft] = useState<EncounterDraft>(() =>
const [seed] = useState<EncounterDraft>(() =>
encounter
? draftFromEncounter(encounter)
: emptyDraft({
@@ -101,6 +101,7 @@ export function EncounterSheet({
...(kind ? { kind } : {}),
}),
);
const [draft, setDraft] = useState<EncounterDraft>(seed);
const [error, setError] = useState<string | null>(null);
const isEdit = encounter !== null;
@@ -140,6 +141,7 @@ export function EncounterSheet({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={JSON.stringify(draft) !== JSON.stringify(seed)}
title={t(isEdit ? "encounters.editTitle" : "encounters.createTitle")}
description={t("encounters.formDescription")}
footer={
@@ -90,7 +90,7 @@ export function PractitionerCombobox({
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
<PopoverContent className="w-(--radix-popover-trigger-width) max-w-none p-2">
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
@@ -1,12 +1,10 @@
"use client";
import { useId } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type {
@@ -20,7 +18,7 @@ import type {
*
* `●●●●○` filled/hollow dots driven by the `presentInputs / requiredInputs`
* ratio off `Derived<T>.coverage`, tinted by the `confidence.band`, with an
* accessible breakdown tooltip (present vs required inputs, history days,
* accessible breakdown on a tap (present vs required inputs, history days,
* and the named missing inputs). This is the one place the whole app speaks
* data-confidence — every score tile, anatomy view, correlation card,
* briefing and report header renders it identically.
@@ -29,7 +27,8 @@ import type {
* shown WITH its confidence, never silently downgraded and never blank. The
* dots read as intentional ("4 of 5 inputs · 80%"), not apologetic.
*
* CSS-only dots + a Radix `Tooltip` for the breakdown — 0 KB runtime.
* CSS-only dots + a `Popover` for the breakdown: it opens on a tap, which a
* hover tooltip never does on a phone.
*/
export interface CoverageMeterProps {
@@ -46,7 +45,7 @@ export interface CoverageMeterProps {
size?: "sm" | "md";
/**
* Replaces the bare `{present}/{required}` fraction, in the visible
* label, the `aria-label` and the tooltip heading alike.
* label, the `aria-label` and the breakdown heading alike.
*
* The default fraction reads as a proportion of the reader's own data,
* and for one caller it is not: the Health Score's composite counts
@@ -94,7 +93,6 @@ export function CoverageMeter({
className,
}: CoverageMeterProps) {
const { t } = useTranslations();
const tooltipId = useId();
const required = clampInt(coverage.requiredInputs, 0, 999);
const present = clampInt(coverage.presentInputs, 0, required || 999);
@@ -128,90 +126,86 @@ export function CoverageMeter({
});
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="coverage-meter"
data-band={band}
data-present={present}
data-required={required}
data-percent={percent}
aria-describedby={tooltipId}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
data-slot="coverage-meter"
data-band={band}
data-present={present}
data-required={required}
data-percent={percent}
className={cn(
// 44px hit target via padding while the dots stay optically
// small; negative margin collapses the row back so the meter
// doesn't inflate its host row.
"-my-3 inline-flex min-h-11 items-center gap-2 rounded px-1 py-3",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<span
data-slot="coverage-meter-dots"
className="inline-flex items-center gap-[3px]"
aria-hidden="true"
>
{Array.from({ length: totalDots }).map((_, i) => {
const isLit = i < litDots;
return (
<span
key={i}
data-dot-index={i}
data-dot-state={isLit ? "lit" : "unlit"}
className={cn(
"rounded-full",
dotSize,
isLit ? dotLit : "bg-muted/40",
)}
/>
);
})}
</span>
<span
data-slot="coverage-meter-label"
className={cn(
// 44px hit target via padding while the dots stay optically
// small; negative margin collapses the row back so the meter
// doesn't inflate its host row.
"-my-3 inline-flex min-h-11 items-center gap-2 rounded px-1 py-3",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
className,
"text-muted-foreground tabular-nums",
size === "md" ? "text-xs" : "text-2xs",
)}
>
<span
data-slot="coverage-meter-dots"
className="inline-flex items-center gap-[3px]"
aria-hidden="true"
>
{Array.from({ length: totalDots }).map((_, i) => {
const isLit = i < litDots;
return (
<span
key={i}
data-dot-index={i}
data-dot-state={isLit ? "lit" : "unlit"}
className={cn(
"rounded-full",
dotSize,
isLit ? dotLit : "bg-muted/40",
)}
/>
);
{axisLabel ??
t("insights.derived.coverage.ratioLabel", {
present,
required,
})}
</span>
<span
data-slot="coverage-meter-label"
className={cn(
"text-muted-foreground tabular-nums",
size === "md" ? "text-xs" : "text-2xs",
)}
>
{axisLabel ??
t("insights.derived.coverage.ratioLabel", {
present,
required,
})}
</span>
{/* The visible ratio names the button; the sentence adds what it
</span>
{/* The visible ratio names the button; the sentence adds what it
counts (WCAG 2.5.3: the name contains what is shown). */}
<span className="sr-only">{summaryLabel}</span>
</button>
</TooltipTrigger>
<TooltipContent
id={tooltipId}
data-slot="coverage-meter-tooltip"
className="bg-muted border-border text-foreground max-w-[15rem]"
>
<div className="space-y-1 text-xs">
<p className="font-medium">{summaryLabel}</p>
<p className="text-muted-foreground">
{t("insights.derived.coverage.historyDays", {
count: clampInt(coverage.historyDays, 0, 99999),
<span className="sr-only">{summaryLabel}</span>
</button>
</PopoverTrigger>
<PopoverContent
data-slot="coverage-meter-tooltip"
className="max-w-[15rem]"
>
<div className="space-y-1 text-xs">
<p className="font-medium">{summaryLabel}</p>
<p className="text-muted-foreground">
{t("insights.derived.coverage.historyDays", {
count: clampInt(coverage.historyDays, 0, 99999),
})}
</p>
{coverage.missing.length > 0 && (
<p
data-slot="coverage-meter-missing"
className="text-muted-foreground"
>
{t("insights.derived.coverage.missing", {
list: coverage.missing.join(", "),
})}
</p>
{coverage.missing.length > 0 && (
<p
data-slot="coverage-meter-missing"
className="text-muted-foreground"
>
{t("insights.derived.coverage.missing", {
list: coverage.missing.join(", "),
})}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -3,12 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { Lightbulb } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InfoPopover } from "@/components/ui/info-popover";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -29,7 +24,7 @@ import { detectMeasurementDiversity } from "@/lib/insights/measurement-diversity
* inline `role="note"` block confused on the category pages (it read
* like a data finding rather than a tip), so the nudge now renders as a
* small `Lightbulb` glyph beside the page title. The hint text the block
* used to show inline lives in a hover / focus Tooltip; the glyph mounts
* used to show inline opens on a tap (`InfoPopover`); the glyph mounts
* only when the spread is actually lopsided, so a healthy metric shows
* no extra affordance at all.
*
@@ -95,26 +90,15 @@ export function MeasurementDiversityNudge({
const triggerLabel = t("insights.subPage.diversity.trigger");
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="measurement-diversity-nudge"
aria-label={triggerLabel}
className="text-warning hover:text-warning/80 focus-visible:ring-ring/50 -mx-2 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Lightbulb className="size-4" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="measurement-diversity-nudge-body"
align="start"
className="max-w-xs leading-relaxed"
>
{message}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<InfoPopover
content={message}
label={triggerLabel}
icon={Lightbulb}
iconClassName="size-4"
className="text-warning hover:text-warning/80"
align="start"
triggerDataSlot="measurement-diversity-nudge"
bodyDataSlot="measurement-diversity-nudge-body"
/>
);
}
@@ -2,12 +2,7 @@
import { TriangleAlert } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { InfoPopover } from "@/components/ui/info-popover";
import { useTranslations } from "@/lib/i18n/context";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels";
@@ -17,7 +12,7 @@ import { MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS } from "@/lib/i18n/source-labels
* `sleep-hypnogram.tsx` (which introduced it in v1.28.21) so the dashboard
* sleep tile and the hypnogram render the identical hint off the identical
* wire shape. Observational only: the headline number always stays the
* winning writer's total; the tooltip lists each writer's own total so a
* winning writer's total; the popover lists each writer's own total so a
* multi-tracker user can see WHY the night reads surprising.
*/
@@ -35,7 +30,7 @@ export interface SleepSourceDiscrepancyDto {
* Localised device-type label keys (`Measurement.deviceType`), reused from
* the source-priority settings surface. Appended in parentheses so two
* writer apps behind the same source (watch vs phone under Apple Health)
* stay distinguishable in the discrepancy tooltip.
* stay distinguishable in the discrepancy popover.
*/
const DEVICE_TYPE_LABEL_KEYS: Record<string, string> = {
watch: "settings.sections.sources.deviceLabels.watch",
@@ -64,7 +59,7 @@ export function sourceDisplayName(
export interface SleepSourceDiscrepancyMarkerProps {
discrepancy: SleepSourceDiscrepancyDto | null | undefined;
/** Tooltip alignment relative to the trigger (default `"start"`). */
/** Popover alignment relative to the trigger (default `"start"`). */
align?: "start" | "center" | "end";
}
@@ -82,36 +77,36 @@ export function SleepSourceDiscrepancyMarker({
const { t } = useTranslations();
if (!discrepancy) return null;
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-slot="sleep-source-discrepancy"
aria-label={t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
className="text-muted-foreground focus-visible:ring-ring/50 -mx-3 -my-3 inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full focus-visible:ring-2 focus-visible:outline-none"
>
<TriangleAlert className="size-3.5" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent
data-slot="sleep-source-discrepancy-body"
align={align}
className="max-w-xs leading-relaxed"
>
<p className="font-medium">
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
</p>
<p>
// On the dashboard the marker sits in the tile's link: its tap opens the
// popover, not the tile.
<span
className="contents"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<InfoPopover
content={
<>
<span className="text-foreground block font-medium">
{t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
</span>
{discrepancy.sources
.map(
(b) =>
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatDurationMinutes(b.asleepMinutes, t)}`,
)
.join(" · ")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
}
label={t("insights.sleep.sourceDiscrepancy.tooltipTitle")}
icon={TriangleAlert}
className="-mx-3"
align={align}
triggerDataSlot="sleep-source-discrepancy"
bodyDataSlot="sleep-source-discrepancy-body"
/>
</span>
);
}
+1 -1
View File
@@ -90,7 +90,7 @@ export function BiomarkerAliases({
{canEdit ? (
<button
type="button"
className="hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 -my-1 -me-1 inline-flex size-6 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
className="hover:text-foreground text-muted-foreground focus-visible:ring-ring/50 relative -my-1 -me-1 inline-flex size-6 items-center justify-center rounded-full after:absolute after:-inset-2.5 focus-visible:ring-[3px] focus-visible:outline-none"
onClick={() =>
save.mutate(aliases.filter((a) => a !== alias))
}
@@ -6,9 +6,6 @@ const hookState = vi.hoisted(() => ({
cursor: 0,
values: [] as unknown[],
}));
const captureState = vi.hoisted(() => ({
dirty: true,
}));
// v1.36.x — the picker asks what the record allows before it offers a kind.
// These fixtures are the caller's own record, which is every kind; the
@@ -57,29 +54,17 @@ vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
vi.mock("@/components/dashboard/quick-entry-sheets", () => ({
sheetBodyHasUnsavedInput: () => captureState.dirty,
}));
function markedComponent(displayName: string) {
const Component = ({ children }: { children?: ReactNode }) => children;
Component.displayName = displayName;
return Component;
}
const sheetBodyHasUnsavedInput = vi.hoisted(() => () => true);
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: markedComponent("ResponsiveSheet"),
}));
vi.mock("@/components/ui/alert-dialog", () => ({
AlertDialog: markedComponent("AlertDialog"),
AlertDialogAction: markedComponent("AlertDialogAction"),
AlertDialogCancel: markedComponent("AlertDialogCancel"),
AlertDialogContent: markedComponent("AlertDialogContent"),
AlertDialogDescription: markedComponent("AlertDialogDescription"),
AlertDialogFooter: markedComponent("AlertDialogFooter"),
AlertDialogHeader: markedComponent("AlertDialogHeader"),
AlertDialogTitle: markedComponent("AlertDialogTitle"),
sheetBodyHasUnsavedInput,
}));
vi.mock("@/components/dashboard/quick-entry-forms.lazy", () => ({
@@ -141,14 +126,13 @@ function formSheet(tree: ReactNode): TestElement {
return sheets[1];
}
describe("<CapturePicker> — confirmed capture-form discard", () => {
describe("<CapturePicker> — capture-form discard", () => {
beforeEach(() => {
hookState.cursor = 0;
hookState.values.length = 0;
captureState.dirty = true;
});
it("asks before discarding a dirty form and closes on confirm", () => {
it("hands the form sheet the dirty check, so a dismissal asks first", () => {
let tree = renderPicker();
// Choose the mood capture; the form sheet opens with the mood form.
@@ -158,19 +142,13 @@ describe("<CapturePicker> — confirmed capture-form discard", () => {
tree = renderPicker();
expect(formSheet(tree).props.open).toBe(true);
expect(findMarked(tree, "MoodForm")).not.toBeNull();
// The sheet asks before a dismissal drops typed input
// (`responsive-sheet.test.tsx` pins the asking).
expect(formSheet(tree).props.dirty).toBe(sheetBodyHasUnsavedInput);
// A dirty dismiss must not close outright — it raises the confirm.
// A close the sheet lets through closes the form.
(formSheet(tree).props.onOpenChange as (open: boolean) => void)(false);
tree = renderPicker();
const confirmDiscard = findMarked(tree, "AlertDialogAction");
expect(confirmDiscard).not.toBeNull();
// The form stays open behind the confirm.
expect(formSheet(tree).props.open).toBe(true);
expect(findMarked(tree, "MoodForm")).not.toBeNull();
// Confirming the discard closes the form.
(confirmDiscard?.props.onClick as () => void)();
tree = renderPicker();
expect(formSheet(tree).props.open).toBe(false);
expect(findMarked(tree, "MoodForm")).toBeNull();
});
@@ -33,6 +33,7 @@ vi.mock("@/components/ui/responsive-sheet", () => ({
open: boolean;
children: React.ReactNode;
}) => (open ? <section>{children}</section> : null),
sheetBodyHasUnsavedInput: () => false,
}));
vi.mock("@/components/dashboard/quick-entry-forms.lazy", () => ({
+2 -2
View File
@@ -287,8 +287,8 @@ export function BottomNav() {
little more width on a 320 px phone; the long single-word labels
(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 pt-4 pb-4">
edge. It scrolls: a phone held sideways is shorter than the hub. */}
<div className="grid min-h-0 grid-cols-2 gap-2 overflow-y-auto overscroll-contain px-3 pt-4 pb-4">
{moreHub.map((item) => {
const isActive = isActiveLink(item.href);
return (
+9 -60
View File
@@ -10,7 +10,10 @@ import {
Waves,
} from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
ResponsiveSheet,
sheetBodyHasUnsavedInput,
} from "@/components/ui/responsive-sheet";
import {
ManualWorkoutForm,
MeasurementForm,
@@ -19,17 +22,6 @@ import {
SymptomEntryForm,
} from "@/components/dashboard/quick-entry-forms.lazy";
import { LifeEventForm } from "@/components/timeline/life-event-form.lazy";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations } from "@/lib/i18n/context";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareDomain } from "@/lib/sharing/scope";
@@ -60,11 +52,9 @@ import {
* from their own pages (`/measurements`, `/mood`, dashboard) and from
* the More hub. The picker is an additional fast path, not a removal.
*
* v1.30.1 — the form sheet shares the dashboard quick-entry sheets'
* confirm-before-discard guard (`sheetBodyHasUnsavedInput()`), so a
* swipe-down / backdrop tap / Escape on a half-filled form from the
* primary mobile capture path asks before dropping typed input,
* instead of closing unconditionally.
* The form sheet asks before a swipe-down, backdrop tap, Escape or Back
* drops a half-filled form (`ResponsiveSheet`'s `dirty`), the same way the
* dashboard quick-entry sheets do.
*/
export type CaptureKind =
@@ -192,7 +182,6 @@ export function CapturePicker({
// v1.30.1 — mirrors `QuickEntrySheets`' `confirmDiscardOpen`: hold a
// dismiss attempt here instead of closing outright when the form
// body carries unsaved input.
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
function chooseKind(next: CaptureKind) {
if (!offered.includes(next)) return;
@@ -206,16 +195,8 @@ export function CapturePicker({
setKind(null);
}
// Intercept a form-sheet dismiss: close immediately when the form is
// clean, otherwise keep the sheet open and ask before discarding —
// same contract as `QuickEntrySheets.handleQuickEntryOpenChange`.
function handleFormOpenChange(next: boolean) {
if (next) return;
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
closeForm();
if (!next) closeForm();
}
const allOptions: ReadonlyArray<{
@@ -318,6 +299,7 @@ export function CapturePicker({
<ResponsiveSheet
open={openKind !== null}
onOpenChange={handleFormOpenChange}
dirty={sheetBodyHasUnsavedInput}
title={formTitle}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
@@ -370,39 +352,6 @@ export function CapturePicker({
/>
)}
</ResponsiveSheet>
{/* v1.30.1 — confirm before discarding a partly-filled capture
form when the sheet is dismissed by an overlay tap, Escape,
or a mobile swipe-down. Copy shared with the dashboard
quick-entry sheets' identical guard. */}
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
closeForm();
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -29,6 +29,7 @@ export function ReminderFormSheet({
isEdit,
form,
setForm,
dirty,
saving,
onClose,
onSubmit,
@@ -37,6 +38,8 @@ export function ReminderFormSheet({
isEdit: boolean;
form: FormState;
setForm: Dispatch<SetStateAction<FormState>>;
/** The form differs from what it opened with. */
dirty: boolean;
saving: boolean;
onClose: () => void;
onSubmit: () => void;
@@ -48,6 +51,7 @@ export function ReminderFormSheet({
onOpenChange={(open) => {
if (!open) onClose();
}}
dirty={dirty}
title={t(
isEdit
? "measurementReminders.form.editTitle"
@@ -109,6 +109,7 @@ export function VorsorgeSection({
function openCreate() {
setForm(EMPTY_FORM);
setInitialForm(EMPTY_FORM);
setEditing("new");
}
@@ -277,6 +278,7 @@ export function VorsorgeSection({
isEdit={isEdit}
form={form}
setForm={setForm}
dirty={JSON.stringify(form) !== JSON.stringify(initialForm)}
saving={saving}
onClose={closeSheet}
onSubmit={submit}
@@ -108,6 +108,26 @@ const STEP_ICONS = {
type StepNumber = keyof typeof STEP_ICONS;
/**
* Whether closing would lose input: what a save would send now against what
* it would have sent on opening. Moving between steps or schedules is not
* input.
*/
function wizardHasInput(
seed: WizardPayload,
payload: WizardPayload,
mode: MedicationWizardDialogProps["mode"],
): boolean {
try {
return (
JSON.stringify(buildCreateBody(payload, mode)) !==
JSON.stringify(buildCreateBody(seed, mode))
);
} catch {
return true;
}
}
export function MedicationWizardDialog(props: MedicationWizardDialogProps) {
// Key the inner state container on (open + mode + medication id) so
// every open gets a fresh state tree. React unmounts the inner
@@ -139,18 +159,15 @@ function WizardDialogShell({
const router = useRouter();
const queryClient = useQueryClient();
const [payload, setPayload] = useState<WizardPayload>(() =>
const [seed] = useState<WizardPayload>(() =>
mode === "edit" && initial
? hydrateWizardPayload(initial)
: emptyWizardPayload(),
);
const [step, setStep] = useState<StepNumber>(() => {
if (mode === "edit" && initial) {
const hydrated = hydrateWizardPayload(initial);
return landingStepForEdit(hydrated) as StepNumber;
}
return 1;
});
const [payload, setPayload] = useState<WizardPayload>(seed);
const [step, setStep] = useState<StepNumber>(() =>
mode === "edit" && initial ? (landingStepForEdit(seed) as StepNumber) : 1,
);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [nlOpen, setNlOpen] = useState(false);
@@ -415,6 +432,7 @@ function WizardDialogShell({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={() => wizardHasInput(seed, payload, mode)}
title={headerTitle}
hideHeader
showCloseButton
+1 -1
View File
@@ -706,7 +706,7 @@ export function OnboardingTour({
// centred above the bottom edge on wide ones (`mx-auto` +
// `max-w`). Used on EVERY viewport when no anchored
// placement keeps the footer buttons inside the viewport.
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 mx-auto max-h-[70vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-4 shadow-2xl md:p-6"
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-[calc(env(safe-area-inset-bottom,0px)+0.5rem)] mx-auto max-h-[70vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-4 shadow-2xl md:p-6"
: "bg-card border-border pointer-events-auto absolute max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-4 shadow-2xl md:p-6"
}
style={
@@ -82,7 +82,8 @@ export function PractitionerSheet({
// open, so a second edit never shows the first one's values and a create
// after an edit starts empty — without an effect that copies props into
// state and briefly renders the stale pair.
const [draft, setDraft] = useState<Draft>(() => draftFrom(practitioner));
const [seed] = useState<Draft>(() => draftFrom(practitioner));
const [draft, setDraft] = useState<Draft>(seed);
const [error, setError] = useState<string | null>(null);
const pending = create.isPending || update.isPending;
@@ -122,6 +123,7 @@ export function PractitionerSheet({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={JSON.stringify(draft) !== JSON.stringify(seed)}
title={t(
practitioner ? "practitioners.editTitle" : "practitioners.createTitle",
)}
@@ -37,6 +37,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ConfirmButton } from "@/components/ui/confirm-button";
import {
useAboutMeSaveTimeoutMs,
@@ -44,7 +45,6 @@ import {
} from "@/hooks/use-ai-capability";
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 { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
@@ -61,9 +61,6 @@ interface AboutMeData {
fieldMaxChars: number;
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function AboutMeNoteManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -134,10 +131,11 @@ export function AboutMeNoteManager() {
<Label htmlFor="settings-about-me-freetext">
{t("settings.ai.aboutMe.freeTextLabel")}
</Label>
<textarea
<Textarea
id="settings-about-me-freetext"
data-testid="settings-about-me-textarea"
className={cn(FIELD_CLASSES, "min-h-36 resize-y")}
className="resize-y"
rows={6}
value={value}
maxLength={maxChars}
placeholder={t("settings.ai.aboutMe.placeholder")}
@@ -31,10 +31,10 @@ import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
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";
@@ -50,9 +50,6 @@ interface AboutMeData {
fieldMaxChars: number;
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function AllergyFreeTextNote({
leadingAction,
}: {
@@ -122,10 +119,10 @@ export function AllergyFreeTextNote({
<p className="text-muted-foreground text-xs">
{t("records.allergies.freeText.hint")}
</p>
<textarea
<Textarea
id="records-allergy-free-text"
data-testid="records-allergy-free-text"
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
className="resize-y"
rows={2}
value={value}
maxLength={fieldMaxChars}
@@ -29,11 +29,11 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
import { QueryErrorCard } from "@/components/ui/query-error-card";
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";
@@ -47,9 +47,6 @@ interface AboutMeData {
fieldMaxChars: number;
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function ConditionsManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -112,10 +109,10 @@ export function ConditionsManager() {
return (
<div className="space-y-1.5">
<Label htmlFor={`records-conditions-${key}`}>{label}</Label>
<textarea
<Textarea
id={`records-conditions-${key}`}
data-testid={`records-conditions-${key}`}
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
className="resize-y"
rows={2}
value={value[key]}
maxLength={fieldMaxChars}
+1 -1
View File
@@ -566,7 +566,7 @@ function FullBackupCard() {
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
className="h-9 text-sm"
className="h-9"
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.cards.fullBackup.passphraseHelp")}
@@ -130,7 +130,7 @@ export function CsvImportCard() {
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder="type,value,unit,measuredAt,…"
className="font-mono text-xs"
className="font-mono sm:pointer-fine:text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
@@ -228,7 +228,7 @@ export function DoseHistoryImportCard() {
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder="Date,Scheduled Date,Medication,…"
className="font-mono text-xs"
className="font-mono sm:pointer-fine:text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
@@ -211,7 +211,7 @@ export function JsonImportCard() {
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder='{"measurements":[…],"moodEntries":[…]}'
className="font-mono text-xs"
className="font-mono sm:pointer-fine:text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
@@ -430,7 +430,7 @@ export function ShareLinkCreateForm({
prev.filter((d) => d.id !== doc.id),
)
}
className="hover:bg-background/80 focus-visible:ring-ring/50 flex size-5 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
className="hover:bg-background/80 focus-visible:ring-ring/50 relative flex size-5 shrink-0 items-center justify-center rounded-full after:absolute after:-inset-3 focus-visible:ring-[3px] focus-visible:outline-none"
aria-label={t("settings.sharing.attachRemove", {
title: doc.title,
})}
@@ -85,16 +85,17 @@ describe("<RangeBar>", () => {
expect(marker).toBeLessThan(100 - EDGE_PADDING_PERCENT);
});
it("makes the marker focusable with a text alternative for the value + range (2026-07-17 a11y audit M1)", () => {
it("makes the marker a button named with the value + range, so a tap or a key opens its numbers", () => {
const html = render({
value: 72,
min: 60,
max: 100,
unit: "bpm",
});
expect(html).toContain('tabindex="0"');
expect(html).toContain('role="img"');
expect(html).toContain('aria-label="Current: 72 bpm');
expect(html).toMatch(
/<button type="button" aria-label="Current: 72 bpm[^"]*"/,
);
expect(html).toContain('aria-haspopup="dialog"');
});
it("prints value, bounds and delta in the reader's number format", () => {
@@ -12,7 +12,7 @@ import {
* so the Targets card and the Insights reference panel render the
* identical pill from the same server-emitted classification category.
* These tests pin the category → semantic-group mapping (which drives
* the pill colour) and the rendered label + range/source tooltip.
* the pill colour) and the rendered label + range/source popover.
*/
function render(props: Parameters<typeof TargetStatusPill>[0]) {
@@ -61,8 +61,9 @@ describe("<TargetStatusPill>", () => {
expect(html).toContain('data-slot="target-status-pill"');
expect(html).toContain('data-status="in"');
expect(html).toContain("Optimal");
// The tooltip trigger is the pill itself; the range/source content
// lives behind a closed Radix tooltip and is not in static markup.
// The pill sits in a button that opens the range and source on a tap;
// the popover is closed, so its content is not in static markup.
expect(html).toMatch(/<button type="button"[^>]*aria-haspopup="dialog"/);
expect(html).toContain('data-state="closed"');
});
+28 -25
View File
@@ -1,18 +1,17 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
/**
* A horizontal range bar with green/yellow/red zones showing where
* the current value falls relative to a target range. The marker dot
* carries a tooltip with the current value + the target range + a
* delta sentence (e.g. "6 mmHg above target band").
* the current value falls relative to a target range. A tap on the marker
* dot shows the current value + the target range + a delta sentence (e.g.
* "6 mmHg above target band").
*
* Behaviour is unchanged from the v1.4.22 inline version; only the
* file boundary moves.
@@ -44,6 +43,15 @@ export interface RangeBarProps {
*/
export const EDGE_PADDING_PERCENT = 4;
/**
* The bar can sit in a list row that is itself a link: a tap on the marker
* or its popover opens the popover, not the row.
*/
function keepTapInside(event: React.MouseEvent) {
event.preventDefault();
event.stopPropagation();
}
export function RangeBar({
value,
min,
@@ -147,11 +155,7 @@ export function RangeBar({
: t("targets.aboveTarget", { delta: fmt.number(delta, 1), unit })
: t("targets.inTarget");
// The marker's value + range live only in a
// hover-only Radix tooltip on a non-focusable `<div>`. Reusing the same
// three strings the tooltip already shows as one text alternative closes
// 1.1.1 (no separate copy to drift) — `tabIndex` below makes the trigger
// focusable, which is all Radix needs to also open the tooltip on focus.
// The same three strings the popover shows, as the marker's name.
const markerAriaLabel = [
t("targets.currentValue", { value: show(value), unit }),
t("targets.targetRangeValue", { min: show(min), max: show(max), unit }),
@@ -209,13 +213,12 @@ export function RangeBar({
width: `${greenEnd - greenStart}%`,
}}
/>
{/* Current value marker with tooltip */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
role="img"
tabIndex={0}
{/* Current value marker; a tap shows its numbers. */}
<span className="contents" onClick={keepTapInside}>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={markerAriaLabel}
className={`focus-visible:ring-ring absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full shadow-sm focus-visible:ring-2 focus-visible:outline-none ${
compact ? "h-5 w-1 border" : "h-5 w-5 border-2"
@@ -226,8 +229,8 @@ export function RangeBar({
borderColor: markerColor,
}}
/>
</TooltipTrigger>
<TooltipContent>
</PopoverTrigger>
<PopoverContent>
<p className="text-xs font-medium">
{t("targets.currentValue", { value: show(value), unit })}
</p>
@@ -239,9 +242,9 @@ export function RangeBar({
})}
</p>
<p className="text-xs font-medium">{deltaText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</PopoverContent>
</Popover>
</span>
</div>
<div
className={`text-muted-foreground relative ${
+30 -33
View File
@@ -1,11 +1,10 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { ScorePill } from "@/components/ui/score-pill";
@@ -185,9 +184,9 @@ export interface TargetStatusPillProps {
}
/**
* Verbal status pill with a tooltip carrying the target range + the
* guideline source. The pill colour is derived from the classification
* category's semantic group; unmapped categories paint amber.
* Verbal status pill; a tap shows the target range + the guideline source.
* The pill colour is derived from the classification category's semantic
* group; unmapped categories paint amber.
*/
export function TargetStatusPill({
classification,
@@ -202,30 +201,28 @@ export function TargetStatusPill({
const statusLabel = translateStatus(classification.category, t);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<ScorePill
data-slot="target-status-pill"
data-status={statusGroup}
className={statusPillStyle}
>
{statusLabel}
</ScorePill>
</TooltipTrigger>
<TooltipContent>
{range && (
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(range.min),
max: String(range.max),
unit,
})}
</p>
)}
<p className="text-xs">{t("targets.sourceLabel", { source })}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Popover>
<PopoverTrigger className="focus-visible:ring-ring/50 inline-flex rounded-full focus-visible:ring-2 focus-visible:outline-none">
<ScorePill
data-slot="target-status-pill"
data-status={statusGroup}
className={statusPillStyle}
>
{statusLabel}
</ScorePill>
</PopoverTrigger>
<PopoverContent align="end">
{range && (
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(range.min),
max: String(range.max),
unit,
})}
</p>
)}
<p className="text-xs">{t("targets.sourceLabel", { source })}</p>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,40 @@
/**
* A picker whose dropdown should be as wide as its field.
*
* Under Tailwind 4 the v3 shorthand `w-[--radix-popover-trigger-width]`
* compiles to `width: --radix-popover-trigger-width`, which no browser
* accepts, so the width silently fell back to the content's. The v4 form is
* `w-(--radix-popover-trigger-width)`. And `PopoverContent` caps every
* popover at `max-w-xs` (20 rem), shorter than a full-width field on most
* phones, so the match needs `max-w-none` beside it.
*/
import { describe, expect, it } from "vitest";
import { productionSources, readSrc } from "@/__tests__/helpers/source-metrics";
const V3_VARIABLE = /(?<![\w-])[\w-]+-\[--[\w-]+\]/g;
const CLASS_STRING = /"[^"\n]*--radix-popover-trigger-width[^"\n]*"/g;
describe("popover trigger width", () => {
const files = productionSources()
.filter((rel) => rel.endsWith(".tsx"))
.map((rel) => [rel, readSrc(rel)] as const);
it("uses no Tailwind 3 variable shorthand", () => {
const offenders = files.flatMap(([rel, text]) =>
[...text.matchAll(V3_VARIABLE)].map((m) => `${rel}: ${m[0]}`),
);
expect(offenders).toEqual([]);
});
it("lifts the popover's width cap wherever it matches the trigger", () => {
const strings = files.flatMap(([rel, text]) =>
[...text.matchAll(CLASS_STRING)].map((m) => [rel, m[0]] as const),
);
expect(strings.length).toBeGreaterThanOrEqual(2);
const capped = strings
.filter(([, cls]) => !/\bmax-w-none\b/.test(cls))
.map(([rel, cls]) => `${rel}: ${cls}`);
expect(capped).toEqual([]);
});
});
@@ -12,14 +12,34 @@ import { renderToStaticMarkup } from "react-dom/server";
// static markup.
let mobile = false;
// The dismissal handler the primitive hands Radix, kept to call it as Radix
// would on a backdrop tap, Escape, a swipe or the X.
const radix: { onOpenChange: ((open: boolean) => void) | null } = {
onOpenChange: null,
};
vi.mock("@/hooks/use-is-mobile", () => ({
useIsMobile: () => mobile,
}));
vi.mock("@/components/forms/use-seeded-form-dismissal", () => ({
SeededFormDiscardDialog: ({ open }: { open: boolean }) =>
open ? <div data-slot="mock-discard" /> : null,
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-slot="mock-sheet">{children}</div> : null,
Sheet: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
radix.onOpenChange = onOpenChange;
return open ? <div data-slot="mock-sheet">{children}</div> : null;
},
SheetContent: ({
children,
className,
@@ -62,8 +82,18 @@ vi.mock("@/components/ui/sheet", () => ({
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-slot="mock-dialog">{children}</div> : null,
Dialog: ({
children,
open,
onOpenChange,
}: {
children: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}) => {
radix.onOpenChange = onOpenChange;
return open ? <div data-slot="mock-dialog">{children}</div> : null;
},
DialogContent: ({
children,
className,
@@ -325,6 +355,44 @@ describe("<ResponsiveSheet>", () => {
});
});
describe("<ResponsiveSheet> dismissal with unsaved input", () => {
function dismiss(dirty: boolean | (() => boolean) | undefined) {
const onOpenChange = vi.fn();
renderToStaticMarkup(
<ResponsiveSheet
open
onOpenChange={onOpenChange}
title="Add medication"
dirty={dirty}
>
<p>body</p>
</ResponsiveSheet>,
);
radix.onOpenChange?.(false);
return onOpenChange;
}
it.each([true, () => true])(
"keeps the sheet open when the form has input (%s)",
(dirty) => {
mobile = true;
expect(dismiss(dirty)).not.toHaveBeenCalled();
mobile = false;
expect(dismiss(dirty)).not.toHaveBeenCalled();
},
);
it.each([false, () => false, undefined])(
"closes at once when there is nothing to lose (%s)",
(dirty) => {
mobile = true;
expect(dismiss(dirty)).toHaveBeenCalledWith(false);
mobile = false;
expect(dismiss(dirty)).toHaveBeenCalledWith(false);
},
);
});
describe("keepKeyboardDownOnOpen (phone branch open focus)", () => {
function setup(activeInside: boolean) {
const focus = vi.fn();
@@ -0,0 +1,100 @@
/**
* `sheetBodyHasUnsavedInput`, the fallback dirty check an add form passes to
* `ResponsiveSheet`. It reads the one sheet body it is handed. Tests run in
* Node, so the body is a fake that answers the three queries the check makes.
*/
import { describe, expect, it } from "vitest";
import { sheetBodyHasUnsavedInput } from "@/components/ui/responsive-sheet";
const MOOD_RADIO_SELECTOR = '[role="radio"][aria-checked="true"]';
const OPEN_SELECT_SELECTOR = '[role="combobox"][data-state="open"]';
interface FakeField {
type: string;
value?: string;
disabled?: boolean;
checked?: boolean;
defaultChecked?: boolean;
/** The text overlay of a `DateField` / `TimeField`. */
inDateOrTimeField?: boolean;
}
function fakeBody(fixture: {
fields?: FakeField[];
moodRadioSelected?: boolean;
openSelect?: boolean;
}): HTMLElement {
const fields = (fixture.fields ?? []).map((field) => ({
...field,
value: field.value ?? "",
closest: () => (field.inDateOrTimeField ? {} : null),
}));
return {
querySelectorAll: (selector: string) =>
selector === "input, textarea" ? fields : [],
querySelector: (selector: string) => {
if (selector === MOOD_RADIO_SELECTOR) {
return fixture.moodRadioSelected ? {} : null;
}
if (selector === OPEN_SELECT_SELECTOR) {
return fixture.openSelect ? {} : null;
}
return null;
},
} as unknown as HTMLElement;
}
describe("sheetBodyHasUnsavedInput", () => {
it("is clean for a pristine form whose date and time read 'now'", () => {
const body = fakeBody({
fields: [
{ type: "text", value: "" },
{ type: "textarea", value: "" },
{ type: "text", value: "04.06.2026", inDateOrTimeField: true },
{ type: "text", value: "08:00", inDateOrTimeField: true },
{ type: "hidden", value: "2026-06-04" },
],
});
expect(sheetBodyHasUnsavedInput(body)).toBe(false);
});
it("is dirty when a text field carries a value", () => {
const body = fakeBody({ fields: [{ type: "text", value: "75.5" }] });
expect(sheetBodyHasUnsavedInput(body)).toBe(true);
});
it("ignores a disabled field carrying a value", () => {
const body = fakeBody({
fields: [{ type: "text", value: "ignored", disabled: true }],
});
expect(sheetBodyHasUnsavedInput(body)).toBe(false);
});
it("is dirty for a checkbox moved off its default", () => {
const body = fakeBody({
fields: [{ type: "checkbox", checked: true, defaultChecked: false }],
});
expect(sheetBodyHasUnsavedInput(body)).toBe(true);
});
it("is dirty when a mood face is chosen, clean when none is", () => {
expect(
sheetBodyHasUnsavedInput(fakeBody({ moodRadioSelected: true })),
).toBe(true);
expect(
sheetBodyHasUnsavedInput(fakeBody({ moodRadioSelected: false })),
).toBe(false);
});
it("is dirty while a Select is open, clean on its closed default", () => {
expect(sheetBodyHasUnsavedInput(fakeBody({ openSelect: true }))).toBe(true);
expect(sheetBodyHasUnsavedInput(fakeBody({ openSelect: false }))).toBe(
false,
);
});
it("is clean without a body", () => {
expect(sheetBodyHasUnsavedInput(null)).toBe(false);
});
});
@@ -0,0 +1,286 @@
import { describe, expect, it, vi } from "vitest";
import { createSheetHistory, type SheetHistoryHost } from "../sheet-history";
type Listener = (event: { stopImmediatePropagation(): void }) => void;
/**
* A session history the way a browser keeps it: `back()` is queued and
* answered later with a popstate, capture listeners hear it before bubble
* ones, and the router is a bubble listener registered first.
*/
function fakeBrowser(start = "https://app.test/measurements") {
const entries: Array<{ state: unknown; href: string }> = [
{ state: { __NA: true }, href: start },
];
let index = 0;
const queued: Array<() => void> = [];
const capture: Listener[] = [];
const bubble: Listener[] = [];
const routerHeard: string[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
const resolve = (url: string | URL | null | undefined) =>
url == null
? entries[index].href
: new URL(String(url), entries[index].href).href;
function dispatch() {
let stopped = false;
const event = { stopImmediatePropagation: () => (stopped = true) };
for (const listener of [...capture, ...bubble]) {
if (stopped) break;
listener(event);
}
}
function traverse(delta: number) {
const target = index + delta;
if (target < 0 || target >= entries.length) return;
index = target;
dispatch();
}
const host: SheetHistoryHost = {
history: {
get state() {
return entries[index].state;
},
pushState(data, _unused, url) {
entries.splice(index + 1);
entries.push({ state: data, href: resolve(url) });
index += 1;
},
replaceState(data, _unused, url) {
entries[index] = { state: data, href: resolve(url) };
},
back() {
queued.push(() => traverse(-1));
},
},
location: {
get href() {
return entries[index].href;
},
},
addEventListener(_type, listener, useCapture) {
(useCapture ? capture : bubble).push(listener);
},
dispatchEvent: () => true,
setTimeout(callback) {
timerId += 1;
timers.set(timerId, callback);
return timerId;
},
clearTimeout(handle) {
timers.delete(handle as number);
},
};
// The router listens from the start, in the bubble phase.
bubble.push(() => routerHeard.push(entries[index].href));
return {
host,
entries,
get index() {
return index;
},
routerHeard,
/** The browser answers the Backs that were asked for. */
settle() {
while (queued.length > 0) queued.shift()!();
},
/** The person presses Back. */
pressBack() {
traverse(-1);
},
/** The person picks an entry further back from the Back button's list. */
go(delta: number) {
traverse(delta);
},
runTimers() {
const pending = [...timers.values()];
timers.clear();
for (const run of pending) run();
},
};
}
function marked(state: unknown): boolean {
return (
state !== null &&
typeof state === "object" &&
"__healthlogSheet" in (state as Record<string, unknown>)
);
}
describe("sheet history", () => {
it("Back closes the sheet and the page under it stays where it was", () => {
const browser = fakeBrowser();
const sheets = createSheetHistory(browser.host);
const onBack = vi.fn();
sheets.open(onBack);
expect(browser.entries).toHaveLength(2);
browser.pressBack();
expect(onBack).toHaveBeenCalledTimes(1);
expect(browser.index).toBe(0);
expect(browser.host.location.href).toBe("https://app.test/measurements");
// Nothing changed for the page, so the router never hears of it.
expect(browser.routerHeard).toEqual([]);
});
it("a sheet closed by its own controls takes its entry off again", () => {
const browser = fakeBrowser();
const sheets = createSheetHistory(browser.host);
const onBack = vi.fn();
const entry = sheets.open(onBack);
entry.release();
browser.settle();
expect(browser.index).toBe(0);
expect(onBack).not.toHaveBeenCalled();
expect(browser.routerHeard).toEqual([]);
expect(marked(browser.host.history.state)).toBe(false);
});
it("a page pushed right after the close is not undone by the close's Back", () => {
const browser = fakeBrowser("https://app.test/medications");
const sheets = createSheetHistory(browser.host);
const entry = sheets.open(vi.fn());
// A save closes the wizard and opens the new medication at once.
entry.release();
browser.host.history.pushState({ __NA: true }, "", "/medications/m1");
browser.settle();
expect(browser.entries.map((e) => e.href)).toEqual([
"https://app.test/medications",
"https://app.test/medications/m1",
]);
expect(browser.index).toBe(1);
});
it("a link followed from inside the sheet replaces the sheet's entry", () => {
const browser = fakeBrowser("https://app.test/settings");
const sheets = createSheetHistory(browser.host);
const entry = sheets.open(vi.fn());
browser.host.history.pushState({ __NA: true }, "", "/settings/security");
entry.release();
browser.settle();
expect(browser.entries.map((e) => e.href)).toEqual([
"https://app.test/settings",
"https://app.test/settings/security",
]);
expect(browser.index).toBe(1);
});
it("Back on a sheet that stays open (unsaved input) gives it its entry back", () => {
const browser = fakeBrowser();
const sheets = createSheetHistory(browser.host);
let restore = () => {};
const entry = sheets.open(() => restore());
restore = entry.restore;
browser.pressBack();
expect(browser.index).toBe(1);
expect(marked(browser.host.history.state)).toBe(true);
// Once it does close, its entry goes and one Back leaves the page.
entry.release();
browser.settle();
expect(browser.index).toBe(0);
});
it("Back closes only the top sheet of two", () => {
const browser = fakeBrowser();
const sheets = createSheetHistory(browser.host);
const lower = vi.fn();
const upper = vi.fn();
sheets.open(lower);
sheets.open(upper);
browser.pressBack();
expect(upper).toHaveBeenCalledTimes(1);
expect(lower).not.toHaveBeenCalled();
expect(browser.index).toBe(1);
expect(marked(browser.host.history.state)).toBe(true);
});
it("two sheets closed lower first still leave no entry behind", () => {
const browser = fakeBrowser();
const sheets = createSheetHistory(browser.host);
const lower = sheets.open(vi.fn());
const upper = sheets.open(vi.fn());
lower.release();
upper.release();
browser.settle();
expect(browser.index).toBe(0);
expect(marked(browser.host.history.state)).toBe(false);
});
it("a URL the page replaced while the sheet was open is the one it keeps", () => {
// `?add=` opens the sheet on the first paint and is stripped after it.
const browser = fakeBrowser("https://app.test/measurements?add=WEIGHT");
const sheets = createSheetHistory(browser.host);
const entry = sheets.open(vi.fn());
browser.host.history.replaceState(null, "", "/measurements");
// The router rewrites the current entry; the sheet keeps its mark.
expect(marked(browser.host.history.state)).toBe(true);
entry.release();
browser.settle();
expect(browser.index).toBe(0);
expect(browser.host.location.href).toBe("https://app.test/measurements");
expect(browser.routerHeard).toEqual([]);
});
it("Back on a sheet opened by a one-shot URL does not bring the URL back", () => {
const browser = fakeBrowser("https://app.test/measurements?add=WEIGHT");
const sheets = createSheetHistory(browser.host);
const onBack = vi.fn();
sheets.open(onBack);
browser.host.history.replaceState(null, "", "/measurements");
browser.pressBack();
expect(onBack).toHaveBeenCalledTimes(1);
expect(browser.host.location.href).toBe("https://app.test/measurements");
expect(browser.routerHeard).toEqual([]);
});
it("a Back that jumps past the page is the router's to handle", () => {
const browser = fakeBrowser("https://app.test/");
browser.host.history.pushState({ __NA: true }, "", "/labs");
const sheets = createSheetHistory(browser.host);
const onBack = vi.fn();
sheets.open(onBack);
browser.go(-2);
expect(onBack).toHaveBeenCalledTimes(1);
expect(browser.routerHeard).toEqual(["https://app.test/"]);
});
it("writes stop waiting when the close's Back never arrives", () => {
const browser = fakeBrowser("https://app.test/medications");
const sheets = createSheetHistory(browser.host);
const entry = sheets.open(vi.fn());
entry.release();
browser.host.history.pushState({ __NA: true }, "", "/medications/m1");
expect(browser.host.location.href).toBe("https://app.test/medications");
browser.runTimers();
expect(browser.host.location.href).toBe("https://app.test/medications/m1");
});
});
+333 -187
View File
@@ -4,6 +4,11 @@ import * as React from "react";
import { cn } from "@/lib/utils";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { SeededFormDiscardDialog } from "@/components/forms/use-seeded-form-dismissal";
import {
sheetHistory,
type SheetHistoryEntry,
} from "@/components/ui/sheet-history";
import {
Dialog,
DialogContent,
@@ -95,6 +100,19 @@ export interface ResponsiveSheetProps {
* `event.preventDefault()` and focus the opener.
*/
onCloseAutoFocus?: (event: Event) => void;
/**
* Whether the form holds input a dismissal would lose. A backdrop tap,
* Escape, a swipe down, the X and Back then ask before closing; the form's
* own Cancel and Save close it as before. A function is read at the moment
* of the dismissal and gets this sheet's body: pass
* `sheetBodyHasUnsavedInput` for an add form that keeps its state inside.
*/
dirty?: boolean | ((body: HTMLElement | null) => boolean);
/**
* Back closes the sheet (`sheet-history.ts`). False for a sheet whose open
* state lives in the URL already, which has its own history entry.
*/
historyEntry?: boolean;
children: React.ReactNode;
}
@@ -148,6 +166,118 @@ export function keepKeyboardDownOnOpen(event: Event): void {
}
}
/**
* The fallback dirty check for an add form that keeps its state inside the
* sheet body: any text field with a value, a checkbox or radio moved off its
* default, a chosen `role="radio"` (the mood faces) or a Select that is open.
* The date and time fields are skipped, because the forms fill them with
* "now". An edit form starts filled and has to say for itself whether
* anything changed.
*/
export function sheetBodyHasUnsavedInput(body: HTMLElement | null): boolean {
if (!body) return false;
const fields = body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
"input, textarea",
);
for (const field of fields) {
if (field.disabled || field.type === "hidden") continue;
if (field.closest('[data-slot="date-field"], [data-slot="time-field"]')) {
continue;
}
if (field.type === "checkbox" || field.type === "radio") {
const box = field as HTMLInputElement;
if (box.checked !== box.defaultChecked) return true;
continue;
}
if (field.value.trim() !== "") return true;
}
// Mood faces start with none chosen, so any chosen one is input.
if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) {
return true;
}
// A Select that is open is a choice being made. One merely off its
// placeholder is not: the type and medication selects open on a default.
if (body.querySelector('[role="combobox"][data-state="open"]') !== null) {
return true;
}
return false;
}
/**
* How a sheet closes: a dismissal with unsaved input asks first, and Back
* closes the sheet (asking the same way) instead of leaving the page.
*/
function useSheetDismissal({
open,
onOpenChange,
dirty,
historyEntry,
}: Pick<
ResponsiveSheetProps,
"open" | "onOpenChange" | "dirty" | "historyEntry"
>) {
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const entryRef = React.useRef<SheetHistoryEntry | null>(null);
const [asking, setAsking] = React.useState(false);
const [backs, countBack] = React.useReducer((n: number) => n + 1, 0);
if (!open && asking) setAsking(false);
const hasUnsavedInput = () =>
typeof dirty === "function" ? dirty(bodyRef.current) : dirty === true;
// Every dismissal Radix reports (backdrop, Escape, swipe, the X).
const handleOpenChange = (next: boolean) => {
if (!next && hasUnsavedInput()) {
setAsking(true);
return;
}
onOpenChange(next);
};
const handleBack = React.useEffectEvent(() => {
if (hasUnsavedInput()) {
entryRef.current?.restore();
setAsking(true);
return;
}
countBack();
onOpenChange(false);
});
React.useEffect(() => {
if (!open || historyEntry === false) return;
const entry = sheetHistory()?.open(() => handleBack());
if (!entry) return;
entryRef.current = entry;
return () => {
entry.release();
if (entryRef.current === entry) entryRef.current = null;
};
}, [open, historyEntry]);
// Back was answered and the sheet is still open: its owner kept it (its
// own discard question, a save under way), so Back must keep working.
React.useEffect(() => {
if (backs > 0 && open) entryRef.current?.restore();
}, [backs, open]);
// A sibling of the sheet, not a child: inside it, a touch on the question
// would reach the sheet's drag through the React tree.
const discard =
dirty === undefined ? null : (
<SeededFormDiscardDialog
open={asking}
onConfirm={() => {
setAsking(false);
onOpenChange(false);
}}
onCancel={() => setAsking(false)}
/>
);
return { bodyRef, handleOpenChange, discard };
}
export function ResponsiveSheet({
open,
onOpenChange,
@@ -161,67 +291,89 @@ export function ResponsiveSheet({
showCloseButton = true,
contentWidth = "md",
onCloseAutoFocus,
dirty,
historyEntry,
children,
}: ResponsiveSheetProps) {
const isMobile = useIsMobile();
const { bodyRef, handleOpenChange, discard } = useSheetDismissal({
open,
onOpenChange,
dirty,
historyEntry,
});
if (isMobile) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
onCloseAutoFocus={onCloseAutoFocus}
onOpenAutoFocus={keepKeyboardDownOnOpen}
side="bottom"
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="sheet"
className={cn(
// Cap at 90 dvh so the user sees a slice of the page
// behind the sheet (clear "this is a sheet, not a
// takeover") + the bottom-sheet rounded top edge that
// matches the iOS feel.
"flex max-h-[90dvh] flex-col gap-0 rounded-t-2xl p-0",
className,
)}
>
{hideHeader ? (
<SheetHeader className="sr-only">
<SheetTitle>{title}</SheetTitle>
{description ? (
<SheetDescription>{description}</SheetDescription>
) : null}
</SheetHeader>
) : (
<SheetHeader
data-slot="responsive-sheet-header"
className={cn(
// 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
// the `p-4` right edge instead of being pushed in by an empty
// reserved gutter.
showCloseButton && "pr-12",
// Optical alignment for a consumer-rendered trailing control.
// An icon button carries its own inner inset (a 20px glyph
// centred in a 44px tap target sits 12px in), so at the plain
// `p-4` right edge the GLYPH lands ~28px from the frame while
// the title starts at 16px — visibly not flush. Trim the right
// padding by that inset so the glyph aligns with the title's
// edge; the tap target still reaches the frame.
headerAction &&
!showCloseButton &&
"flex-row items-start justify-between gap-3 pr-1 sm:pr-2",
headerAction &&
showCloseButton &&
"flex-row items-start justify-between gap-3",
)}
>
{headerAction ? (
<>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<>
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
onCloseAutoFocus={onCloseAutoFocus}
onOpenAutoFocus={keepKeyboardDownOnOpen}
side="bottom"
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="sheet"
className={cn(
// Cap at 90 dvh so the user sees a slice of the page
// behind the sheet (clear "this is a sheet, not a
// takeover") + the bottom-sheet rounded top edge that
// matches the iOS feel.
"flex max-h-[90dvh] flex-col gap-0 rounded-t-2xl p-0",
className,
)}
>
{hideHeader ? (
<SheetHeader className="sr-only">
<SheetTitle>{title}</SheetTitle>
{description ? (
<SheetDescription>{description}</SheetDescription>
) : null}
</SheetHeader>
) : (
<SheetHeader
data-slot="responsive-sheet-header"
className={cn(
// 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
// the `p-4` right edge instead of being pushed in by an empty
// reserved gutter.
showCloseButton && "pr-12",
// Optical alignment for a consumer-rendered trailing control.
// An icon button carries its own inner inset (a 20px glyph
// centred in a 44px tap target sits 12px in), so at the plain
// `p-4` right edge the GLYPH lands ~28px from the frame while
// the title starts at 16px — visibly not flush. Trim the right
// padding by that inset so the glyph aligns with the title's
// edge; the tap target still reaches the frame.
headerAction &&
!showCloseButton &&
"flex-row items-start justify-between gap-3 pr-1 sm:pr-2",
headerAction &&
showCloseButton &&
"flex-row items-start justify-between gap-3",
)}
>
{headerAction ? (
<>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<SheetTitle className="text-base font-semibold">
{title}
</SheetTitle>
{description ? (
<SheetDescription className="text-muted-foreground text-xs">
{description}
</SheetDescription>
) : null}
</div>
<div className="shrink-0">{headerAction}</div>
</>
) : (
<>
<SheetTitle className="text-base font-semibold">
{title}
</SheetTitle>
@@ -230,160 +382,154 @@ export function ResponsiveSheet({
{description}
</SheetDescription>
) : null}
</>
)}
</SheetHeader>
)}
<div
ref={bodyRef}
data-slot="responsive-sheet-body"
className={cn(
// Vertical-only scroll port — see the note on the Dialog
// branch's body below for why `overflow-y-auto` alone does not
// say that. `overscroll-contain`: a flick that reaches the end
// of the form stops there instead of chaining into the page
// (or the browser's pull-to-refresh) behind the sheet.
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto overscroll-contain p-4",
// Without a footer the body is the sheet's bottom edge, so it
// keeps its last row above the home indicator of an
// installed app; the footer does that when there is one.
// Standing on the keyboard, the sheet is clear of the
// indicator already; the inset would leave a gap above it.
!footer &&
"keyboard-open:pb-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]",
bodyClassName,
)}
>
{children}
</div>
{footer ? (
<SheetFooter
data-slot="responsive-sheet-footer"
className="border-border/70 bg-background/95 supports-[backdrop-filter]:bg-background/80 keyboard-open:pb-4 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] backdrop-blur"
>
{footer}
</SheetFooter>
) : null}
</SheetContent>
</Sheet>
{discard}
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
onCloseAutoFocus={onCloseAutoFocus}
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="dialog"
// v1.21.1 — flip the desktop Dialog from the primitive's single
// `overflow-y-auto` grid (where header + body + footer all scroll
// together, so the primary CTA can slide below the fold once the
// form, a long locale string, browser zoom, or OS display scaling
// pushes the content past `max-h-[calc(100dvh-2rem)]`) to a flex
// column that scrolls ONLY the body. This brings the Dialog branch
// to parity with the Sheet branch, which already pins its footer.
// The header + footer stay shrink-0 so the body absorbs every
// height shortfall and the action row is always reachable without
// scrolling. `overflow-hidden` makes the body the lone scroll port.
className={cn(
"flex flex-col overflow-hidden",
CONTENT_WIDTH_CLASS[contentWidth],
className,
)}
>
{hideHeader ? (
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
) : (
<DialogHeader
data-slot="responsive-sheet-header"
className={cn(
"shrink-0",
headerAction && "flex-row items-start justify-between gap-3",
// Reserve the trailing gutter for the primitive's absolute close
// button ONLY when it is actually painted. With
// `showCloseButton={false}` the headerAction sits flush to the
// header's right edge instead of being pushed in.
headerAction && showCloseButton && "pr-9",
)}
>
{headerAction ? (
<>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</div>
<div className="shrink-0">{headerAction}</div>
</>
) : (
<>
<SheetTitle className="text-base font-semibold">
{title}
</SheetTitle>
<DialogTitle>{title}</DialogTitle>
{description ? (
<SheetDescription className="text-muted-foreground text-xs">
{description}
</SheetDescription>
<DialogDescription>{description}</DialogDescription>
) : null}
</>
)}
</SheetHeader>
</DialogHeader>
)}
<div
ref={bodyRef}
data-slot="responsive-sheet-body"
className={cn(
// Vertical-only scroll port — see the note on the Dialog
// branch's body below for why `overflow-y-auto` alone does not
// say that. `overscroll-contain`: a flick that reaches the end
// of the form stops there instead of chaining into the page
// (or the browser's pull-to-refresh) behind the sheet.
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto overscroll-contain p-4",
// Without a footer the body is the sheet's bottom edge, so it
// keeps its last row above the home indicator of an
// installed app; the footer does that when there is one.
// Standing on the keyboard, the sheet is clear of the
// indicator already; the inset would leave a gap above it.
!footer &&
"keyboard-open:pb-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]",
// `overflow-x-clip` states the invariant the surrounding comment
// already claims: this body scrolls VERTICALLY. `overflow-y-auto`
// alone does not say that — CSS promotes the other axis from
// `visible` to `auto`, so any child wider than the port turned the
// whole form into a sideways-pannable surface. That is how the
// date/time row and `<SheetSection>`'s deliberate 4 px hover bleed
// (`-mx-1 w-[calc(100%+0.5rem)]`) both produced a horizontal
// scrollbar on the desktop dialog. `clip` is not `visible`, so the
// promotion does not happen and the vertical scroll is kept.
//
// Content that genuinely needs to scroll sideways (a wide table)
// owns its own `overflow-x-auto` wrapper — that is the correct
// place for it, and it still works inside a clipped port.
//
// `-mr-3 pr-3` reclaims a 12 px scroll gutter from the dialog's own
// `p-6` padding: the vertical scrollbar would otherwise render flush
// against the body's right edge, exactly where a right-aligned child
// (a slider's high anchor label) sits, painting on top of the last
// characters. The margin gives the padding straight back, so content
// alignment is unchanged — the scrollbar just lives beside the text
// now, not on it. The mobile Sheet branch has its own `p-4` and
// needs nothing.
"-mr-3 flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto pr-3",
bodyClassName,
)}
>
{children}
</div>
{footer ? (
<SheetFooter
<DialogFooter
data-slot="responsive-sheet-footer"
className="border-border/70 bg-background/95 supports-[backdrop-filter]:bg-background/80 keyboard-open:pb-4 sticky bottom-0 mt-0 flex-row justify-end gap-2 border-t p-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)] backdrop-blur"
className="shrink-0"
>
{footer}
</SheetFooter>
</DialogFooter>
) : null}
</SheetContent>
</Sheet>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onCloseAutoFocus={onCloseAutoFocus}
showCloseButton={showCloseButton}
data-slot="responsive-sheet-content"
data-variant="dialog"
// v1.21.1 — flip the desktop Dialog from the primitive's single
// `overflow-y-auto` grid (where header + body + footer all scroll
// together, so the primary CTA can slide below the fold once the
// form, a long locale string, browser zoom, or OS display scaling
// pushes the content past `max-h-[calc(100dvh-2rem)]`) to a flex
// column that scrolls ONLY the body. This brings the Dialog branch
// to parity with the Sheet branch, which already pins its footer.
// The header + footer stay shrink-0 so the body absorbs every
// height shortfall and the action row is always reachable without
// scrolling. `overflow-hidden` makes the body the lone scroll port.
className={cn(
"flex flex-col overflow-hidden",
CONTENT_WIDTH_CLASS[contentWidth],
className,
)}
>
{hideHeader ? (
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
) : (
<DialogHeader
data-slot="responsive-sheet-header"
className={cn(
"shrink-0",
headerAction && "flex-row items-start justify-between gap-3",
// Reserve the trailing gutter for the primitive's absolute close
// button ONLY when it is actually painted. With
// `showCloseButton={false}` the headerAction sits flush to the
// header's right edge instead of being pushed in.
headerAction && showCloseButton && "pr-9",
)}
>
{headerAction ? (
<>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</div>
<div className="shrink-0">{headerAction}</div>
</>
) : (
<>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</>
)}
</DialogHeader>
)}
<div
data-slot="responsive-sheet-body"
className={cn(
// `overflow-x-clip` states the invariant the surrounding comment
// already claims: this body scrolls VERTICALLY. `overflow-y-auto`
// alone does not say that — CSS promotes the other axis from
// `visible` to `auto`, so any child wider than the port turned the
// whole form into a sideways-pannable surface. That is how the
// date/time row and `<SheetSection>`'s deliberate 4 px hover bleed
// (`-mx-1 w-[calc(100%+0.5rem)]`) both produced a horizontal
// scrollbar on the desktop dialog. `clip` is not `visible`, so the
// promotion does not happen and the vertical scroll is kept.
//
// Content that genuinely needs to scroll sideways (a wide table)
// owns its own `overflow-x-auto` wrapper — that is the correct
// place for it, and it still works inside a clipped port.
//
// `-mr-3 pr-3` reclaims a 12 px scroll gutter from the dialog's own
// `p-6` padding: the vertical scrollbar would otherwise render flush
// against the body's right edge, exactly where a right-aligned child
// (a slider's high anchor label) sits, painting on top of the last
// characters. The margin gives the padding straight back, so content
// alignment is unchanged — the scrollbar just lives beside the text
// now, not on it. The mobile Sheet branch has its own `p-4` and
// needs nothing.
"-mr-3 flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto pr-3",
bodyClassName,
)}
>
{children}
</div>
{footer ? (
<DialogFooter
data-slot="responsive-sheet-footer"
className="shrink-0"
>
{footer}
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
{discard}
</>
);
}
+316
View File
@@ -0,0 +1,316 @@
"use client";
import { URL_STATE_EVENT } from "@/lib/url-state";
/**
* Back closes the open sheet instead of leaving the page under it.
*
* Opening a sheet pushes one history entry with the page's own URL, marked
* with the sheet's id. Back (the Android button, the edge swipe of an
* installed app, the browser's button) then lands on the entry below, and
* the sheet hears it as a close it may refuse: one with unsaved input asks
* first and puts its entry back. Closing the sheet any other way takes its
* entry off again, so one Back after a closed sheet still leaves the page.
*
* Four things keep that from fighting the router:
*
* - Landing on the entry right below a sheet (by Back or by the sheet's
* own close) is never shown to Next: nothing changed for the page, and
* the entry below may still hold a URL the page has since replaced (a
* stripped `?add=`), which Next would restore and so reopen the sheet.
* That entry is given the page's current URL and state instead.
* - A history write made while the sheet's own Back is under way waits for
* it. A close followed by `router.push` would otherwise push the new page
* and have the queued Back undo it.
* - A navigation pushed while a sheet's entry is current (a link in the
* sheet) replaces that entry, so Back from the new page returns to the
* page, not to a sheet entry nobody shows any more.
* - A replace of a sheet's entry keeps the sheet's mark: the router
* rewrites the current entry on every update.
*
* A sheet whose open state already lives in the URL (the vault's `?doc=`)
* has its own entry and opts out (`ResponsiveSheet historyEntry={false}`).
*/
const SHEET_ENTRY = "__healthlogSheet";
/** How long a Back of our own may take before writes stop waiting for it. */
const SHEET_BACK_GRACE_MS = 500;
type HistoryWrite = (
data: unknown,
unused: string,
url?: string | URL | null,
) => void;
export interface SheetHistoryHost {
history: {
readonly state: unknown;
pushState: HistoryWrite;
replaceState: HistoryWrite;
back(): void;
};
location: { readonly href: string };
addEventListener(
type: "popstate",
listener: (event: { stopImmediatePropagation(): void }) => void,
capture: boolean,
): void;
dispatchEvent(event: Event): boolean;
setTimeout(callback: () => void, ms: number): unknown;
clearTimeout(handle: unknown): void;
}
export interface SheetHistoryEntry {
/** The sheet closed some other way than Back: take its entry off. */
release(): void;
/** The sheet stayed open after Back (unsaved input): push its entry again. */
restore(): void;
}
export interface SheetHistory {
/**
* A sheet opened. `onBack` runs when Back lands below its entry; the
* entry is gone by then, and `restore` puts it back.
*/
open(onBack: () => void): SheetHistoryEntry;
}
interface Layer {
id: string;
onBack: () => void;
/** The URL of the entry the sheet was opened over. */
baseHref: string;
/** Its own entry as last written, which the page entry takes on close. */
snapshot: { state: unknown; href: string } | null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function markOf(state: unknown): string | undefined {
if (!isRecord(state)) return undefined;
const id = state[SHEET_ENTRY];
return typeof id === "string" ? id : undefined;
}
function withMark(state: unknown, id: string | undefined): unknown {
const rest = isRecord(state) ? { ...state } : {};
delete rest[SHEET_ENTRY];
if (id !== undefined) rest[SHEET_ENTRY] = id;
return rest;
}
function samePage(a: string, b: string): boolean {
const left = new URL(a);
const right = new URL(b, a);
return left.pathname === right.pathname && left.search === right.search;
}
let sequence = 0;
export function createSheetHistory(host: SheetHistoryHost): SheetHistory {
const { history, location } = host;
const prefix = Math.random().toString(36).slice(2, 8);
const layers: Layer[] = [];
/** Backs we asked for and whose popstate has not arrived yet. */
let ownBacks = 0;
/** What the entry we land on takes once our Backs have arrived. */
let landing: { state: unknown; href: string } | null = null;
/** Writes held back while our Back is under way. */
let held: Array<() => void> = [];
let grace: unknown = null;
/** Set around our own writes, which pass the wrappers untouched. */
let ours = false;
let installed = false;
let push: HistoryWrite = () => undefined;
let replace: HistoryWrite = () => undefined;
const layerOf = (id: string | undefined) =>
id === undefined ? undefined : layers.find((layer) => layer.id === id);
function write(kind: "push" | "replace", data: unknown, url?: string) {
ours = true;
try {
// Through whatever wraps `history` (Next copies its own state in).
if (kind === "push") history.pushState(data, "", url);
else history.replaceState(data, "", url);
} finally {
ours = false;
}
}
function snapshot() {
const layer = layerOf(markOf(history.state));
if (layer) layer.snapshot = { state: history.state, href: location.href };
}
function install() {
if (installed) return;
installed = true;
const below = {
push: history.pushState,
replace: history.replaceState,
};
push = (data, unused, url) => {
if (ours) return below.push.call(history, data, unused, url);
if (ownBacks > 0) {
held.push(() => push.call(history, data, unused, url));
return;
}
const current = layerOf(markOf(history.state));
if (current && url != null && !samePage(location.href, String(url))) {
below.replace.call(history, data, unused, url);
return;
}
below.push.call(history, data, unused, url);
};
replace = (data, unused, url) => {
if (ours) return below.replace.call(history, data, unused, url);
if (ownBacks > 0) {
held.push(() => replace.call(history, data, unused, url));
return;
}
const id = markOf(history.state);
const keep = layerOf(id) && markOf(data) === undefined;
below.replace.call(
history,
keep ? withMark(data, id) : data,
unused,
url,
);
snapshot();
};
history.pushState = push;
history.replaceState = replace;
host.addEventListener("popstate", onPopState, true);
}
function armGrace() {
if (grace !== null) host.clearTimeout(grace);
grace = host.setTimeout(() => {
// The Back never arrived (a traversal can be dropped while another
// settles). Stop holding writes; where we stand is unknown, so the
// landing is left alone.
grace = null;
ownBacks = 0;
landing = null;
flush();
}, SHEET_BACK_GRACE_MS);
}
function goBack() {
ownBacks += 1;
history.back();
armGrace();
}
function land(target: { state: unknown; href: string }) {
write(
"replace",
withMark(target.state, markOf(history.state)),
target.href,
);
snapshot();
}
function flush() {
if (grace !== null) {
host.clearTimeout(grace);
grace = null;
}
const writes = held;
held = [];
for (const run of writes) run();
// `useUrlSearch` readers were told about a write that only lands now.
if (writes.length > 0) host.dispatchEvent(new Event(URL_STATE_EVENT));
}
function onPopState(event: { stopImmediatePropagation(): void }) {
if (ownBacks > 0) {
ownBacks -= 1;
event.stopImmediatePropagation();
if (ownBacks > 0) return;
const id = markOf(history.state);
if (id !== undefined && !layerOf(id)) {
// The entry of a sheet that closed while another stood over it.
goBack();
return;
}
if (landing) land(landing);
landing = null;
flush();
return;
}
const id = markOf(history.state);
const kept = layerOf(id);
const closing = layers.splice(kept ? layers.indexOf(kept) + 1 : 0);
if (closing.length === 0) return;
const top = closing[closing.length - 1];
if (location.href === closing[0].baseHref) {
// One step below the sheet, on the page it was opened over.
event.stopImmediatePropagation();
if (top.snapshot) land(top.snapshot);
}
for (const layer of closing.reverse()) layer.onBack();
}
function pushEntry(layer: Layer) {
if (ownBacks > 0) {
held.push(() => pushEntry(layer));
return;
}
if (!layers.includes(layer)) return;
layer.baseHref = location.href;
write("push", withMark(null, layer.id));
snapshot();
}
return {
open(onBack) {
install();
sequence += 1;
const layer: Layer = {
id: `${prefix}-${sequence}`,
onBack,
baseHref: location.href,
snapshot: null,
};
layers.push(layer);
pushEntry(layer);
return {
release() {
const index = layers.indexOf(layer);
if (index === -1) return;
layers.splice(index, 1);
// Below another sheet's entry, or taken by a navigation: a later
// Back of ours steps over it, or there is nothing to take off.
if (markOf(history.state) !== layer.id) return;
if (ownBacks === 0) {
landing = layer.snapshot ?? {
state: history.state,
href: location.href,
};
}
goBack();
},
restore() {
if (layers.includes(layer)) return;
layers.push(layer);
pushEntry(layer);
},
};
},
};
}
let shared: SheetHistory | null = null;
/** The one instance for the page, or null outside a browser. */
export function sheetHistory(): SheetHistory | null {
if (typeof window === "undefined") return null;
shared ??= createSheetHistory(window as unknown as SheetHistoryHost);
return shared;
}
@@ -149,7 +149,7 @@ export function CatalogPicker({
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
<PopoverContent className="w-(--radix-popover-trigger-width) max-w-none p-2">
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
@@ -76,9 +76,10 @@ export function VaccinationSheet({
const detail = useVaccination(vaccination?.id ?? null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Seeded once at mount; the caller remounts on every open by varying `key`.
const [draft, setDraft] = useState<VaccinationDraft>(() =>
const [seed] = useState<VaccinationDraft>(() =>
vaccination ? draftFromVaccination(vaccination) : emptyDraft(),
);
const [draft, setDraft] = useState<VaccinationDraft>(seed);
const [error, setError] = useState<string | null>(null);
const isEdit = vaccination !== null;
@@ -111,6 +112,7 @@ export function VaccinationSheet({
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={JSON.stringify(draft) !== JSON.stringify(seed)}
title={t(isEdit ? "vaccinations.editTitle" : "vaccinations.createTitle")}
description={t("vaccinations.formDescription")}
footer={
@@ -2,18 +2,10 @@
import { useCallback, useRef, useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
ResponsiveSheet,
sheetBodyHasUnsavedInput,
} from "@/components/ui/responsive-sheet";
import {
ManualWorkoutForm,
type ManualWorkoutEdit,
@@ -23,12 +15,11 @@ import { useTranslations } from "@/lib/i18n/context";
/**
* The "Log workout" sheet for a surface that owns its own open state (the
* workouts page header). The dashboard quick-add and the capture picker mount
* `ManualWorkoutForm` inside their own sheets, which already carry the same
* confirm-before-discard guard; this one carries it too, so a half-filled
* form is never dropped by a stray swipe.
* `ManualWorkoutForm` inside their own sheets, which ask before a stray swipe
* drops a half-filled form; this one does too.
*
* With `edit` it opens on a stored workout (#1162). Its fields start filled,
* so "has input" says nothing there; the guard asks the form whether anything
* so "has input" says nothing there; the sheet asks the form whether anything
* differs from what it opened with.
*/
export function ManualWorkoutSheet({
@@ -42,72 +33,33 @@ export function ManualWorkoutSheet({
}) {
const { t } = useTranslations();
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
const editDirty = useRef(false);
const handleDirtyChange = useCallback((dirty: boolean) => {
editDirty.current = dirty;
}, []);
function handleOpenChange(next: boolean) {
if (next) return onOpenChange(true);
if (edit ? editDirty.current : sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
onOpenChange(false);
}
return (
<>
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t(
edit
? "insights.workouts.manual.editTitle"
: "insights.workouts.manual.sheetTitle",
)}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{open && (
<ManualWorkoutForm
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
edit={edit}
onDirtyChange={edit ? handleDirtyChange : undefined}
/>
)}
</ResponsiveSheet>
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
onOpenChange(false);
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
dirty={edit ? () => editDirty.current : sheetBodyHasUnsavedInput}
title={t(
edit
? "insights.workouts.manual.editTitle"
: "insights.workouts.manual.sheetTitle",
)}
description={t("insights.workouts.manual.sheetDescription")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{open && (
<ManualWorkoutForm
onSuccess={() => onOpenChange(false)}
onCancel={() => onOpenChange(false)}
footerSlot={footerEl}
edit={edit}
onDirtyChange={edit ? handleDirtyChange : undefined}
/>
)}
</ResponsiveSheet>
);
}