From e9e060083cb86746abd3d433a833292620b33f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 08:02:10 +0200 Subject: [PATCH] fix(cycle): list symptoms one per line with the intensity beside them (#1001) Selecting a symptom grew its chip inline with the 1-4 intensity selector, which reflowed the wrapping row and moved the chips after it, often out from under the finger reaching for the intensity. Each symptom is now its own row: the toggle on the left, the intensity on the right. The intensity's space is laid out whether or not it is shown, so a selection changes colours and reveals it in place and nothing moves. The intensity stays optional and out of the tab order until the symptom is picked. Long names wrap instead of truncating. --- .../cycle/__tests__/symptom-row.test.tsx | 75 ++++++++++ src/components/cycle/log-day-sheet.tsx | 136 +++++++++++------- 2 files changed, 156 insertions(+), 55 deletions(-) create mode 100644 src/components/cycle/__tests__/symptom-row.test.tsx 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 }) {