mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(cycle): give the custom-symptom popover the shared symptom icon picker
This commit is contained in:
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"');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user