fix(medications): rebuild the advanced-settings sheet as a single column

Drop the lg:grid-cols-2 data|reminders grid that read as overloaded.
The sheet now narrows from 4xl to 2xl and stacks four bordered section
cards in most-used-first order: Reminders, Lifecycle, Data, Danger. One
spacing rule throughout — space-y-4 between cards, divide-y plus py-3
rows inside each card, with the SettingsGroup card owning the dividers
so no row interleaves its own separator.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 22:54:20 +02:00
1 parent a3b34bc0cf
commit 795898bb0a
9 files changed
+113 -127

No files matched your search

+1 -2
View File
@@ -1281,8 +1281,7 @@
"group": {
"data": "Daten",
"reminders": "Erinnerungen",
"lifecycle": "Lebenszyklus",
"danger": "Gefahrenzone"
"lifecycle": "Lebenszyklus"
},
"dataPortability": {
"import": {
+1 -2
View File
@@ -1281,8 +1281,7 @@
"group": {
"data": "Data",
"reminders": "Reminders",
"lifecycle": "Lifecycle",
"danger": "Danger zone"
"lifecycle": "Lifecycle"
},
"dataPortability": {
"import": {
+1 -2
View File
@@ -1281,8 +1281,7 @@
"group": {
"data": "Datos",
"reminders": "Recordatorios",
"lifecycle": "Ciclo de vida",
"danger": "Zona de peligro"
"lifecycle": "Ciclo de vida"
},
"dataPortability": {
"import": {
+1 -2
View File
@@ -1281,8 +1281,7 @@
"group": {
"data": "Données",
"reminders": "Rappels",
"lifecycle": "Cycle de vie",
"danger": "Zone de danger"
"lifecycle": "Cycle de vie"
},
"dataPortability": {
"import": {
+1 -2
View File
@@ -1281,8 +1281,7 @@
"group": {
"data": "Dati",
"reminders": "Promemoria",
"lifecycle": "Ciclo di vita",
"danger": "Zona pericolosa"
"lifecycle": "Ciclo di vita"
},
"dataPortability": {
"import": {
+1 -2
View File
@@ -1281,8 +1281,7 @@
"group": {
"data": "Dane",
"reminders": "Przypomnienia",
"lifecycle": "Cykl życia",
"danger": "Strefa zagrożenia"
"lifecycle": "Cykl życia"
},
"dataPortability": {
"import": {
@@ -1,14 +1,14 @@
/**
* v1.7.0 — AdvancedSettingsSheet composition contract.
*
* The sheet hosts four labelled groups — Data → Reminders → Lifecycle →
* Danger zone — composed from bare section bodies. `<ResponsiveSheet>`
* The sheet hosts four blocks — Reminders → Lifecycle → Data → Danger
* zone — composed from bare section bodies. `<ResponsiveSheet>`
* wraps Radix portals that `renderToStaticMarkup` does not materialise,
* so we mock it to a passthrough that renders children only when `open`
* and echoes `contentWidth`. The bodies are mocked to ordered markers.
* The test pins:
* - the four groups render in the documented order;
* - the sheet opens at the `4xl` width token;
* - the four blocks render in the documented order;
* - the sheet opens at the `2xl` width token;
* - `onRequestPhaseSheet` threads through to the phases row;
* - `onOpenImport` wires the import button (co-located with export);
* - the export control sits beside the import control;
@@ -107,27 +107,27 @@ function baseProps(
}
describe("AdvancedSettingsSheet (v1.7.0)", () => {
it("renders the four groups in Data → Reminders → Lifecycle → Danger order", () => {
it("renders the four blocks in Reminders → Lifecycle → Data → Danger order", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet
{...baseProps(true, { onRequestPhaseSheet: () => {} })}
/>,
);
const data = html.indexOf("advanced-group-data");
const reminders = html.indexOf("advanced-group-reminders");
const lifecycle = html.indexOf("advanced-group-lifecycle");
const danger = html.indexOf("advanced-group-danger");
expect(data).toBeGreaterThan(-1);
expect(reminders).toBeGreaterThan(data);
const data = html.indexOf("advanced-group-data");
const danger = html.indexOf("mock-danger");
expect(reminders).toBeGreaterThan(-1);
expect(lifecycle).toBeGreaterThan(reminders);
expect(danger).toBeGreaterThan(lifecycle);
expect(data).toBeGreaterThan(lifecycle);
expect(danger).toBeGreaterThan(data);
});
it("opens at the 4xl width token", () => {
it("opens at the 2xl width token", () => {
const html = renderToStaticMarkup(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
expect(html).toContain('data-content-width="4xl"');
expect(html).toContain('data-content-width="2xl"');
});
it("threads onRequestPhaseSheet through to the phases row", () => {
@@ -3,24 +3,21 @@
/**
* v1.7.0 — Advanced settings sheet.
* v1.7.2 — widened to `4xl` and re-laid into a responsive two-column
* grid so a normal desktop viewport shows every group without forcing
* a long single-column scroll.
* grid.
* v1.8.6 — rebuilt as a single column. The two-column data|reminders
* grid read as overloaded; the sheet now narrows to `2xl` (~672px) and
* stacks four bordered section cards in most-used-first order:
* Reminders → Lifecycle → Data → Danger. One spacing rule throughout —
* `space-y-4` between cards, `divide-y` + `py-3` rows inside each card
* (the `<SettingsGroup>` card owns the dividers).
*
* Four labelled groups under one consistent visual system (R-medui §5):
*
* - Data: intake Import + medications CSV Export (co-located) +
* external-API endpoint / token.
* - Reminders: notifications switch + reminder window (grace).
* - Lifecycle: pause/resume + end course + phases.
* - Danger zone: purge intake history + delete medication.
*
* Layout: on `lg+` the first three groups flow into a two-column grid —
* the Data group (tallest, with the export toggle + token snippets)
* holds the left column; Reminders + Lifecycle stack down the right.
* The Danger zone always spans the full width at the bottom, set off by
* its own destructive-tinted card, so an irreversible action never
* shares a column with a routine toggle. On `<lg` everything stacks in
* the documented Data → Reminders → Lifecycle → Danger order.
* - Data: intake Import + medications CSV Export (co-located) +
* external-API endpoint / token.
* - Danger zone: purge intake history + delete medication, set off by
* its own destructive-tinted card so an irreversible action never
* sits next to a routine toggle.
*
* Button styling is consistent: reversible / neutral actions are
* `outline`, destructive actions are `destructive font-semibold`,
@@ -32,7 +29,6 @@
* self-saves, so the footer slot stays empty.
*/
import { Separator } from "@/components/ui/separator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SettingsGroup } from "@/components/medications/settings-group";
import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row";
@@ -95,96 +91,86 @@ export function AdvancedSettingsSheet({
open={open}
onOpenChange={onOpenChange}
title={t("medications.detail.advanced.title")}
contentWidth="4xl"
bodyClassName="gap-6"
contentWidth="2xl"
bodyClassName="gap-4"
>
<div className="space-y-6" data-slot="advanced-settings-sheet-body">
{/* Top region: Data + Reminders + Lifecycle in a two-column grid
on lg+, single column below. `items-start` keeps each group
top-aligned so the shorter right column never stretches. */}
<div className="grid items-start gap-6 lg:grid-cols-2">
{/* DATA — import + export + external API. Tallest group → left
column on lg+ so the right column's two shorter groups
balance against it. */}
<SettingsGroup
label={t("medications.detail.advanced.group.data")}
dataSlot="advanced-group-data"
>
<div className="space-y-4" data-slot="advanced-settings-sheet-body">
{/* REMINDERS — notifications + reminder window. Most-used group
first; each row is a padded `<div>` so the group card's
`divide-y` draws the hairline between them. */}
<SettingsGroup
label={t("medications.detail.advanced.group.reminders")}
dataSlot="advanced-group-reminders"
>
<div className="py-3">
<NotificationsBody
medicationId={medicationId}
notificationsEnabled={notificationsEnabled}
/>
</div>
<div className="py-3">
<GraceRow
medicationId={medicationId}
reminderGraceMinutes={reminderGraceMinutes}
/>
</div>
</SettingsGroup>
{/* LIFECYCLE — pause/resume + end course + phases */}
<SettingsGroup
label={t("medications.detail.advanced.group.lifecycle")}
dataSlot="advanced-group-lifecycle"
>
<div className="py-3">
<LifecycleManageBody
medicationId={medicationId}
medicationName={medicationName}
active={active}
onAfterAction={() => onOpenChange(false)}
/>
</div>
{isGlp1 && (
<div className="py-3">
<PhasesRow
medicationId={medicationId}
treatmentClass={treatmentClass}
startsOn={startsOn}
endsOn={endsOn}
onRequestPhaseSheet={onRequestPhaseSheet}
/>
</div>
)}
</SettingsGroup>
{/* DATA — import + export + external API */}
<SettingsGroup
label={t("medications.detail.advanced.group.data")}
dataSlot="advanced-group-data"
>
<div className="py-3">
<DataPortabilityRow
medicationId={medicationId}
onOpenImport={onOpenImport}
/>
<Separator />
</div>
<div className="py-3">
<ApiTokensRow
medicationId={medicationId}
medicationName={medicationName}
/>
</SettingsGroup>
{/* Right column: Reminders + Lifecycle stacked. */}
<div className="space-y-6">
{/* REMINDERS — notifications + reminder window */}
<SettingsGroup
label={t("medications.detail.advanced.group.reminders")}
dataSlot="advanced-group-reminders"
>
<NotificationsBody
medicationId={medicationId}
notificationsEnabled={notificationsEnabled}
/>
<Separator />
<GraceRow
medicationId={medicationId}
reminderGraceMinutes={reminderGraceMinutes}
/>
</SettingsGroup>
{/* LIFECYCLE — pause/resume + end course + phases */}
<SettingsGroup
label={t("medications.detail.advanced.group.lifecycle")}
dataSlot="advanced-group-lifecycle"
>
<LifecycleManageBody
medicationId={medicationId}
medicationName={medicationName}
active={active}
onAfterAction={() => onOpenChange(false)}
/>
{isGlp1 && (
<>
<Separator />
<PhasesRow
medicationId={medicationId}
treatmentClass={treatmentClass}
startsOn={startsOn}
endsOn={endsOn}
onRequestPhaseSheet={onRequestPhaseSheet}
/>
</>
)}
</SettingsGroup>
</div>
</div>
{/* DANGER ZONE — purge + delete. Full-width at the bottom, set
off by the destructive-tinted card inside the body so an
irreversible action never shares a column with a toggle. */}
<SettingsGroup
label={t("medications.detail.advanced.group.danger")}
dataSlot="advanced-group-danger"
>
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount}
onAfterAction={() => onOpenChange(false)}
/>
</SettingsGroup>
{/* DANGER ZONE — purge + delete. Set off by the destructive-
tinted card so an irreversible action never sits next to a
routine toggle. The body supplies its own matched-pair card,
so it slots in without the neutral group frame. */}
<DangerZoneBody
medicationId={medicationId}
medicationName={medicationName}
intakeCount={intakeCount}
onAfterAction={() => onOpenChange(false)}
/>
</div>
</ResponsiveSheet>
);
+14 -8
View File
@@ -2,11 +2,13 @@
/**
* v1.7.0 — advanced-settings group header.
*
* Lightweight presentational grouping for the redesigned
* `<AdvancedSettingsSheet>`: an uppercase micro-label over a hairline
* rule, then the group body. Reads as "one settings document, four
* parts" rather than three bordered section cards.
* v1.8.6 — rebuilt as a bordered section card. The sheet now stacks
* every group in a single column (Reminders → Lifecycle → Data →
* Danger), so each group needs its own frame to read as a distinct
* block rather than a hairline-separated run. The card owns one spacing
* rule: an uppercase micro-label header, then a `divide-y` body where
* each row carries `py-3`. Callers pass one `<div>` per row; the card
* supplies the dividers so no row interleaves its own `<Separator>`.
*
* The label is a plain `<p>` (not a heading with an id) so it stays out
* of the `aria-labelledby` graph — the sheet already owns the dialog
@@ -17,26 +19,30 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface SettingsGroupProps {
label: ReactNode;
children: ReactNode;
dataSlot?: string;
className?: string;
}
export function SettingsGroup({
label,
children,
dataSlot,
className,
}: SettingsGroupProps) {
return (
<section
className="space-y-4"
className={cn("bg-card rounded-lg border px-4 py-1", className)}
{...(dataSlot ? { "data-slot": dataSlot } : {})}
>
<p className="border-border/60 text-muted-foreground border-t pt-1 text-xs font-medium tracking-wide uppercase">
<p className="text-muted-foreground border-border/60 border-b py-2.5 text-xs font-medium tracking-wide uppercase">
{label}
</p>
{children}
<div className="divide-border/60 divide-y">{children}</div>
</section>
);
}