From b74c6de7ce95128de949f0e2fe3d9a48458370d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 23:38:12 +0200 Subject: [PATCH] fix(cycle): give the custom-symptom popover the shared symptom icon picker --- .../custom-symptom-icon-picker.test.tsx | 56 ++++++++++++++++++ src/components/cycle/log-day-sheet.tsx | 41 ++++--------- src/components/symptoms/add-symptom-chip.tsx | 37 ++---------- .../symptoms/symptom-edit-sheets.tsx | 37 ++---------- .../symptoms/symptom-icon-picker.tsx | 57 +++++++++++++++++++ 5 files changed, 136 insertions(+), 92 deletions(-) create mode 100644 src/components/cycle/__tests__/custom-symptom-icon-picker.test.tsx create mode 100644 src/components/symptoms/symptom-icon-picker.tsx diff --git a/src/components/cycle/__tests__/custom-symptom-icon-picker.test.tsx b/src/components/cycle/__tests__/custom-symptom-icon-picker.test.tsx new file mode 100644 index 000000000..e828dc7cb --- /dev/null +++ b/src/components/cycle/__tests__/custom-symptom-icon-picker.test.tsx @@ -0,0 +1,56 @@ +/** + * The cycle sheet's "add a custom symptom" popover offers the same icon + * picker as the symptoms feature, and must look and behave the same: 44 px + * targets on a phone, 36 px from `sm`, a translated name per icon. + * + * The popover portals and portals do not render server-side, so it is + * replaced by pass-through wrappers; what is measured is the picker inside. + * + * Mutation check (run, seen red): put the cycle sheet's own inline picker + * back (`size-7`, `aria-label={name}`) → this test goes red. + */ +import { describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/components/ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("../use-cycle", async (importOriginal) => ({ + ...(await importOriginal()), + useCreateCustomSymptom: () => ({ + mutateAsync: vi.fn(), + reset: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared"; +import { I18nProvider } from "@/lib/i18n/context"; +import { AddSymptomChip } from "../log-day-sheet"; + +describe("cycle custom-symptom icon picker", () => { + it("has 44 px targets on a phone and translated icon names", () => { + const html = renderToStaticMarkup( + + {}} /> + , + ); + const buttons = html.match(/]*role="radio"[^>]*>/g) ?? []; + expect(buttons).toHaveLength(CUSTOM_SYMPTOM_ICON_ALLOWLIST.length); + for (const button of buttons) { + expect(button).toContain("size-11"); + expect(button).toContain("sm:size-9"); + expect(button).not.toMatch(/\bsize-7\b/); + } + expect(html).toContain('aria-label="Herzschlag"'); + expect(html).not.toContain('aria-label="HeartPulse"'); + }); +}); diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 3cf04f61d..b15617d7a 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -48,7 +48,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { resolveIntlLocale } from "@/lib/format-locale"; import type { Locale } from "@/lib/i18n/config"; -import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared"; +import { SymptomIconPicker } from "@/components/symptoms/symptom-icon-picker"; import { FieldInfo } from "./field-info"; import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; import { FLOW_HUE, PHASE_HUE } from "./phase-tokens"; @@ -1519,7 +1519,11 @@ export function SymptomRow({ * label + icon popover to mint a new custom symptom. Same height and shape as * a symptom row so it reads as part of the list, not a foreign button. */ -function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) { +export function AddSymptomChip({ + onCreated, +}: { + onCreated: (key: string) => void; +}) { const { t } = useTranslations(); const create = useCreateCustomSymptom(); const [openForm, setOpenForm] = useState(false); @@ -1594,34 +1598,11 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {

{t("cycle.symptom.custom.icon")}

-
- {CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => { - const IconC = customIcon(name); - const selected = icon === name; - return ( - - ); - })} -
+ {limitReached ? (

diff --git a/src/components/symptoms/add-symptom-chip.tsx b/src/components/symptoms/add-symptom-chip.tsx index 4fb88f272..4f613a754 100644 --- a/src/components/symptoms/add-symptom-chip.tsx +++ b/src/components/symptoms/add-symptom-chip.tsx @@ -19,10 +19,8 @@ import { } from "@/components/ui/popover"; import { ApiError } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared"; -import { cn } from "@/lib/utils"; -import { symptomIcon } from "./symptom-icons"; +import { SymptomIconPicker } from "./symptom-icon-picker"; import { useCreateSymptomDefinition } from "./use-symptoms"; export function AddSymptomChip({ @@ -101,34 +99,11 @@ export function AddSymptomChip({

{t("symptoms.iconLabel")}

-
- {SYMPTOM_ICON_ALLOWLIST.map((name) => { - const IconC = symptomIcon(name); - const selected = icon === name; - return ( - - ); - })} -
+
{create.isError ? (

diff --git a/src/components/symptoms/symptom-edit-sheets.tsx b/src/components/symptoms/symptom-edit-sheets.tsx index c2d5c1dc8..3a1ec5076 100644 --- a/src/components/symptoms/symptom-edit-sheets.tsx +++ b/src/components/symptoms/symptom-edit-sheets.tsx @@ -18,15 +18,13 @@ import { SliderField } from "@/components/ui/slider"; import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { - SYMPTOM_ICON_ALLOWLIST, SYMPTOM_INTENSITY_MAX, SYMPTOM_INTENSITY_MIN, type SymptomDefinitionDTO, type SymptomEventDTO, } from "@/lib/symptoms/shared"; -import { cn } from "@/lib/utils"; -import { symptomIcon } from "./symptom-icons"; +import { SymptomIconPicker } from "./symptom-icon-picker"; import { useUpdateSymptomDefinition, useUpdateSymptomEvent, @@ -227,34 +225,11 @@ export function EditSymptomDefinitionSheet({

-
- {SYMPTOM_ICON_ALLOWLIST.map((name) => { - const IconC = symptomIcon(name); - const selected = icon === name; - return ( - - ); - })} -
+
{error ? (

diff --git a/src/components/symptoms/symptom-icon-picker.tsx b/src/components/symptoms/symptom-icon-picker.tsx new file mode 100644 index 000000000..4b766a73f --- /dev/null +++ b/src/components/symptoms/symptom-icon-picker.tsx @@ -0,0 +1,57 @@ +"use client"; + +/** + * The icon picker a person-named symptom is given an icon with. One source for + * the three places that offer it (the symptoms add chip, the symptom edit + * sheet, and the cycle sheet's custom-symptom popover), which all draw from + * the same allowlist: 44 px targets on a phone, 36 px from `sm`, and each icon + * named in the reader's language rather than by its Lucide component name. + */ +import { useTranslations } from "@/lib/i18n/context"; +import { SYMPTOM_ICON_ALLOWLIST } from "@/lib/symptoms/shared"; +import { cn } from "@/lib/utils"; + +import { symptomIcon } from "./symptom-icons"; + +export function SymptomIconPicker({ + value, + onChange, + label, +}: { + value: string; + onChange: (name: string) => void; + /** The radiogroup's accessible name (the visible field label). */ + label: string; +}) { + const { t } = useTranslations(); + return ( +

+ {SYMPTOM_ICON_ALLOWLIST.map((name) => { + const IconC = symptomIcon(name); + const selected = value === name; + return ( + + ); + })} +
+ ); +}