diff --git a/src/components/cycle/__tests__/symptom-row.test.tsx b/src/components/cycle/__tests__/symptom-row.test.tsx new file mode 100644 index 000000000..fb84de6a5 --- /dev/null +++ b/src/components/cycle/__tests__/symptom-row.test.tsx @@ -0,0 +1,75 @@ +/** + * Selecting a symptom must not move anything on the sheet (#1001 follow-up). + * + * The intensity selector used to appear inline beside a symptom chip in a + * wrapping row, so a selection widened the chip and pushed the chips after it + * along, sometimes out from under the finger reaching for the intensity. A + * symptom is now one row with its intensity beside it, and the intensity's + * space is laid out whether or not it is shown. What the test pins is exactly + * that: the selected and unselected row render the same boxes, the intensity + * group is present in both, and only its visibility and the colours change. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Droplet } from "lucide-react"; + +vi.mock("../use-cycle", () => ({ + useDeleteCustomSymptom: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SymptomRow } from "../log-day-sheet"; + +function render(active: boolean, severity: number | null = null): string { + return renderToStaticMarkup( + + {}} + onSeverity={() => {}} + /> + , + ); +} + +/** The markup with every class and state attribute removed: the box tree. */ +function skeleton(html: string): string { + return html + .replace(/ class="[^"]*"/g, "") + .replace(/ (aria-[a-z]+|data-active|disabled|tabindex)(="[^"]*")?/g, ""); +} + +function severityGroup(html: string): string { + const m = /]*data-slot="cycle-symptom-severity"[^>]*>/.exec(html); + if (!m) throw new Error("no severity group rendered"); + return m[0]; +} + +describe("", () => { + it("lays out the same boxes whether or not the symptom is selected", () => { + expect(skeleton(render(true, 2))).toBe(skeleton(render(false))); + }); + + it("reserves the intensity's space while hidden, and reveals it in place", () => { + const idle = severityGroup(render(false)); + const picked = severityGroup(render(true)); + expect(idle).toContain("invisible"); + expect(idle).toContain('aria-hidden="true"'); + expect(picked).not.toContain("invisible"); + expect(picked).not.toContain("aria-hidden"); + }); + + it("keeps the intensity optional and out of reach until the symptom is picked", () => { + const idle = render(false); + // Four levels exist in both states, none of them focusable while hidden. + expect(idle.match(/aria-label="Severity \d of 4"/g)).toHaveLength(4); + expect(idle.match(/tabindex="-1"/g)).toHaveLength(4); + const picked = render(true); + expect(picked).not.toContain('tabindex="-1"'); + expect(picked.match(/aria-pressed="true"/g)).toHaveLength(1); + }); +}); diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 9fad24d67..8d7c5411f 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -88,9 +88,9 @@ import type { * detailed form posts a single day-log (flow, symptoms, BBT, OPK, mucus, * intercourse + protection, free-text note). The write is optimistic-ish * (the mutation invalidates the cycle keys on success so the calendar + - * wheel repaint); the symptom chips reuse the mood-tag chip styling. + * wheel repaint); symptoms list one per line with their intensity beside them. * - * a11y: every chip is a real ` + {custom ? ( + <> - ) : null} - {custom ? ( - ) : null} - - {active ? ( -
- {SEVERITY_LEVELS.map((lvl) => ( - - ))} -
+ ) : null} + {/* Always laid out, only shown while the symptom is selected: the + reserved width is what keeps the row still when it appears. */} +
+ {SEVERITY_LEVELS.map((lvl) => ( + + ))} +
); } /** - * The dashed ghost-chip in the symptom grid that opens a compact label + icon - * popover to mint a new custom symptom. Matches the chip grid (same rounded- - * full pill, same size) so it reads as part of the grid, not a foreign button. + * The dashed ghost row at the end of the custom symptoms that opens a compact + * 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 }) { const { t } = useTranslations(); @@ -1486,9 +1512,9 @@ function AddSymptomChip({ onCreated }: { onCreated: (key: string) => void }) {