diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index ee32c5dd8..fe3074434 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s | A sheet from the bottom edge (phones) | `` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet | | Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) | | Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined ` +`), + ).toBe(2); + expect(pressedByValue(``)).toBe(0); + }); +}); diff --git a/src/components/ui/__tests__/segmented.browser.test.tsx b/src/components/ui/__tests__/segmented.browser.test.tsx index c351c2047..d4bf150c0 100644 --- a/src/components/ui/__tests__/segmented.browser.test.tsx +++ b/src/components/ui/__tests__/segmented.browser.test.tsx @@ -1,8 +1,12 @@ +import { useState } from "react"; +import { LayoutGrid, Table } from "lucide-react"; import { describe, expect, it, vi } from "vitest"; import { userEvent } from "vitest/browser"; import { renderWithApp } from "../../../../tests/browser/render"; -import { Segmented } from "../segmented"; +import { Segmented, type SegmentedProps } from "../segmented"; + +type Zoom = "day" | "week" | "month"; const OPTIONS = [ { value: "day", label: "Day" }, @@ -10,22 +14,216 @@ const OPTIONS = [ { value: "month", label: "Month" }, ] as const; -describe(" smoke", () => { - it("moves with the arrow key", async () => { - const onChange = vi.fn(); +/** + * The control as a caller holds it: the value lives in the parent and moves + * only when `onChange` says so, which is what makes the roving tab stop + * follow the selection. + */ +function Controlled({ + initial = "day", + onChange, + ...props +}: Partial, "value">> & { initial?: Zoom }) { + const [value, setValue] = useState(initial); + return ( + <> + + { + onChange?.(next); + setValue(next); + }} + /> + + + ); +} + +async function mount(props: Parameters[0] = {}) { + const onChange = vi.fn(); + const { screen } = await renderWithApp( + , + ); + const radio = (name: string) => screen.getByRole("radio", { name }); + return { screen, onChange, radio }; +} + +describe(" in radio mode", () => { + it("is one tab stop, on the chosen segment", async () => { + const { screen, radio } = await mount({ initial: "week" }); + await expect + .element(screen.getByRole("radiogroup", { name: "Zoom" })) + .toBeInTheDocument(); + await expect.element(radio("Week")).toBeChecked(); + await expect.element(radio("Day")).not.toBeChecked(); + + await screen.getByRole("button", { name: "Before" }).click(); + await userEvent.tab(); + await expect.element(radio("Week")).toHaveFocus(); + await userEvent.tab(); + await expect + .element(screen.getByRole("button", { name: "After" })) + .toHaveFocus(); + }); + + it("moves focus and selection together with the arrow keys", async () => { + const { onChange, radio } = await mount(); + await radio("Day").click(); + onChange.mockClear(); + + await userEvent.keyboard("{ArrowRight}"); + await expect.element(radio("Week")).toHaveFocus(); + await expect.element(radio("Week")).toBeChecked(); + await userEvent.keyboard("{ArrowDown}"); + await expect.element(radio("Month")).toBeChecked(); + await userEvent.keyboard("{ArrowLeft}"); + await expect.element(radio("Week")).toHaveFocus(); + expect(onChange.mock.calls).toEqual([["week"], ["month"], ["week"]]); + }); + + it("wraps at both ends", async () => { + const { radio } = await mount({ initial: "month" }); + await radio("Month").click(); + await userEvent.keyboard("{ArrowRight}"); + await expect.element(radio("Day")).toHaveFocus(); + await expect.element(radio("Day")).toBeChecked(); + await userEvent.keyboard("{ArrowUp}"); + await expect.element(radio("Month")).toHaveFocus(); + await expect.element(radio("Month")).toBeChecked(); + }); + + it("jumps to the first and last segment with Home and End", async () => { + const { onChange, radio } = await mount({ initial: "week" }); + await radio("Week").click(); + await userEvent.keyboard("{End}"); + await expect.element(radio("Month")).toHaveFocus(); + await userEvent.keyboard("{Home}"); + await expect.element(radio("Day")).toHaveFocus(); + await expect.element(radio("Day")).toBeChecked(); + expect(onChange).toHaveBeenLastCalledWith("day"); + }); + + it("fires again on the chosen segment, which can open something", async () => { + const { onChange, radio } = await mount({ initial: "day" }); + await radio("Day").click(); + await radio("Day").click(); + expect(onChange.mock.calls).toEqual([["day"], ["day"]]); + }); + + it("takes no press and no key while disabled", async () => { + const { screen, onChange, radio } = await mount({ disabled: true }); + await expect + .element(screen.getByRole("radiogroup", { name: "Zoom" })) + .toHaveAttribute("aria-disabled", "true"); + await expect.element(radio("Week")).toBeDisabled(); + await radio("Week").click({ force: true }); + await screen.getByRole("button", { name: "Before" }).click(); + await userEvent.tab(); + await userEvent.keyboard("{ArrowRight}{End}"); + expect(onChange).not.toHaveBeenCalled(); + await expect.element(radio("Day")).toBeChecked(); + }); +}); + +describe(" in toggle mode", () => { + it("renders pressed buttons and ignores a press on the active one", async () => { + const { screen, onChange } = await mount({ mode: "toggle" }); + await expect + .element(screen.getByRole("group", { name: "Zoom" })) + .toBeInTheDocument(); + expect(screen.getByRole("radio").elements()).toHaveLength(0); + const day = screen.getByRole("button", { name: "Day" }); + const week = screen.getByRole("button", { name: "Week" }); + await expect.element(day).toHaveAttribute("aria-pressed", "true"); + + await day.click(); + expect(onChange).not.toHaveBeenCalled(); + await week.click(); + expect(onChange).toHaveBeenCalledExactlyOnceWith("week"); + await expect.element(week).toHaveAttribute("aria-pressed", "true"); + await expect.element(day).toHaveAttribute("aria-pressed", "false"); + }); + + it("leaves every segment its own tab stop and the arrows alone", async () => { + const { screen, onChange } = await mount({ mode: "toggle" }); + await screen.getByRole("button", { name: "Day" }).click(); + await userEvent.keyboard("{ArrowRight}"); + expect(onChange).not.toHaveBeenCalled(); + await userEvent.tab(); + await expect + .element(screen.getByRole("button", { name: "Week" })) + .toHaveFocus(); + }); +}); + +describe(" look", () => { + it("fills only the chosen segment in the primary colour, on no track", async () => { + const { screen, radio } = await mount({ initial: "week" }); + const group = screen.getByRole("radiogroup").element(); + const probe = document.createElement("span"); + probe.className = "bg-primary"; + document.body.append(probe); + const primary = getComputedStyle(probe).backgroundColor; + probe.remove(); + + const background = (name: string) => + getComputedStyle(radio(name).element()).backgroundColor; + expect(background("Week")).toBe(primary); + expect(background("Day")).not.toBe(primary); + expect(getComputedStyle(group).backgroundColor).toBe("rgba(0, 0, 0, 0)"); + }); + + it("sets the small size in smaller type and keeps the 36 px floor", async () => { + const { radio } = await mount(); + const md = radio("Day").element(); + expect(getComputedStyle(md).fontSize).toBe("14px"); + expect(md.getBoundingClientRect().height).toBeGreaterThanOrEqual(36); + const { screen } = await renderWithApp( {}} + label="Range" + size="sm" />, ); - await screen.getByRole("radio", { name: "Day" }).click(); - await userEvent.keyboard("{ArrowRight}"); - expect(onChange).toHaveBeenLastCalledWith("week"); + const sm = screen + .getByRole("radiogroup", { name: "Range" }) + .getByRole("radio", { name: "Day" }) + .element(); + expect(getComputedStyle(sm).fontSize).toBe("12px"); + expect(sm.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); + }); + + it("names icon-only segments without showing the label", async () => { + const onChange = vi.fn(); + const { screen } = await renderWithApp( + `medications-view-${v}`} + />, + ); + const table = screen.getByRole("button", { name: "Table" }); + await expect.element(table).toHaveAttribute("title", "Table"); await expect - .element(screen.getByRole("radio", { name: "Week" })) - .toHaveFocus(); + .element(table) + .toHaveAttribute("data-slot", "medications-view-table"); + await expect.element(screen.getByText("Cards")).not.toBeInTheDocument(); + await table.click(); + expect(onChange).toHaveBeenCalledWith("table"); }); }); diff --git a/src/components/ui/__tests__/segmented.test.tsx b/src/components/ui/__tests__/segmented.test.tsx deleted file mode 100644 index 7f3770b4c..000000000 --- a/src/components/ui/__tests__/segmented.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; -import { isValidElement, type ReactElement, type ReactNode } from "react"; -import { LayoutGrid, Table } from "lucide-react"; - -import { Segmented, type SegmentedProps } from "../segmented"; - -const OPTIONS = [ - { value: "recent", label: "Last 90 days" }, - { value: "all", label: "All" }, -] as const; - -type Props = SegmentedProps<"recent" | "all">; - -function html(props: Partial = {}) { - return renderToStaticMarkup( - {}} - label="History" - slot="history-view" - {...props} - />, - ); -} - -/** The segment buttons of a rendered tree, for driving their handlers. */ -function buttons(node: ReactNode): ReactElement<{ onClick: () => void }>[] { - const out: ReactElement<{ onClick: () => void }>[] = []; - const walk = (n: ReactNode) => { - if (Array.isArray(n)) return n.forEach(walk); - if (!isValidElement(n)) return; - const el = n as ReactElement<{ children?: ReactNode; onClick: () => void }>; - if (el.type === "button") out.push(el); - walk(el.props.children); - }; - walk(node); - return out; -} - -vi.mock("@/hooks/use-roving-radio-group", () => ({ - useRovingRadioGroup: () => ({ - getRadioProps: (i: number) => ({ tabIndex: i === 0 ? 0 : -1 }), - }), -})); - -describe("", () => { - it("is a radiogroup with one checked radio by default", () => { - const out = html(); - expect(out).toContain('role="radiogroup"'); - expect(out).toContain('aria-label="History"'); - expect(out.match(/role="radio"/g)?.length).toBe(2); - expect(out.match(/aria-checked="true"/g)?.length).toBe(1); - expect(out).toContain('data-slot="history-view-all"'); - }); - - it("gives every segment the 44 px floor on a coarse pointer", () => { - const out = html(); - expect(out.match(/pointer-coarse:min-h-11/g)?.length).toBe(2); - expect(out).toContain("min-h-9"); - }); - - it("fills the chosen segment in the primary colour, on no track", () => { - const out = html(); - const segments = out.match(/]*>/g) ?? []; - expect(segments).toHaveLength(2); - const chosen = segments.filter((b) => b.includes('aria-checked="true"')); - expect(chosen).toHaveLength(1); - expect(chosen[0]).toContain("bg-primary text-primary-foreground"); - const other = segments.find((b) => b.includes('aria-checked="false"')); - expect(other).not.toContain("bg-primary"); - const group = out.match(/]*role="radiogroup"[^>]*>/)?.[0]; - expect(group).not.toContain("bg-muted"); - }); - - it("sets a chart header's range row in the small type", () => { - expect(html()).toContain("text-sm"); - const small = html({ size: "sm" }); - expect(small).toContain("text-xs"); - expect(small).not.toContain("text-sm"); - expect(small.match(/min-h-11/g)?.length).toBe(2); - }); - - it("keeps a disabled chosen segment on the readable token pair", () => { - const chosen = html({ disabled: true }) - .match(/]*>/g) - ?.find((b) => b.includes('aria-checked="true"')); - expect(chosen).toContain("disabled:bg-primary-disabled"); - expect(chosen).toContain("disabled:opacity-100"); - }); - - it("renders pressed buttons in toggle mode", () => { - const out = html({ mode: "toggle" }); - expect(out).toContain('role="group"'); - expect(out).not.toContain('role="radio"'); - expect(out).toContain('aria-pressed="true"'); - expect(out).toContain('aria-pressed="false"'); - }); - - it("names icon-only segments and sizes them square", () => { - const out = renderToStaticMarkup( - {}} - label="View" - optionSlot={(v) => `medications-view-${v}`} - />, - ); - expect(out).toContain('aria-label="Cards"'); - expect(out).toContain('title="Table"'); - expect(out.match(/pointer-coarse:size-11/g)?.length).toBe(2); - expect(out).toContain('data-slot="medications-view-table"'); - expect(out).not.toContain(">Cards<"); - }); - - it("re-fires the chosen segment in radio mode, which can open something", () => { - const onChange = vi.fn(); - const tree = Segmented({ - options: OPTIONS, - value: "recent", - onChange, - label: "History", - }); - buttons(tree)[0].props.onClick(); - expect(onChange).toHaveBeenCalledWith("recent"); - }); - - it("ignores a press on the active segment in toggle mode", () => { - const onChange = vi.fn(); - const tree = Segmented({ - options: OPTIONS, - value: "recent", - onChange, - label: "History", - mode: "toggle", - }); - const [active, other] = buttons(tree); - active.props.onClick(); - expect(onChange).not.toHaveBeenCalled(); - other.props.onClick(); - expect(onChange).toHaveBeenCalledWith("all"); - }); -}); - -describe("one segmented control", () => { - it("is the only segmented-control component in the tree", async () => { - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { walkSourceFiles } = - await import("@/__tests__/helpers/source-files"); - const src = join(__dirname, "../../.."); - const defining = walkSourceFiles(src, { floor: 2000 }) - .filter((p) => !p.includes("__tests__/")) - .filter((p) => - /export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test( - readFileSync(join(src, p), "utf8"), - ), - ); - expect(defining).toEqual(["components/ui/segmented.tsx"]); - }); -}); - -/** - * A row of buttons whose `aria-pressed` compares a state to each button's - * value is a segmented control spelled by hand: six of them sat beside - * `Segmented` (the narrative period, the lab result type, both admin filters, - * the chart range tabs, the mood chart's copy of them), each with its own - * size, fill and gap. New ones are refused; the ones that remain are not one - * choice on one track, and say why. - */ -describe("no hand-built segmented control", () => { - const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g; - - const NOT_A_SEGMENT: Record = { - "components/settings/security-section/passkey-list-section.tsx": - "one method is disabled while the account has no passkey; Segmented disables the whole track", - "components/medications/units-per-dose-field.tsx": - "a chip row that opens a free-number field for any other count", - "components/body-sites/body-sites-section.tsx": - "wrapping side chips under a body-site picker, not a track", - "components/illness/log-day-sheet.tsx": - "a graded severity scale per symptom, one square per level", - "components/cycle/log-day-sheet.tsx": - "a graded severity scale per symptom, one circle per level", - "components/charts/chart-overlay-controls.tsx": - "baselines stay pressable while greyed, with a tooltip saying why there is no data", - "components/timeline/timeline-view.tsx": - "a single button that opens the range sheet, beside the zoom Segmented", - }; - - function pressedByValue(source: string): number { - return (source.match(PRESSED_BY_VALUE) ?? []).length; - } - - it("finds none outside the reasoned exceptions", async () => { - const { readFileSync } = await import("node:fs"); - const { join } = await import("node:path"); - const { stripComments, walkSourceFiles } = - await import("@/__tests__/helpers/source-files"); - const src = join(__dirname, "../../.."); - const found = walkSourceFiles(src, { floor: 2000 }) - .filter((p) => /^(components|app)\//.test(p)) - .filter((p) => !p.includes("__tests__/")) - .filter((p) => p !== "components/ui/segmented.tsx") - .filter( - (p) => - pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0, - ) - .sort(); - expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort()); - }); - - it("counter-check: catches a hand-built pair, not an on/off toggle", () => { - expect( - pressedByValue(` -`), - ).toBe(2); - expect(pressedByValue(``)).toBe(0); - }); -}); diff --git a/tests/browser/setup.ts b/tests/browser/setup.ts index 2be7c1b36..d1ea7821f 100644 --- a/tests/browser/setup.ts +++ b/tests/browser/setup.ts @@ -2,7 +2,9 @@ * Setup for the `browser` project (`*.browser.test.tsx`, headless Chromium). * * - The app stylesheet, compiled by Vite through the project's PostCSS - * config, so components lay out and hide as they do in the app. + * config, so components lay out and hide as they do in the app, under the + * theme class the boot script sets for a first visit (`dark`, see + * `src/lib/pwa/theme-color.ts`); without one no colour token resolves. * - The EN catalog, the fallback floor of every `t()` chain, loaded through * the production loader. `renderWithApp` loads any other locale it is * asked for. @@ -19,6 +21,8 @@ import { loadMessages } from "@/lib/i18n/load-locale"; import { restoreFetch } from "./fetch-stub"; import { rememberAddress, restoreAddress } from "./url"; +document.documentElement.classList.add("dark"); + beforeAll(async () => { await loadMessages("en"); }); diff --git a/vitest.config.mts b/vitest.config.mts index ee075169f..996edbdec 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -129,6 +129,13 @@ export default defineConfig({ ), }, }, + // Pre-bundle every dependency the browser files reach before the + // first one loads. Discovered late, a dependency makes Vite re-bundle + // and reload the page mid-run, which fails the file that was + // importing it; CI starts every run on an empty cache. + optimizeDeps: { + entries: ["src/**/*.browser.test.tsx", "tests/browser/**/*.{ts,tsx}"], + }, test: { name: "browser", include: ["src/**/*.browser.test.tsx"],