mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user