fix(cycle): give the custom-symptom popover the shared symptom icon picker

This commit is contained in:
Marc-André Bombeck committed 2026-10-03 23:38:12 +02:00
1 parent c809a811e6
commit b74c6de7ce
5 files changed
+136 -92

No files matched your search

@@ -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 }) => (
<div>{children}</div>
),
}));
vi.mock("../use-cycle", async (importOriginal) => ({
...(await importOriginal<typeof import("../use-cycle")>()),
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(
<I18nProvider initialLocale="de">
<AddSymptomChip onCreated={() => {}} />
</I18nProvider>,
);
const buttons = html.match(/<button[^>]*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"');
});
});
+11 -30
View File
@@ -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 }) {
<p className="text-xs font-medium">
{t("cycle.symptom.custom.icon")}
</p>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("cycle.symptom.custom.icon")}
>
{CUSTOM_SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = customIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={name}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-7 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("cycle.symptom.custom.icon")}
/>
</div>
{limitReached ? (
<p className="text-destructive text-sm" role="alert">
+6 -31
View File
@@ -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({
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium">{t("symptoms.iconLabel")}</p>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("symptoms.iconLabel")}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`symptoms.icons.${name}`)}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("symptoms.iconLabel")}
/>
</div>
{create.isError ? (
<p className="text-destructive text-sm" role="alert">
@@ -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({
</FieldGroup>
<div className="space-y-2">
<Label>{t("symptoms.iconLabel")}</Label>
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={t("symptoms.iconLabel")}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = icon === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`symptoms.icons.${name}`)}
onClick={() => setIcon(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
<SymptomIconPicker
value={icon}
onChange={setIcon}
label={t("symptoms.iconLabel")}
/>
</div>
{error ? (
<p role="alert" className="text-destructive text-sm">
@@ -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 (
<div
className="flex flex-wrap gap-1.5"
role="radiogroup"
aria-label={label}
>
{SYMPTOM_ICON_ALLOWLIST.map((name) => {
const IconC = symptomIcon(name);
const selected = value === name;
return (
<button
key={name}
type="button"
role="radio"
aria-checked={selected}
aria-label={t(`symptoms.icons.${name}`)}
onClick={() => onChange(name)}
className={cn(
"focus-visible:ring-ring/50 grid size-11 place-items-center rounded-md border transition-colors focus-visible:ring-2 focus-visible:outline-none sm:size-9",
selected
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:bg-accent",
)}
>
<IconC className="size-4" aria-hidden="true" />
</button>
);
})}
</div>
);
}