feat(entry): make mood and cycle add a sectioned single sheet

Redesign both add flows as one sheet that is fast by default and deep on
demand: the mood face and a quick row of recently used tags stay open, the
period buttons and flow stay open for cycle, and everything else moves into
collapsible sections that each show a count of what is set inside them. A
shared SheetSection primitive carries the collapse, the summary slot and the
reduced-motion behaviour. Tag, factor and day-log logic is moved, not
rewritten, and the submit path is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 18:59:06 +02:00
commit 8b73acfa9c
15 files changed
+1042 -228

No files matched your search

+10 -2
View File
@@ -720,7 +720,12 @@
"iconGroupWeather": "Wetter",
"iconGroupPlaces": "Orte",
"iconGroupMisc": "Sonstiges"
}
},
"sectionMoreTags": "Weitere Tags",
"sectionFactors": "Faktoren",
"sectionNoteDetails": "Notiz & Details",
"quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt",
"quickTagsExpand": "Weitere"
},
"medications": {
"title": "Medikamente",
@@ -5926,7 +5931,10 @@
"contraceptive": "Verhütung",
"severity": "Schweregrad",
"severityLevel": "Schweregrad {level} von 4",
"delete": "Löschen"
"delete": "Löschen",
"temperatureSection": "Temperatur & Eisprung",
"intimacySection": "Intimität & Verhütung",
"testsSection": "Tests"
},
"settings": {
"title": "Zyklus-Einstellungen",
+10 -2
View File
@@ -720,7 +720,12 @@
"iconGroupWeather": "Weather",
"iconGroupPlaces": "Places",
"iconGroupMisc": "Other"
}
},
"sectionMoreTags": "More tags",
"sectionFactors": "Factors",
"sectionNoteDetails": "Note & details",
"quickTagsHelp": "Your frequent tags — one tap",
"quickTagsExpand": "More"
},
"medications": {
"title": "Medications",
@@ -5926,7 +5931,10 @@
"saved": "Day saved",
"saveError": "Could not save. Try again.",
"periodStarted": "Period start logged",
"clear": "None"
"clear": "None",
"temperatureSection": "Temperature & ovulation",
"intimacySection": "Intimacy & contraception",
"testsSection": "Tests"
},
"testResult": {
"POSITIVE": "Positive",
+10 -2
View File
@@ -720,7 +720,12 @@
"iconGroupWeather": "Tiempo",
"iconGroupPlaces": "Lugares",
"iconGroupMisc": "Otros"
}
},
"sectionMoreTags": "Más etiquetas",
"sectionFactors": "Factores",
"sectionNoteDetails": "Nota y detalles",
"quickTagsHelp": "Tus etiquetas frecuentes: un toque",
"quickTagsExpand": "Más"
},
"medications": {
"title": "Medicamentos",
@@ -5926,7 +5931,10 @@
"contraceptive": "Anticonceptivo",
"severity": "Intensidad",
"severityLevel": "Intensidad {level} de 4",
"delete": "Eliminar"
"delete": "Eliminar",
"temperatureSection": "Temperatura y ovulación",
"intimacySection": "Intimidad y anticoncepción",
"testsSection": "Pruebas"
},
"settings": {
"title": "Ajustes del ciclo",
+10 -2
View File
@@ -720,7 +720,12 @@
"iconGroupWeather": "Météo",
"iconGroupPlaces": "Lieux",
"iconGroupMisc": "Autres"
}
},
"sectionMoreTags": "Plus de tags",
"sectionFactors": "Facteurs",
"sectionNoteDetails": "Note et détails",
"quickTagsHelp": "Vos tags fréquents — en un geste",
"quickTagsExpand": "Plus"
},
"medications": {
"title": "Médicaments",
@@ -5926,7 +5931,10 @@
"contraceptive": "Contraception",
"severity": "Intensité",
"severityLevel": "Intensité {level} sur 4",
"delete": "Supprimer"
"delete": "Supprimer",
"temperatureSection": "Température et ovulation",
"intimacySection": "Intimité et contraception",
"testsSection": "Tests"
},
"settings": {
"title": "Réglages du cycle",
+10 -2
View File
@@ -720,7 +720,12 @@
"iconGroupWeather": "Meteo",
"iconGroupPlaces": "Luoghi",
"iconGroupMisc": "Altro"
}
},
"sectionMoreTags": "Altri tag",
"sectionFactors": "Fattori",
"sectionNoteDetails": "Nota e dettagli",
"quickTagsHelp": "I tuoi tag frequenti: un tocco",
"quickTagsExpand": "Altro"
},
"medications": {
"title": "Farmaci",
@@ -5926,7 +5931,10 @@
"contraceptive": "Contraccettivo",
"severity": "Intensità",
"severityLevel": "Intensità {level} su 4",
"delete": "Elimina"
"delete": "Elimina",
"temperatureSection": "Temperatura e ovulazione",
"intimacySection": "Intimità e contraccezione",
"testsSection": "Test"
},
"settings": {
"title": "Impostazioni del ciclo",
+10 -2
View File
@@ -720,7 +720,12 @@
"iconGroupWeather": "Pogoda",
"iconGroupPlaces": "Miejsca",
"iconGroupMisc": "Inne"
}
},
"sectionMoreTags": "Więcej tagów",
"sectionFactors": "Czynniki",
"sectionNoteDetails": "Notatka i szczegóły",
"quickTagsHelp": "Twoje częste tagi — jedno dotknięcie",
"quickTagsExpand": "Więcej"
},
"medications": {
"title": "Leki",
@@ -5926,7 +5931,10 @@
"contraceptive": "Antykoncepcja",
"severity": "Nasilenie",
"severityLevel": "Nasilenie {level} z 4",
"delete": "Usuń"
"delete": "Usuń",
"temperatureSection": "Temperatura i owulacja",
"intimacySection": "Intymność i antykoncepcja",
"testsSection": "Testy"
},
"settings": {
"title": "Ustawienia cyklu",
@@ -3,6 +3,11 @@ import { describe, it, expect } from "vitest";
import {
buildDayLogPatch,
buildDayLogInput,
symptomsCount,
temperatureCount,
intimacyCount,
testsCount,
noteCount,
type DayLogFormState,
} from "../log-day-sheet";
@@ -136,3 +141,56 @@ describe("buildDayLogInput (new row → omit empties)", () => {
expect("temperatureExcluded" in input).toBe(false);
});
});
describe("section summary-badge counts (v1.17.0)", () => {
it("counts each logged symptom", () => {
expect(symptomsCount(blank())).toBe(0);
const s = { ...blank(), symptoms: new Map([["cramps", 2], ["headache", null]]) };
expect(symptomsCount(s)).toBe(2);
});
it("counts only the rendered symptothermal sign alongside BBT and OPK", () => {
expect(temperatureCount(blank(), false)).toBe(0);
// A non-finite / empty BBT string does not count.
expect(temperatureCount({ ...blank(), bbt: "abc" }, false)).toBe(0);
const s: DayLogFormState = {
...blank(),
bbt: "36.5",
opk: "POSITIVE_LH_SURGE",
mucus: "EGG_WHITE",
cervixPosition: "HIGH",
cervixFirmness: "SOFT",
cervixOpening: "OPEN",
};
// Mucus surface (showCervix=false): BBT + OPK + mucus = 3; cervix signs
// are not rendered, so they don't count.
expect(temperatureCount(s, false)).toBe(3);
// Cervix surface (showCervix=true): BBT + OPK + 3 cervix signs = 5; the
// stale mucus value is not rendered, so it doesn't count.
expect(temperatureCount(s, true)).toBe(5);
});
it("counts intercourse and contraceptive", () => {
expect(intimacyCount(blank())).toBe(0);
expect(
intimacyCount({ ...blank(), intercourse: true, contraceptive: "ORAL" }),
).toBe(2);
});
it("counts each home-test result", () => {
expect(testsCount(blank())).toBe(0);
expect(
testsCount({
...blank(),
pregnancyTest: "NEGATIVE",
progesteroneTest: "POSITIVE",
}),
).toBe(2);
});
it("counts a note only when it carries non-whitespace content", () => {
expect(noteCount(blank())).toBe(0);
expect(noteCount({ ...blank(), note: " " })).toBe(0);
expect(noteCount({ ...blank(), note: "tired today" })).toBe(1);
});
});
+170 -74
View File
@@ -42,6 +42,10 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SheetSection,
SheetSectionCount,
} from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
@@ -249,6 +253,62 @@ export function buildDayLogInput(
};
}
/**
* v1.17.0 — section summary-badge counts for the sectioned log-day sheet.
*
* Each helper reports how many fields a collapsed section holds, so the
* badge communicates its contents without expanding it. Pure + exported
* for the unit tests.
*/
export function symptomsCount(s: DayLogFormState): number {
return s.symptoms.size;
}
/**
* Temperature & ovulation: BBT reading, OPK, and the symptothermal secondary
* sign. Only the sign the section actually renders is counted — mucus when
* `showCervix` is false, the three cervix observations when true — so the
* badge never reports a stale value from the surface that isn't shown (e.g.
* after the user flips the advanced-settings secondary-symptom choice).
*/
export function temperatureCount(
s: DayLogFormState,
showCervix: boolean,
): number {
let n = 0;
if (resolveBbt(s.bbt) != null) n += 1;
if (s.opk) n += 1;
if (showCervix) {
if (s.cervixPosition) n += 1;
if (s.cervixFirmness) n += 1;
if (s.cervixOpening) n += 1;
} else if (s.mucus) {
n += 1;
}
return n;
}
/** Intimacy & contraception: intercourse logged, and any contraceptive. */
export function intimacyCount(s: DayLogFormState): number {
let n = 0;
if (s.intercourse) n += 1;
if (s.contraceptive) n += 1;
return n;
}
/** Tests: pregnancy + progesterone home-test results. */
export function testsCount(s: DayLogFormState): number {
let n = 0;
if (s.pregnancyTest) n += 1;
if (s.progesteroneTest) n += 1;
return n;
}
/** Note: 1 when the free-text note carries content. */
export function noteCount(s: DayLogFormState): number {
return s.note.trim() ? 1 : 0;
}
export interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -421,25 +481,28 @@ export function LogDaySheet({
});
}
// The live form state, shared by the save-payload builders and the section
// summary badges (so a collapsed section reports what it holds).
const formState: DayLogFormState = {
flow,
intermenstrual,
bbt,
bbtDisturbed,
opk,
mucus,
cervixPosition,
cervixFirmness,
cervixOpening,
intercourse,
protectedSex,
pregnancyTest,
progesteroneTest,
contraceptive,
note,
symptoms,
};
async function handleSave() {
const state: DayLogFormState = {
flow,
intermenstrual,
bbt,
bbtDisturbed,
opk,
mucus,
cervixPosition,
cervixFirmness,
cervixOpening,
intercourse,
protectedSex,
pregnancyTest,
progesteroneTest,
contraceptive,
note,
symptoms,
};
// v1.16.4 — catch so a rejected save doesn't escape as an unhandled
// rejection; the inline `logDay.isError || patchDay.isError` strip in the
// footer carries the visible failure signal and the sheet stays open.
@@ -449,10 +512,10 @@ export function LogDaySheet({
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
await patchDay.mutateAsync({
id: rowId,
patch: buildDayLogPatch(state),
patch: buildDayLogPatch(formState),
});
} else {
await logDay.mutateAsync(buildDayLogInput(state, date));
await logDay.mutateAsync(buildDayLogInput(formState, date));
}
onOpenChange(false);
} catch {
@@ -573,7 +636,9 @@ export function LogDaySheet({
) : null}
</div>
{/* Flow */}
{/* Quick row — Flow is the single most common period sign, so it
stays always-open at the top alongside the intermenstrual toggle.
Everything else lives in collapsible sections below. */}
<Field label={t("cycle.sheet.flow")}>
<div className="flex flex-wrap gap-2">
{FLOW_LEVELS.map((f) => (
@@ -599,7 +664,10 @@ export function LogDaySheet({
</Field>
{/* Symptoms */}
<Field label={t("cycle.sheet.symptoms")}>
<SheetSection
title={t("cycle.sheet.symptoms")}
summary={<SheetSectionCount count={symptomsCount(formState)} />}
>
<div className="space-y-3">
{CYCLE_SYMPTOM_CATALOG.map((cat) => (
<div key={cat.key} className="space-y-1.5">
@@ -656,10 +724,18 @@ export function LogDaySheet({
</div>
</div>
</div>
</Field>
</SheetSection>
{/* BBT */}
<Field label={t("cycle.sheet.temperature")}>
{/* Temperature & ovulation — BBT, OPK, and the symptothermal secondary
sign (mucus or cervix) grouped under one disclosure. */}
<SheetSection
title={t("cycle.sheet.temperatureSection")}
summary={
<SheetSectionCount count={temperatureCount(formState, showCervix)} />
}
>
{/* BBT */}
<Field label={t("cycle.sheet.temperature")}>
<input
type="number"
inputMode="decimal"
@@ -756,9 +832,15 @@ export function LogDaySheet({
</div>
</Field>
)}
</SheetSection>
{/* Intercourse + protection */}
<Field label={t("cycle.sheet.intercourse")}>
{/* Intimacy & contraception */}
<SheetSection
title={t("cycle.sheet.intimacySection")}
summary={<SheetSectionCount count={intimacyCount(formState)} />}
>
{/* Intercourse + protection */}
<Field label={t("cycle.sheet.intercourse")}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="cycle-intercourse" className="text-sm font-normal">
@@ -786,57 +868,71 @@ export function LogDaySheet({
</div>
) : null}
</div>
</Field>
</Field>
{/* Pregnancy test */}
<Field label={t("cycle.sheet.pregnancyTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={pregnancyTest === v}
onClick={() => setPregnancyTest((cur) => (cur === v ? null : v))}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Contraceptive */}
<Field label={t("cycle.sheet.contraceptive")}>
<div className="flex flex-wrap gap-2">
{CONTRACEPTIVE_KINDS.map((v) => (
<Chip
key={v}
active={contraceptive === v}
onClick={() =>
setContraceptive((cur) => (cur === v ? null : v))
}
>
{t(`cycle.contraceptive.${v}`)}
</Chip>
))}
</div>
</Field>
</SheetSection>
{/* Progesterone test */}
<Field label={t("cycle.sheet.progesteroneTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={progesteroneTest === v}
onClick={() =>
setProgesteroneTest((cur) => (cur === v ? null : v))
}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Tests */}
<SheetSection
title={t("cycle.sheet.testsSection")}
summary={<SheetSectionCount count={testsCount(formState)} />}
>
{/* Pregnancy test */}
<Field label={t("cycle.sheet.pregnancyTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={pregnancyTest === v}
onClick={() =>
setPregnancyTest((cur) => (cur === v ? null : v))
}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Contraceptive */}
<Field label={t("cycle.sheet.contraceptive")}>
<div className="flex flex-wrap gap-2">
{CONTRACEPTIVE_KINDS.map((v) => (
<Chip
key={v}
active={contraceptive === v}
onClick={() => setContraceptive((cur) => (cur === v ? null : v))}
>
{t(`cycle.contraceptive.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Progesterone test */}
<Field label={t("cycle.sheet.progesteroneTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={progesteroneTest === v}
onClick={() =>
setProgesteroneTest((cur) => (cur === v ? null : v))
}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
</SheetSection>
{/* Note */}
<Field label={t("cycle.sheet.note")}>
<SheetSection
title={t("cycle.sheet.note")}
summary={<SheetSectionCount count={noteCount(formState)} />}
>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -845,7 +941,7 @@ export function LogDaySheet({
placeholder={t("cycle.sheet.notePlaceholder")}
aria-label={t("cycle.sheet.note")}
/>
</Field>
</SheetSection>
{logDay.isError || patchDay.isError ? (
<p className="text-destructive text-sm" role="alert">
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { promoteRecentTags, selectQuickTagKeys } from "../recent-tags";
describe("promoteRecentTags", () => {
it("prepends a freshly-used key, most-recent first", () => {
expect(promoteRecentTags(["a", "b"], ["c"])).toEqual(["c", "a", "b"]);
});
it("moves an already-present key to the front without duplicating", () => {
expect(promoteRecentTags(["a", "b", "c"], ["b"])).toEqual([
"b",
"a",
"c",
]);
});
it("keeps the latest of a multi-key use at the front", () => {
// The last key in `usedKeys` is the most recent → leads the list.
expect(promoteRecentTags([], ["x", "y", "z"])).toEqual(["z", "y", "x"]);
});
it("de-duplicates within the used keys", () => {
expect(promoteRecentTags(["a"], ["a", "a"])).toEqual(["a"]);
});
it("drops empty keys", () => {
expect(promoteRecentTags(["a"], ["", "b"])).toEqual(["b", "a"]);
});
it("caps the history length", () => {
const existing = Array.from({ length: 30 }, (_, i) => `k${i}`);
const result = promoteRecentTags(existing, ["new"]);
expect(result.length).toBe(24);
expect(result[0]).toBe("new");
});
});
describe("selectQuickTagKeys", () => {
const catalog = ["a", "b", "c", "d", "e"];
it("returns the MRU keys that still exist in the catalog", () => {
expect(selectQuickTagKeys(["c", "a"], catalog, 3)).toEqual([
"c",
"a",
"b",
]);
});
it("drops MRU keys no longer in the catalog", () => {
expect(selectQuickTagKeys(["gone", "b"], catalog, 2)).toEqual([
"b",
"a",
]);
});
it("falls back to the first catalog keys with no history", () => {
expect(selectQuickTagKeys([], catalog, 3)).toEqual(["a", "b", "c"]);
});
it("respects the limit", () => {
expect(selectQuickTagKeys(["e", "d", "c"], catalog, 2)).toEqual([
"e",
"d",
]);
});
it("never duplicates when topping up from the catalog", () => {
const result = selectQuickTagKeys(["b"], catalog, 5);
expect(new Set(result).size).toBe(result.length);
expect(result[0]).toBe("b");
});
});
+198 -135
View File
@@ -24,13 +24,27 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DateTimeInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import {
ListChecks,
Loader2,
MoreHorizontal,
Plus,
RotateCcw,
SlidersHorizontal,
StickyNote,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import {
SheetSection,
SheetSectionCount,
} from "@/components/ui/sheet-section";
import { MoodTagPicker, type RatedFactor } from "./mood-tag-picker";
import { MoodQuickTags } from "./mood-quick-tags";
import { useRecentTags } from "./recent-tags";
import { moodFaceIcon } from "./mood-tag-icons";
// v1.12.0 — best-on-the-left face order for the "How are you?" hero,
@@ -116,6 +130,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// fields the user actively fills count toward "dirty"; the timestamp
// always carries an auto-populated default, so it is excluded.
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
// v1.17.0 — MRU tag history powers the always-open Quick row; tapping the
// Quick row's "+" expands the full "More tags" section (controlled so the
// chip can open it).
const { recent, recordUse } = useRecentTags();
const [moreTagsOpen, setMoreTagsOpen] = useState(false);
const isDirty =
mood !== "" ||
tagsInput.trim() !== "" ||
@@ -193,7 +212,11 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
moodLoggedAt: timestamp,
});
// v1.17.0 — promote the saved structured tags to the Quick row's MRU
// history so the next entry surfaces them as one-tap chips.
if (tagKeys.length > 0) recordUse(tagKeys);
resetForm();
setMoreTagsOpen(false);
await invalidateKeys(queryClient, moodDependentKeys);
toast.success(t("common.saved"));
onSuccess?.();
@@ -259,6 +282,16 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
// primary input and a fast log is a single tap + Save.
const moodPicked = mood !== "";
// v1.17.0 — the "Note & details" badge counts the free-text tags entered
// plus the note (when present), so a collapsed section communicates what
// it holds. The auto-populated timestamp is excluded (it always carries a
// default, so it would never read as "empty").
const freeTextTagCount = tagsInput
.split(",")
.map((s) => s.trim())
.filter(Boolean).length;
const detailsCount = freeTextTagCount + (note.trim() ? 1 : 0);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
{/* v1.12.0 — "How are you?" 5-face hero. The five faces are a
@@ -318,152 +351,182 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
</div>
{moodPicked && (
<div
className="space-y-4 border-t pt-4"
data-slot="mood-annotate-panel"
>
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => setMoodLoggedAt(e.target.value)}
// v1.17 W1b — match the server bound: no future instant.
max={getDefaultMoodLoggedAtValue()}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
<div className="space-y-3" data-slot="mood-annotate-panel">
{/* v1.17.0 — always-open Quick row: MRU one-tap tags + a "+" that
expands the full "More tags" section. The common case stays a
face + a couple of taps + Save. */}
<MoodQuickTags
selected={tagKeys}
onToggle={toggleTagKey}
recent={recent}
onExpand={() => setMoreTagsOpen(true)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="tags">
{t("mood.tags")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<span className="text-muted-foreground text-xs">
{t("mood.tagsHelp")}
</span>
</div>
<Input
id="tags"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder={t("mood.tagsPlaceholder")}
enterKeyHint="done"
autoCapitalize="none"
autoComplete="off"
{/* v1.17.0 — "More tags": the full structured-tag taxonomy
(binary tiles + custom). Controlled so the Quick row's "+" can
open it; the badge reports how many tags are selected. */}
<SheetSection
title={t("mood.sectionMoreTags")}
icon={<ListChecks />}
open={moreTagsOpen}
onOpenChange={setMoreTagsOpen}
summary={<SheetSectionCount count={tagKeys.length} />}
>
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
mode="binary"
/>
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
appends the localised label to the free-text tag list.
Always visible for now (cheap UX; the Coach side-effect
aggregator filters on the canonical English tag set so the
German labels still register correctly). */}
<div className="space-y-1.5 pt-1">
<p className="text-muted-foreground text-xs">
{t("medications.sideEffectTagsHelp")}
</p>
<div className="flex flex-wrap gap-1.5">
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
const label = t(key);
const tags = tagsInput
.split(",")
.map((p) => p.trim().toLowerCase());
const isActive = tags.includes(label.toLowerCase());
return (
<button
key={key}
type="button"
onClick={() => {
if (isActive) {
const next = tagsInput
.split(",")
.map((p) => p.trim())
.filter(
(p) => p.toLowerCase() !== label.toLowerCase(),
);
setTagsInput(next.join(", "));
} else {
const next = tagsInput.trim()
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
: label;
setTagsInput(next);
}
}}
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
aria-pressed={isActive}
>
{label}
</button>
);
})}
</div>
</div>
</div>
</SheetSection>
{/* v1.8.5 / v1.12.0 — structured-tag taxonomy picker. Additive next
to the free-text input above; an entry can carry both axes. The
picker also surfaces the FACTOR-kind tags as 1..scaleMax rating
controls, lifted here as `ratedFactors`. */}
<div className="space-y-2">
<Label>
{t("mood.tagPicker")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
{/* v1.17.0 — "Factors": the FACTOR-kind segmented ratings, kept
exactly as the existing segmented control. */}
<SheetSection
title={t("mood.sectionFactors")}
icon={<SlidersHorizontal />}
summary={<SheetSectionCount count={ratedFactors.length} />}
>
<MoodTagPicker
selected={tagKeys}
onToggle={toggleTagKey}
ratedFactors={ratedFactors}
onRateFactor={rateFactor}
mode="rated"
/>
</div>
</SheetSection>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
{/* v1.17.0 — "Note & details": free-text tags, GLP-1 quick-tags,
the note field, and the timestamp. The badge counts whichever
of these carry input. */}
<SheetSection
title={t("mood.sectionNoteDetails")}
icon={<StickyNote />}
summary={<SheetSectionCount count={detailsCount} />}
>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="tags">
{t("mood.tags")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
<span className="text-muted-foreground text-xs">
{t("mood.tagsHelp")}
</span>
</Label>
{/* v1.11.5 — character counter so the `maxLength` cap no longer
truncates silently. Turns destructive (warns) as the input
approaches the limit. */}
<span
data-testid="mood-note-counter"
className={`text-xs tabular-nums ${
note.length >= NOTE_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(note.length),
max: String(NOTE_MAX_LENGTH),
})}
</span>
</div>
<Input
id="tags"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder={t("mood.tagsPlaceholder")}
enterKeyHint="done"
autoCapitalize="none"
autoComplete="off"
/>
{/* v1.4.25 W4d — GLP-1 side-effect quick-tags. Tapping a chip
appends the localised label to the free-text tag list.
Always visible for now (cheap UX; the Coach side-effect
aggregator filters on the canonical English tag set so the
German labels still register correctly). */}
<div className="space-y-1.5 pt-1">
<p className="text-muted-foreground text-xs">
{t("medications.sideEffectTagsHelp")}
</p>
<div className="flex flex-wrap gap-1.5">
{GLP1_SIDE_EFFECT_KEYS.map((key) => {
const label = t(key);
const tags = tagsInput
.split(",")
.map((p) => p.trim().toLowerCase());
const isActive = tags.includes(label.toLowerCase());
return (
<button
key={key}
type="button"
onClick={() => {
if (isActive) {
const next = tagsInput
.split(",")
.map((p) => p.trim())
.filter(
(p) => p.toLowerCase() !== label.toLowerCase(),
);
setTagsInput(next.join(", "));
} else {
const next = tagsInput.trim()
? `${tagsInput.replace(/[,\s]+$/, "")}, ${label}`
: label;
setTagsInput(next);
}
}}
className={`inline-flex min-h-11 items-center rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
aria-pressed={isActive}
>
{label}
</button>
);
})}
</div>
</div>
</div>
<Textarea
id="mood-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
rows={3}
/>
</div>
{/* v1.8.5 (C1) — free-text note. */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="mood-note">
{t("mood.note")}{" "}
<span className="text-muted-foreground font-normal">
({t("common.optional")})
</span>
</Label>
{/* v1.11.5 — character counter so the `maxLength` cap no
longer truncates silently. Turns destructive (warns) as the
input approaches the limit. */}
<span
data-testid="mood-note-counter"
className={`text-xs tabular-nums ${
note.length >= NOTE_MAX_LENGTH
? "text-destructive"
: "text-muted-foreground"
}`}
aria-live="polite"
>
{t("mood.noteCharCount", {
count: String(note.length),
max: String(NOTE_MAX_LENGTH),
})}
</span>
</div>
<Textarea
id="mood-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("mood.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => setMoodLoggedAt(e.target.value)}
// v1.17 W1b — match the server bound: no future instant.
max={getDefaultMoodLoggedAtValue()}
required
aria-required="true"
aria-invalid={!!error || undefined}
aria-describedby={errorDescriptor}
/>
</div>
</SheetSection>
</div>
)}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { moodTagIcon } from "./mood-tag-icons";
import { selectQuickTagKeys } from "./recent-tags";
/**
* v1.17.0 — the always-open Quick row at the top of the mood add-sheet.
*
* Surfaces the caller's most-recently-used binary tags (falling back to the
* first catalogue tags on a fresh account) as one-tap chips, so the common
* case is a face + a couple of familiar taps + Save. A trailing "+" expands
* the full "More tags" section for everything else.
*
* Reads the same `/api/mood/tags` catalogue as `MoodTagPicker` (shared query
* key → one network round-trip). Renders nothing while the catalogue is
* empty so a cleared taxonomy degrades gracefully.
*/
interface CatalogTag {
key: string;
labelKey: string;
label?: string | null;
icon: string | null;
kind: "BINARY" | "RATED";
}
interface CatalogCategory {
tags: CatalogTag[];
}
interface CatalogResponse {
categories: CatalogCategory[];
}
const QUICK_LIMIT = 8;
export function MoodQuickTags({
selected,
onToggle,
recent,
onExpand,
}: {
/** Selected binary tag keys (shared with `MoodTagPicker`). */
selected: string[];
onToggle: (tagKey: string) => void;
/** MRU tag keys, most-recent first (from `useRecentTags`). */
recent: readonly string[];
/** Opens the full "More tags" section. */
onExpand: () => void;
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodTagCatalog(),
queryFn: async () => apiGet<CatalogResponse>("/api/mood/tags"),
enabled: isAuthenticated,
staleTime: 60 * 60 * 1000,
});
if (isLoading) {
return <Skeleton className="h-11 w-full rounded-lg" />;
}
if (!data || data.categories.length === 0) return null;
// Flatten the catalogue to binary tags only; the Quick row never carries
// rated factors (those live under the "Factors" section).
const binaryTags = data.categories
.flatMap((c) => c.tags)
.filter((tag) => tag.kind !== "RATED");
if (binaryTags.length === 0) return null;
const tagByKey = new Map(binaryTags.map((tag) => [tag.key, tag]));
const catalogKeys = binaryTags.map((tag) => tag.key);
const selectedSet = new Set(selected);
// Show the MRU/fallback quick keys, but always include any currently
// selected key so a tap from the full picker stays visible up here too.
const quickKeys = selectQuickTagKeys(recent, catalogKeys, QUICK_LIMIT);
const orderedKeys = [
...quickKeys,
...selected.filter((k) => tagByKey.has(k) && !quickKeys.includes(k)),
];
return (
<div className="space-y-1.5" data-slot="mood-quick-tags">
<p className="text-muted-foreground text-xs">{t("mood.quickTagsHelp")}</p>
<div className="flex flex-wrap gap-1.5">
{orderedKeys.map((key) => {
const tag = tagByKey.get(key);
if (!tag) return null;
const TagIcon = moodTagIcon(tag.icon);
const label = tag.label ?? t(tag.labelKey);
const isActive = selectedSet.has(key);
return (
<button
key={key}
type="button"
onClick={() => onToggle(key)}
aria-pressed={isActive}
data-slot="mood-quick-tag"
className={`inline-flex min-h-11 items-center gap-1.5 rounded-full border px-3 py-2 text-xs transition-colors ${
isActive
? "border-primary bg-primary/15 text-primary"
: "border-border/70 bg-muted text-foreground/75 hover:bg-accent hover:text-foreground"
}`}
>
<TagIcon className="h-3.5 w-3.5" aria-hidden="true" />
{label}
</button>
);
})}
<button
type="button"
onClick={onExpand}
aria-label={t("mood.quickTagsExpand")}
title={t("mood.quickTagsExpand")}
data-slot="mood-quick-tags-expand"
className="border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-1 rounded-full border border-dashed px-3 py-2 text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{t("mood.quickTagsExpand")}
</button>
</div>
</div>
);
}
+23 -7
View File
@@ -77,6 +77,7 @@ export function MoodTagPicker({
onToggle,
ratedFactors = [],
onRateFactor,
mode = "all",
}: {
/** Selected binary tag keys (sent as `tagKeys`). */
selected: string[];
@@ -89,6 +90,13 @@ export function MoodTagPicker({
* when omitted the picker only surfaces binary tags.
*/
onRateFactor?: (key: string, rating: number | null) => void;
/**
* v1.17.0 — which tag kinds to surface. The sectioned add-sheet renders
* the binary tiles under "More tags" (`mode="binary"`) and the segmented
* factor ratings under "Factors" (`mode="rated"`) as two separate
* sections; the default `"all"` keeps the legacy interleaved layout.
*/
mode?: "all" | "binary" | "rated";
}) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -116,6 +124,8 @@ export function MoodTagPicker({
const selectedSet = new Set(selected);
const ratingByKey = new Map(ratedFactors.map((f) => [f.key, f.rating]));
const showBinary = mode !== "rated";
const showRated = mode !== "binary";
const groupOptions = data.categories.map((category) => ({
key: category.key,
@@ -131,10 +141,15 @@ export function MoodTagPicker({
<div className="space-y-4" data-slot="mood-tag-picker">
{data.categories.map((category) => {
const CategoryIcon = moodTagIcon(category.icon);
const binaryTags = category.tags.filter((tag) => tag.kind !== "RATED");
const ratedTags = category.tags.filter((tag) => tag.kind === "RATED");
const binaryTags = showBinary
? category.tags.filter((tag) => tag.kind !== "RATED")
: [];
const ratedTags = showRated
? category.tags.filter((tag) => tag.kind === "RATED")
: [];
// Self-gate an empty category (e.g. a RATED-only category when no
// `onRateFactor` is wired) so the picker never shows a bare heading.
// `onRateFactor` is wired, or a mode that filters all tags out) so
// the picker never shows a bare heading.
if (binaryTags.length === 0 && ratedTags.length === 0) return null;
if (ratedTags.length > 0 && !onRateFactor && binaryTags.length === 0) {
return null;
@@ -147,7 +162,7 @@ export function MoodTagPicker({
<span>{category.label ?? t(category.labelKey)}</span>
</div>
{
{showBinary && (
<div className="flex flex-wrap gap-2">
{binaryTags.map((tag) => {
const TagIcon = moodTagIcon(tag.icon);
@@ -178,7 +193,7 @@ export function MoodTagPicker({
onClick={() => setCreateGroupKey(category.key)}
/>
</div>
}
)}
{ratedTags.length > 0 && onRateFactor && (
<div className="space-y-2" data-slot="mood-factor-ratings">
@@ -262,8 +277,9 @@ export function MoodTagPicker({
{/* Bootstrap node for the seeded Custom group: before the first
custom tag exists the plain read carries no `custom` category,
so the inline-create entry point renders synthetically. */}
{!hasCustomGroup && (
so the inline-create entry point renders synthetically. Only the
binary-tag surfaces it (custom tags are binary). */}
{showBinary && !hasCustomGroup && (
<div className="space-y-2" data-slot="mood-tag-custom-bootstrap">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs font-medium">
<Tag className="h-3.5 w-3.5" aria-hidden="true" />
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
/**
* v1.17.0 — most-recently-used mood-tag tracking for the Quick row.
*
* The Quick row surfaces the caller's handful of most-recently-used binary
* tags as one-tap chips so the common case (a face + a couple of familiar
* tags + Save) is genuinely fast. The MRU order is a small, client-only
* convenience persisted in `localStorage`; it never touches the API and a
* cleared store simply degrades to "no quick chips yet".
*
* The selection logic is split out as pure functions so it is unit-testable
* without a DOM.
*/
const STORAGE_KEY = "healthlog:mood:recent-tags";
const MAX_RECENT = 24;
/**
* Promote `usedKeys` to the front of the existing MRU list (most-recent
* first), de-duplicated, capped at `MAX_RECENT`. A key already present is
* moved to the front rather than duplicated. Order within `usedKeys` is
* preserved (first item ends up most-recent).
*/
export function promoteRecentTags(
existing: readonly string[],
usedKeys: readonly string[],
): string[] {
const seen = new Set<string>();
const next: string[] = [];
// Most-recent first: the latest-used keys lead, then prior history.
for (const key of [...usedKeys].reverse().concat(existing)) {
if (!key || seen.has(key)) continue;
seen.add(key);
next.push(key);
if (next.length >= MAX_RECENT) break;
}
return next;
}
/**
* Resolve the ordered Quick-row keys for a render: the MRU keys that still
* exist in the live catalog (intersected so a deleted tag drops out), capped
* at `limit`. When the user has no history yet, fall back to the first
* `limit` catalog keys so the Quick row is never empty on a fresh account.
*/
export function selectQuickTagKeys(
recent: readonly string[],
catalogKeys: readonly string[],
limit: number,
): string[] {
const catalogSet = new Set(catalogKeys);
const fromHistory = recent.filter((k) => catalogSet.has(k));
if (fromHistory.length >= limit) return fromHistory.slice(0, limit);
// Top up with catalog keys not already chosen so the row stays full.
const chosen = new Set(fromHistory);
const topped = [...fromHistory];
for (const key of catalogKeys) {
if (topped.length >= limit) break;
if (!chosen.has(key)) {
chosen.add(key);
topped.push(key);
}
}
return topped.slice(0, limit);
}
const EMPTY: string[] = [];
// Cache the parsed snapshot so `useSyncExternalStore` gets a stable
// reference between reads (a fresh array every call would loop forever).
let snapshot: string[] = EMPTY;
let snapshotRaw: string | null | undefined;
function readStore(): string[] {
if (typeof window === "undefined") return EMPTY;
let raw: string | null = null;
try {
raw = window.localStorage.getItem(STORAGE_KEY);
} catch {
return EMPTY;
}
if (raw === snapshotRaw) return snapshot;
snapshotRaw = raw;
if (!raw) {
snapshot = EMPTY;
return snapshot;
}
try {
const parsed: unknown = JSON.parse(raw);
snapshot = Array.isArray(parsed)
? parsed.filter((x): x is string => typeof x === "string")
: EMPTY;
} catch {
snapshot = EMPTY;
}
return snapshot;
}
const listeners = new Set<() => void>();
function subscribe(cb: () => void): () => void {
listeners.add(cb);
return () => listeners.delete(cb);
}
function emit() {
for (const cb of listeners) cb();
}
/**
* React hook over the MRU store. Reads the persisted keys via
* `useSyncExternalStore` (SSR-safe: the server snapshot is empty and
* hydration stays stable), and exposes a `recordUse` to promote
* freshly-saved tag keys to the front.
*/
export function useRecentTags(): {
recent: string[];
recordUse: (usedKeys: readonly string[]) => void;
} {
const recent = useSyncExternalStore(subscribe, readStore, () => EMPTY);
const recordUse = useCallback((usedKeys: readonly string[]) => {
if (usedKeys.length === 0 || typeof window === "undefined") return;
const next = promoteRecentTags(readStore(), usedKeys);
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* storage full / disabled — nothing to promote */
return;
}
// Invalidate the cached snapshot and notify subscribers to re-read.
snapshotRaw = undefined;
emit();
}, []);
return { recent, recordUse };
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { SheetSection, SheetSectionCount } from "../sheet-section";
describe("<SheetSection>", () => {
it("renders the title and summary slot on the trigger row", () => {
const html = renderToStaticMarkup(
<SheetSection title="More tags" summary={<span>summary-badge</span>}>
<p>section body</p>
</SheetSection>,
);
expect(html).toContain("More tags");
expect(html).toContain("summary-badge");
});
it("renders children inside an open section", () => {
// Radix Collapsible omits the content subtree while collapsed, so the
// body is asserted on an expanded section.
const html = renderToStaticMarkup(
<SheetSection title="More tags" defaultOpen>
<p>section body</p>
</SheetSection>,
);
expect(html).toContain("section body");
});
it("starts collapsed by default and open when defaultOpen", () => {
const closed = renderToStaticMarkup(
<SheetSection title="Factors">
<p>x</p>
</SheetSection>,
);
const open = renderToStaticMarkup(
<SheetSection title="Factors" defaultOpen>
<p>x</p>
</SheetSection>,
);
expect(closed).toContain('data-state="closed"');
expect(open).toContain('data-state="open"');
});
it("hides the leading icon from assistive tech", () => {
const html = renderToStaticMarkup(
<SheetSection title="Note" icon={<svg data-testid="icon" />}>
<p>x</p>
</SheetSection>,
);
expect(html).toMatch(/aria-hidden="true"[^>]*>[^<]*<svg/);
});
it("respects reduced motion on the content and chevron", () => {
const html = renderToStaticMarkup(
<SheetSection title="x">
<p>y</p>
</SheetSection>,
);
expect(html).toContain("motion-reduce:animate-none");
expect(html).toContain("motion-reduce:transition-none");
});
});
describe("<SheetSectionCount>", () => {
it("renders the count when positive", () => {
const html = renderToStaticMarkup(<SheetSectionCount count={3} />);
expect(html).toContain("3");
expect(html).toContain('data-slot="sheet-section-count"');
});
it("renders nothing for zero or negative counts", () => {
expect(renderToStaticMarkup(<SheetSectionCount count={0} />)).toBe("");
expect(renderToStaticMarkup(<SheetSectionCount count={-1} />)).toBe("");
});
});
+114
View File
@@ -0,0 +1,114 @@
"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* v1.17.0 — `SheetSection` is the collapsible disclosure primitive that
* powers the sectioned single-sheet add flows (mood, cycle). Each section
* carries a title, an optional leading icon, and a `summary` slot that
* communicates what is selected inside it while collapsed (e.g. a count
* badge) — so a closed section never hides its contents silently.
*
* Built on Radix Collapsible. The chevron rotates and the content height
* animates open/closed via the `tw-animate-css` `collapsible-down/up`
* keyframes; both motions are suppressed under `prefers-reduced-motion`.
* Matches the shadcn new-york + zinc token language used across the app.
*/
interface SheetSectionProps {
/** Section heading shown on the always-visible trigger row. */
title: string;
/**
* A node rendered on the trigger row's trailing edge — typically a count
* badge that reports what is selected inside while the section is
* collapsed. Stays visible whether the section is open or closed.
*/
summary?: React.ReactNode;
/** Optional leading icon (a Lucide component or any node). */
icon?: React.ReactNode;
/** Whether the section starts expanded (uncontrolled). Defaults to closed. */
defaultOpen?: boolean;
/** Controlled open state. When set, pair with `onOpenChange`. */
open?: boolean;
/** Controlled open-state change handler. */
onOpenChange?: (open: boolean) => void;
/** Optional className merged onto the section wrapper. */
className?: string;
children: React.ReactNode;
}
export function SheetSection({
title,
summary,
icon,
defaultOpen = false,
open,
onOpenChange,
className,
children,
}: SheetSectionProps) {
return (
<CollapsiblePrimitive.Root
// Controlled when `open` is provided, otherwise uncontrolled.
{...(open === undefined ? { defaultOpen } : { open, onOpenChange })}
data-slot="sheet-section"
className={cn("border-border/60 border-t pt-3", className)}
>
<CollapsiblePrimitive.Trigger
data-slot="sheet-section-trigger"
className="group focus-visible:ring-ring/50 -mx-1 flex w-[calc(100%+0.5rem)] items-center gap-2 rounded-md px-1 py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
{icon ? (
<span
className="text-muted-foreground flex h-4 w-4 shrink-0 items-center justify-center [&>svg]:h-4 [&>svg]:w-4"
aria-hidden="true"
>
{icon}
</span>
) : null}
<span className="text-foreground flex-1 text-sm font-medium">
{title}
</span>
{summary ? (
<span
data-slot="sheet-section-summary"
className="flex items-center"
>
{summary}
</span>
) : null}
<ChevronDown
aria-hidden="true"
className="text-muted-foreground h-4 w-4 shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none"
/>
</CollapsiblePrimitive.Trigger>
<CollapsiblePrimitive.Content
data-slot="sheet-section-content"
className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none"
>
<div className="space-y-3 pt-3 pb-1">{children}</div>
</CollapsiblePrimitive.Content>
</CollapsiblePrimitive.Root>
);
}
/**
* A compact "(N)" count badge for a `SheetSection` summary slot. Renders
* nothing when `count <= 0`, so a section with no selections shows a bare
* chevron rather than a "(0)".
*/
export function SheetSectionCount({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span
data-slot="sheet-section-count"
className="bg-primary/15 text-primary inline-flex min-w-5 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums"
>
{count}
</span>
);
}