]*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 (
- 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",
- )}
- >
-
-
- );
- })}
-
+
{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 (
- 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",
- )}
- >
-
-
- );
- })}
-
+
{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({
{t("symptoms.iconLabel")}
-
- {SYMPTOM_ICON_ALLOWLIST.map((name) => {
- const IconC = symptomIcon(name);
- const selected = icon === name;
- return (
- 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",
- )}
- >
-
-
- );
- })}
-
+
{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 (
+ 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",
+ )}
+ >
+
+
+ );
+ })}
+
+ );
+}