mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
chore(release): v1.28.43 — visual consistency polish
Verified UI-consistency findings from the campaign's design audit, each pixel-faithful except the intended correction: - insights hero band: off-scale py-5/gap-5 → the standard scale; H1 font-semibold → font-bold; sm:text-[28px] → sm:text-3xl. - medication-compliance-chart + recent-achievements-card: hand-rolled muted h-4 icon + text-sm headers → the standard TileHeader primitive so they match sibling tiles. - achievements stat tiles + clinician-view shells: p-5 → p-4 md:p-6. - two text-primary header glyphs (research-mode dialog, enroll-mfa) → text-foreground (never accent on a header glyph); the deliberate badge-boxed icons left untouched. - SheetHeader now reserves the trailing close-X gutter like DialogHeader, closing the latent overlap class for future raw-Sheet consumers. - five raw Dialogs on phone-reachable surfaces → ResponsiveSheet. - new bg-card-scoped healthlog/spacing-scale lint (flags p-5/gap-5 etc. only on card shells; list-marker insets + half-steps excluded) at error, with the five offenders it flagged swept. - dropped the stale CLAUDE.md '18 PascalCase outliers' line (0 remain). Every data-slot preserved. No migration.
This commit is contained in:
1 parent
3a9ed8dd0e
commit
6d83115d16
25 files changed
+717
-544
No files matched your search
@@ -49,4 +49,22 @@ describe("<MedicationComplianceChart> v1.4.18 clean-line revert", () => {
|
||||
expect(html).not.toContain("chart-gradient-medication");
|
||||
expect(html).not.toContain("linearGradient");
|
||||
});
|
||||
|
||||
it("leads with the canonical TileHeader, not the old muted h-4 / text-sm row", async () => {
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MedicationComplianceChart } =
|
||||
await import("../medication-compliance-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MedicationComplianceChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Adopts the shared tile-header primitive so the card matches its sibling
|
||||
// HealthChart tiles (foreground h-5 glyph, text-base title).
|
||||
expect(html).toContain('data-slot="tile-header"');
|
||||
// The old hand-rolled muted h-4 icon row is gone.
|
||||
expect(html).not.toContain("text-muted-foreground h-4 w-4");
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from "recharts";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Pill } from "lucide-react";
|
||||
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -402,8 +403,10 @@ export function MedicationComplianceChart({
|
||||
≥sm: original side-by-side layout. */}
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{displayTitle}</h3>
|
||||
{/* Canonical tile header (foreground h-5 icon, text-base title) so
|
||||
this card reads in the same language as its sibling HealthChart
|
||||
tiles instead of the old muted h-4 / text-sm row. */}
|
||||
<TileHeader icon={Pill} title={displayTitle} />
|
||||
{/* `?` explainer for what the adherence percentage measures —
|
||||
shares the i18n body with the per-card bars. */}
|
||||
<ComplianceInfoTip />
|
||||
|
||||
@@ -106,7 +106,7 @@ function Section({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-border bg-card rounded-lg border p-5">
|
||||
<section className="border-border bg-card rounded-lg border p-4 md:p-6">
|
||||
<h2 className="mb-3 text-base font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
@@ -345,7 +345,7 @@ export function ClinicianView({
|
||||
|
||||
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
|
||||
{wellness.length > 0 ? (
|
||||
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-5">
|
||||
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6">
|
||||
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
|
||||
{t("clinicianView.wellness.title")}
|
||||
</h2>
|
||||
|
||||
@@ -202,6 +202,18 @@ describe("<RecentAchievementsCard>", () => {
|
||||
expect(html).toContain("View all");
|
||||
});
|
||||
|
||||
it("routes the header through the canonical TileHeader (foreground icon, h2)", () => {
|
||||
mockData = { summary: {}, achievements: [], metrics: {} };
|
||||
const html = render();
|
||||
// Adopts the shared tile-header primitive so it matches sibling tiles…
|
||||
expect(html).toContain('data-slot="tile-header"');
|
||||
// …with the foreground (never muted) glyph and the h2 heading, not the
|
||||
// old muted h-4 / text-sm row.
|
||||
expect(html).toContain("Recent unlocks");
|
||||
expect(html).not.toMatch(/<h2[^>]*text-sm font-semibold/);
|
||||
expect(html).not.toContain("text-muted-foreground h-4 w-4");
|
||||
});
|
||||
|
||||
it("renders up to three most-recent unlocks with title + date", () => {
|
||||
mockData = {
|
||||
summary: {},
|
||||
|
||||
@@ -25,6 +25,7 @@ import { formatDate } from "@/lib/format";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
import type { AchievementProgress } from "@/lib/gamification/achievements";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Activity,
|
||||
@@ -113,23 +114,23 @@ export function RecentAchievementsCard() {
|
||||
data-slot="recent-achievements-card"
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy
|
||||
className="text-muted-foreground h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t("achievements.dashboardCard.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/achievements"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
|
||||
>
|
||||
{t("achievements.dashboardCard.viewAll")}
|
||||
</Link>
|
||||
</div>
|
||||
{/* Canonical tile header (foreground h-5 icon, text-base h2) so the
|
||||
dashboard card matches its sibling tiles instead of the old muted
|
||||
h-4 / text-sm row. The "view all" link rides the header's right slot
|
||||
(§11 inline-action-row). */}
|
||||
<TileHeader
|
||||
icon={Trophy}
|
||||
titleAs="h2"
|
||||
title={t("achievements.dashboardCard.title")}
|
||||
right={
|
||||
<Link
|
||||
href="/achievements"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
|
||||
>
|
||||
{t("achievements.dashboardCard.viewAll")}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{isPending ? (
|
||||
<ul
|
||||
|
||||
@@ -55,6 +55,27 @@ describe("<HeroStrip>", () => {
|
||||
expect(html).toContain("Good morning");
|
||||
});
|
||||
|
||||
it("renders the H1 at the canonical bold weight and on-scale sizes", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
const greeting = html.match(
|
||||
/data-slot="insights-hero-strip-greeting"[^>]*class="([^"]*)"/,
|
||||
);
|
||||
expect(greeting).not.toBeNull();
|
||||
// App H1 weight is bold (UI-STANDARDS §5) — never font-semibold.
|
||||
expect(greeting![1]).toContain("font-bold");
|
||||
expect(greeting![1]).not.toContain("font-semibold");
|
||||
// No off-scale arbitrary type sizes (§5) — round to the neighbouring step.
|
||||
expect(greeting![1]).not.toContain("text-[28px]");
|
||||
expect(greeting![1]).toContain("sm:text-3xl");
|
||||
});
|
||||
|
||||
it("keeps the hero band and its columns on the spacing scale (no step-5)", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
// The band's own padding + the flex column gaps stay on 4/6, never 5.
|
||||
expect(html).not.toContain("py-5");
|
||||
expect(html).not.toContain("gap-5");
|
||||
});
|
||||
|
||||
it("renders the morning greeting in German", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
|
||||
expect(html).toContain("Guten Morgen");
|
||||
|
||||
@@ -257,7 +257,7 @@ export function HeroStrip({
|
||||
// it the shadow bled through the sticky section nav below
|
||||
// (the nav uses bg-background/80 + backdrop-blur, which the
|
||||
// shadow leaked through).
|
||||
"relative isolate overflow-hidden rounded-xl px-4 py-5 sm:px-6 sm:py-6",
|
||||
"relative isolate overflow-hidden rounded-xl px-4 py-4 sm:px-6 sm:py-6",
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
@@ -290,12 +290,12 @@ export function HeroStrip({
|
||||
from first paint. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-5",
|
||||
"flex flex-col gap-4",
|
||||
(healthScore || healthScorePending) &&
|
||||
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
|
||||
@@ -307,7 +307,7 @@ export function HeroStrip({
|
||||
/>
|
||||
<h1
|
||||
data-slot="insights-hero-strip-greeting"
|
||||
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
|
||||
className="text-2xl leading-tight font-bold tracking-tight sm:text-3xl"
|
||||
>
|
||||
{greeting}
|
||||
</h1>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function ResearchModeAcknowledgmentDialog({
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<BookOpenCheck className="text-primary h-5 w-5 shrink-0" />
|
||||
<BookOpenCheck className="text-foreground h-5 w-5 shrink-0" />
|
||||
{t("medications.researchMode.dialog.title")}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -45,14 +45,7 @@ import { useCallback, useId, useRef, useState } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -208,92 +201,20 @@ export function NaturalLanguageExtractor({
|
||||
const overLimit = charCount > MAX_TEXT_LENGTH;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) handleClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent aria-busy={busy || undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.scheduling.naturalLanguage.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<Label htmlFor={textareaId} className="sr-only">
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"medications.scheduling.naturalLanguage.placeholder",
|
||||
)}
|
||||
rows={4}
|
||||
maxLength={MAX_TEXT_LENGTH + 200}
|
||||
disabled={busy}
|
||||
aria-invalid={overLimit || error?.kind === "empty" || undefined}
|
||||
aria-describedby={
|
||||
errorMessage ? `${textareaId}-error` : undefined
|
||||
}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-1 text-right text-xs">
|
||||
{charCount} / {MAX_TEXT_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p
|
||||
id={`${textareaId}-error`}
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-muted-foreground text-xs font-medium">
|
||||
{t("medications.scheduling.naturalLanguage.examples.label")}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.weekly",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.daily",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.rolling",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
</span>
|
||||
}
|
||||
description={t("medications.scheduling.naturalLanguage.description")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -322,9 +243,73 @@ export function NaturalLanguageExtractor({
|
||||
? t("medications.scheduling.naturalLanguage.submitBusy")
|
||||
: t("medications.scheduling.naturalLanguage.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3" aria-busy={busy || undefined}>
|
||||
<div>
|
||||
<Label htmlFor={textareaId} className="sr-only">
|
||||
{t("medications.scheduling.naturalLanguage.title")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={textareaId}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
placeholder={t(
|
||||
"medications.scheduling.naturalLanguage.placeholder",
|
||||
)}
|
||||
rows={4}
|
||||
maxLength={MAX_TEXT_LENGTH + 200}
|
||||
disabled={busy}
|
||||
aria-invalid={overLimit || error?.kind === "empty" || undefined}
|
||||
aria-describedby={errorMessage ? `${textareaId}-error` : undefined}
|
||||
/>
|
||||
<div className="text-muted-foreground mt-1 text-right text-xs">
|
||||
{charCount} / {MAX_TEXT_LENGTH}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p
|
||||
id={`${textareaId}-error`}
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="text-muted-foreground text-xs font-medium">
|
||||
{t("medications.scheduling.naturalLanguage.examples.label")}
|
||||
</legend>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.weekly",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t("medications.scheduling.naturalLanguage.examples.daily")}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ExampleChip
|
||||
onPick={insertExample}
|
||||
label={t(
|
||||
"medications.scheduling.naturalLanguage.examples.rolling",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
* (pill packs count too).
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useId, useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
useQuery,
|
||||
@@ -48,14 +48,7 @@ import { Loader2, PackageOpen, Plus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DeleteButton } from "@/components/data-list/delete-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -500,6 +493,7 @@ export function AddInventoryDialog({
|
||||
useState<ContainerType>(initialContainerType);
|
||||
const [expiry, setExpiry] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
const parsed = Number(quantity);
|
||||
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
|
||||
@@ -531,152 +525,147 @@ export function AddInventoryDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.detail.bestand.addTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.bestand.addDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-container-type"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.containerTypeLabel")}
|
||||
</label>
|
||||
<Select
|
||||
value={containerType}
|
||||
onValueChange={(v) => setContainerType(v as ContainerType)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="inventory-add-container-type"
|
||||
className="w-full"
|
||||
data-slot="inventory-container-type"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTAINER_TYPES.map((ct) => (
|
||||
<SelectItem key={ct} value={ct}>
|
||||
{t(`medications.detail.bestand.containerType.${ct}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-quantity"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityLabel")}
|
||||
</label>
|
||||
{unitsPerDose > 1 && (
|
||||
<div
|
||||
className="border-border/60 inline-flex rounded-md border p-0.5"
|
||||
role="group"
|
||||
aria-label={t("medications.detail.bestand.addQuantityLabel")}
|
||||
data-slot="inventory-quantity-mode"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "doses" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "doses"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("doses")}
|
||||
>
|
||||
{t("medications.detail.bestand.quantityModeDoses")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "units" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "units"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("units")}
|
||||
>
|
||||
{t("medications.detail.bestand.quantityModeUnits")}
|
||||
</Button>
|
||||
</div>
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={t("medications.detail.bestand.addTitle")}
|
||||
description={t("medications.detail.bestand.addDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={!quantityValid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
id="inventory-add-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={
|
||||
effectiveMode === "doses"
|
||||
? Math.floor(1000 / unitsPerDose)
|
||||
: 1000
|
||||
}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
aria-describedby="inventory-add-quantity-helper"
|
||||
/>
|
||||
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="inventory-quantity-conversion"
|
||||
{t("medications.detail.bestand.addSubmit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={formId} onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-container-type"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.containerTypeLabel")}
|
||||
</label>
|
||||
<Select
|
||||
value={containerType}
|
||||
onValueChange={(v) => setContainerType(v as ContainerType)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="inventory-add-container-type"
|
||||
className="w-full"
|
||||
data-slot="inventory-container-type"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTAINER_TYPES.map((ct) => (
|
||||
<SelectItem key={ct} value={ct}>
|
||||
{t(`medications.detail.bestand.containerType.${ct}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-quantity"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityLabel")}
|
||||
</label>
|
||||
{unitsPerDose > 1 && (
|
||||
<div
|
||||
className="border-border/60 inline-flex rounded-md border p-0.5"
|
||||
role="group"
|
||||
aria-label={t("medications.detail.bestand.addQuantityLabel")}
|
||||
data-slot="inventory-quantity-mode"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "doses" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "doses"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("doses")}
|
||||
>
|
||||
{effectiveMode === "doses"
|
||||
? t("medications.detail.bestand.quantityInUnits", { units })
|
||||
: // A unit count below one dose must not read "≈ 0 doses" —
|
||||
// it is simply less than one dose.
|
||||
Math.floor(parsed / unitsPerDose) === 0
|
||||
? t("medications.detail.bestand.quantityUnderOneDose")
|
||||
: t("medications.detail.bestand.quantityInDoses", {
|
||||
doses: Math.floor(parsed / unitsPerDose),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{t("medications.detail.bestand.quantityModeDoses")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={quantityMode === "units" ? "secondary" : "ghost"}
|
||||
aria-pressed={quantityMode === "units"}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setQuantityMode("units")}
|
||||
>
|
||||
{t("medications.detail.bestand.quantityModeUnits")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
id="inventory-add-quantity"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={
|
||||
effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000
|
||||
}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
aria-describedby="inventory-add-quantity-helper"
|
||||
/>
|
||||
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
|
||||
<p
|
||||
id="inventory-add-quantity-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="inventory-quantity-conversion"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityHelper")}
|
||||
{effectiveMode === "doses"
|
||||
? t("medications.detail.bestand.quantityInUnits", { units })
|
||||
: // A unit count below one dose must not read "≈ 0 doses" —
|
||||
// it is simply less than one dose.
|
||||
Math.floor(parsed / unitsPerDose) === 0
|
||||
? t("medications.detail.bestand.quantityUnderOneDose")
|
||||
: t("medications.detail.bestand.quantityInDoses", {
|
||||
doses: Math.floor(parsed / unitsPerDose),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-add-expiry"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.addExpiryLabel")}
|
||||
</label>
|
||||
<DateField
|
||||
id="inventory-add-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!quantityValid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("medications.detail.bestand.addSubmit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
<p
|
||||
id="inventory-add-quantity-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.addQuantityHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="inventory-add-expiry" className="text-sm font-medium">
|
||||
{t("medications.detail.bestand.addExpiryLabel")}
|
||||
</label>
|
||||
<DateField
|
||||
id="inventory-add-expiry"
|
||||
value={expiry}
|
||||
onChange={setExpiry}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -703,6 +692,7 @@ function AdjustInventoryDialog({
|
||||
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
const parsed = Number(value);
|
||||
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
|
||||
@@ -733,68 +723,65 @@ function AdjustInventoryDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.bestand.adjustTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.bestand.adjustDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-adjust-remaining"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustQuantityLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-adjust-remaining"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={item.unitsTotal ?? undefined}
|
||||
step="any"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
aria-describedby="inventory-adjust-helper"
|
||||
/>
|
||||
<p
|
||||
id="inventory-adjust-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustHelper", {
|
||||
total:
|
||||
item.unitsTotal ?? t("medications.detail.bestand.unknown"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!valid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("medications.detail.bestand.adjustSubmit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={t("medications.detail.bestand.adjustTitle")}
|
||||
description={t("medications.detail.bestand.adjustDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={!valid || busy}
|
||||
aria-busy={busy || undefined}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("medications.detail.bestand.adjustSubmit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={formId} onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="inventory-adjust-remaining"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustQuantityLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="inventory-adjust-remaining"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
max={item.unitsTotal ?? undefined}
|
||||
step="any"
|
||||
required
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
aria-describedby="inventory-adjust-helper"
|
||||
/>
|
||||
<p
|
||||
id="inventory-adjust-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.adjustHelper", {
|
||||
total: item.unitsTotal ?? t("medications.detail.bestand.unknown"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -823,6 +810,7 @@ function PackagingDialog({
|
||||
dosesPerUnit === null ? "" : String(dosesPerUnit),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const formId = useId();
|
||||
|
||||
const parsedPerDose = Number(perDoseValue);
|
||||
const perDoseValid =
|
||||
@@ -864,94 +852,92 @@ function PackagingDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.detail.bestand.packagingTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.detail.bestand.packagingDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="packaging-units-per-dose"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-units-per-dose"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={perDoseValue}
|
||||
onChange={(e) => setPerDoseValue(e.target.value)}
|
||||
aria-describedby="packaging-units-per-dose-helper"
|
||||
/>
|
||||
<p
|
||||
id="packaging-units-per-dose-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="packaging-default-pack"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingDefaultPackLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-default-pack"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
step={1}
|
||||
autoComplete="off"
|
||||
value={packValue}
|
||||
onChange={(e) => setPackValue(e.target.value)}
|
||||
aria-describedby="packaging-default-pack-helper"
|
||||
/>
|
||||
<p
|
||||
id="packaging-default-pack-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingDefaultPackHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!perDoseValid || !packValid || busy}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ResponsiveSheet
|
||||
open
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={t("medications.detail.bestand.packagingTitle")}
|
||||
description={t("medications.detail.bestand.packagingDescription")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={!perDoseValid || !packValid || busy}
|
||||
>
|
||||
{busy && (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={formId} onSubmit={submit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="packaging-units-per-dose"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-units-per-dose"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
required
|
||||
autoComplete="off"
|
||||
value={perDoseValue}
|
||||
onChange={(e) => setPerDoseValue(e.target.value)}
|
||||
aria-describedby="packaging-units-per-dose-helper"
|
||||
/>
|
||||
<p
|
||||
id="packaging-units-per-dose-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="packaging-default-pack"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingDefaultPackLabel")}
|
||||
</label>
|
||||
<Input
|
||||
id="packaging-default-pack"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
step={1}
|
||||
autoComplete="off"
|
||||
value={packValue}
|
||||
onChange={(e) => setPackValue(e.target.value)}
|
||||
aria-describedby="packaging-default-pack-helper"
|
||||
/>
|
||||
<p
|
||||
id="packaging-default-pack-helper"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("medications.detail.bestand.packagingDefaultPackHelper")}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function DoneScreen() {
|
||||
<section
|
||||
aria-labelledby="onboarding-ai-panel-title"
|
||||
data-slot="onboarding-ai-panel"
|
||||
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-5 text-left"
|
||||
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-4 text-left md:p-6"
|
||||
>
|
||||
<header className="flex items-start gap-3">
|
||||
<span
|
||||
|
||||
@@ -5,13 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save, Shield, User } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -506,7 +500,7 @@ export function AccountSection() {
|
||||
Erweitert. It is a maintenance / reset action, not a profile or
|
||||
security control, so it sits beside Research Mode + the danger zone. */}
|
||||
|
||||
<Dialog
|
||||
<ResponsiveSheet
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPasswordDialogOpen(open);
|
||||
@@ -518,81 +512,73 @@ export function AccountSection() {
|
||||
setPasswordMsgType(null);
|
||||
}
|
||||
}}
|
||||
title={t("settings.passwordReset")}
|
||||
description={t("settings.changePasswordDescription")}
|
||||
className="sm:max-w-xl"
|
||||
>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("settings.passwordReset")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.changePasswordDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">
|
||||
{t("settings.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password">
|
||||
{t("settings.currentPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="current-password"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("settings.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{passwordMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
passwordMsgType === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(passwordMsg, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={passwordSaving}
|
||||
{passwordMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
passwordMsgType === "success"
|
||||
? "text-success"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{passwordSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{statusText(passwordMsg, t)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={passwordSaving}
|
||||
>
|
||||
{passwordSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { SheetHeader } from "../sheet";
|
||||
|
||||
/**
|
||||
* The raw `SheetHeader` reserves a trailing gutter for the sheet's absolute
|
||||
* close-X the way `DialogHeader` (pr-9) does — sized to the sheet close
|
||||
* button (`right-4`, min-w-9…11) so a long raw-`<Sheet>` title never slides
|
||||
* under the X. Latent hardening (every current consumer passes its own
|
||||
* header/close), but it forecloses the Documents-class overlap at the
|
||||
* primitive. Consumers that render no close-X drop the reserve via className,
|
||||
* and tailwind-merge keeps the later value.
|
||||
*/
|
||||
describe("<SheetHeader>", () => {
|
||||
it("reserves the trailing close-X gutter (pr-12) by default", () => {
|
||||
const html = renderToStaticMarkup(<SheetHeader>Title</SheetHeader>);
|
||||
expect(html).toContain('data-slot="sheet-header"');
|
||||
expect(html).toContain("pr-12");
|
||||
});
|
||||
|
||||
it("lets a consumer className override the reserve", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SheetHeader className="p-3">Title</SheetHeader>,
|
||||
);
|
||||
// A later padding utility from the consumer wins under tailwind-merge.
|
||||
expect(html).toContain("p-3");
|
||||
expect(html).not.toMatch(/class="[^"]*\bpr-12\b/);
|
||||
});
|
||||
});
|
||||
@@ -102,7 +102,14 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
// Reserve the trailing gutter for the absolute close-X the way
|
||||
// `DialogHeader` (pr-9) does, sized to the sheet's own close button
|
||||
// (`top-3 right-4`, min-w-9…11) so a long raw-`<Sheet>` title never
|
||||
// slides under the X — the Documents close-X overlap class, foreclosed
|
||||
// at the primitive. Consumers that render `showCloseButton={false}`
|
||||
// (or their own close affordance) drop it back with `pr-4` via
|
||||
// `className` — tailwind-merge keeps the later value.
|
||||
className={cn("flex flex-col gap-1.5 p-4 pr-12", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user