diff --git a/frontend/src/v1/README.md b/frontend/src/v1/README.md index 405387cbda..84f54e5b3f 100644 --- a/frontend/src/v1/README.md +++ b/frontend/src/v1/README.md @@ -108,6 +108,17 @@ is convention. - `CodeEditor` uses Monaco from its default CDN, as v0 does. Never call `loader.config()` in v1: the loader is shared with v0. +## Toggles + +- Checkboxes, switches, radio groups and sliders come from + `components/ui/toggles/` (`Checkbox`, `Switch`, `RadioGroup` + `Radio`, + `Slider`), imported from `@v1/components/ui/toggles`; its README lists + every prop. Do not build another checkbox, radio, switch or slider. +- The mixed checkbox is controlled only: pass `checked="indeterminate"`. +- Every group and control has a name: a `RadioGroup` takes `label` or + `aria-label`, a control without a visible label takes `aria-label`, and a + range slider names its thumbs with `thumbLabels`. + ## Version switch and route registry v0's router (`routes/Router.jsx`) wraps its routes in `VersionSwitch`, which diff --git a/frontend/src/v1/components/ui/toggles/Checkbox.test.tsx b/frontend/src/v1/components/ui/toggles/Checkbox.test.tsx new file mode 100644 index 0000000000..2fc5d45ee3 --- /dev/null +++ b/frontend/src/v1/components/ui/toggles/Checkbox.test.tsx @@ -0,0 +1,131 @@ +import { screen } from "@testing-library/react"; +import { renderV1 } from "@v1/test"; +import { expect, it, vi } from "vitest"; +import { Checkbox } from "./Checkbox"; + +it("renders a checkbox named by its label and described by its description", () => { + renderV1(); + const box = screen.getByRole("checkbox", { name: "Email me" }); + const description = screen.getByText("Weekly digest"); + expect(description.id).not.toBe(""); + expect(box.getAttribute("aria-describedby")).toBe(description.id); +}); + +it("toggles with a click on the box and on the label, and with Space", async () => { + const onCheckedChange = vi.fn(); + const { user } = renderV1( + , + ); + const box = screen.getByRole("checkbox", { name: "Email me" }); + expect(box).toHaveAttribute("aria-checked", "false"); + + await user.click(screen.getByText("Email me")); + expect(box).toHaveAttribute("aria-checked", "true"); + expect(onCheckedChange).toHaveBeenLastCalledWith(true); + + await user.click(box); + expect(box).toHaveAttribute("aria-checked", "false"); + expect(onCheckedChange).toHaveBeenLastCalledWith(false); + + await user.click(box); + expect(box).toHaveAttribute("aria-checked", "true"); + box.focus(); + await user.keyboard(" "); + expect(box).toHaveAttribute("aria-checked", "false"); + expect(onCheckedChange).toHaveBeenLastCalledWith(false); +}); + +it("shows a minus and reports mixed when indeterminate, and Space makes it checked", async () => { + const onCheckedChange = vi.fn(); + const { user } = renderV1( + , + ); + const box = screen.getByRole("checkbox", { name: "All rows" }); + expect(box).toHaveAttribute("aria-checked", "mixed"); + expect(box.querySelector("svg.lucide-minus")).not.toBeNull(); + expect(box.querySelector("svg.lucide-check")).toBeNull(); + + box.focus(); + await user.keyboard(" "); + expect(onCheckedChange).toHaveBeenCalledWith(true); +}); + +it("does nothing when disabled", async () => { + const onCheckedChange = vi.fn(); + const { user } = renderV1( + , + ); + const box = screen.getByRole("checkbox", { name: "Email me" }); + expect(box).toHaveAttribute("disabled"); + expect(screen.getByText("Email me")).toHaveAttribute("data-disabled"); + + await user.click(screen.getByText("Email me")); + box.focus(); + await user.keyboard(" "); + expect(box).toHaveAttribute("aria-checked", "false"); + expect(onCheckedChange).not.toHaveBeenCalled(); +}); + +it("keeps the caller's aria-describedby alongside the description", () => { + renderV1( + , + ); + const box = screen.getByRole("checkbox", { name: "Email me" }); + const ids = box.getAttribute("aria-describedby")?.split(" ") ?? []; + expect(ids).toContain("extra"); + expect(ids).toContain(screen.getByText("Weekly digest").id); +}); + +const expectOnlyTheBox = (container: HTMLElement, name: string) => { + const row = container.querySelector("[data-slot=control-row]"); + expect(row).not.toBeNull(); + const box = screen.getByRole("checkbox", { name }); + expect(row?.children).toHaveLength(1); + const wrapper = row?.firstElementChild; + expect(wrapper?.tagName).toBe("SPAN"); + expect(wrapper?.children).toHaveLength(1); + expect(wrapper?.firstElementChild).toBe(box); + expect(box).not.toHaveAttribute("aria-describedby"); + expect(row?.querySelector("[data-slot=control-label]")).toBeNull(); + expect(row?.querySelector("p")).toBeNull(); +}; + +it("renders only the box when there is no label or description", () => { + const { container } = renderV1(); + expectOnlyTheBox(container, "Select row"); +}); + +it("treats a false or empty label and description as absent", () => { + const { container } = renderV1( + , + ); + expectOnlyTheBox(container, "x"); +}); + +it("centres the box on a 20px first line", () => { + const { container } = renderV1(); + const wrapper = container.querySelector( + "[data-slot=control-row]", + )?.firstElementChild; + expect(wrapper).toHaveClass("ui:flex", "ui:h-5", "ui:items-center"); + expect(screen.getByText("Email me")).toHaveClass("ui:leading-5"); +}); + +// Type-level guard, never rendered: mixed is a controlled state only. +export function _checkboxDefaultCheckedIsBoolean() { + return ( + <> + + {/* @ts-expect-error defaultChecked takes a boolean, not "indeterminate" */} + + + ); +} diff --git a/frontend/src/v1/components/ui/toggles/Checkbox.tsx b/frontend/src/v1/components/ui/toggles/Checkbox.tsx new file mode 100644 index 0000000000..7be3b9b89b --- /dev/null +++ b/frontend/src/v1/components/ui/toggles/Checkbox.tsx @@ -0,0 +1,63 @@ +import { Check, Minus } from "lucide-react"; +import { Checkbox as CheckboxPrimitive } from "radix-ui"; +import type { ComponentProps, JSX, ReactNode } from "react"; +import { ControlRow } from "./ControlRow"; + +export type CheckboxProps = Omit< + ComponentProps, + "asChild" | "children" | "defaultChecked" +> & + Readonly<{ + /** Uncontrolled start state. Mixed is controlled only (`checked="indeterminate"`), because the icon follows `checked`. */ + defaultChecked?: boolean; + label?: ReactNode; + description?: ReactNode; + /** Class for the row, not the box. */ + className?: string; + }>; + +/** + * A 16px checkbox with an optional label and description. The mixed state is + * controlled only (`checked="indeterminate"`) and shows a minus. The icon is + * the indicator itself (`asChild`), so its parent is the button and v0's + * `svg.lucide` sizing rule does not reach it. + */ +export function Checkbox({ + label, + description, + className, + id, + disabled, + checked, + "aria-describedby": ariaDescribedBy, + ...props +}: CheckboxProps): JSX.Element { + const Icon = checked === "indeterminate" ? Minus : Check; + return ( + ( + + + + + + )} + /> + ); +} diff --git a/frontend/src/v1/components/ui/toggles/ControlRow.tsx b/frontend/src/v1/components/ui/toggles/ControlRow.tsx new file mode 100644 index 0000000000..09d622195d --- /dev/null +++ b/frontend/src/v1/components/ui/toggles/ControlRow.tsx @@ -0,0 +1,81 @@ +import { cn } from "@v1/lib/cn"; +import { Label as LabelPrimitive } from "radix-ui"; +import { type JSX, type ReactNode, useId } from "react"; + +/** Text style of a control's label; shared by every toggle and the slider. */ +export const CONTROL_LABEL_CLASS = + "ui:text-body-small ui:font-medium ui:text-foreground ui:select-none ui:leading-5 ui:data-disabled:opacity-50"; + +/** Whether a label or description renders: `false` and `""` count as absent. */ +export function isPresent(node: ReactNode): boolean { + return node !== null && node !== undefined && node !== false && node !== ""; +} + +/** Text style of a control's description. */ +export const CONTROL_DESCRIPTION_CLASS = + "ui:text-caption ui:text-muted-foreground"; + +export type ControlRowProps = Readonly<{ + /** The control. Receives `id` and `aria-describedby` through the render callback. */ + control: (ids: { id: string; describedBy: string | undefined }) => ReactNode; + label?: ReactNode; + description?: ReactNode; + /** Caller's id for the control; generated when omitted. */ + id?: string; + disabled?: boolean; + className?: string; +}>; + +/** + * A control with its label and description beside it. The label is tied to + * the control by `htmlFor`, the description by `aria-describedby`. With + * neither, only the control renders. The control sits in a 20px box, the + * label's line height, so every control centres on the label's first line. + */ +export function ControlRow({ + control, + label, + description, + id: idProp, + disabled, + className, +}: ControlRowProps): JSX.Element { + const generatedId = useId(); + const id = idProp ?? generatedId; + const hasDescription = isPresent(description); + const hasLabel = isPresent(label); + const descriptionId = hasDescription ? `${id}-description` : undefined; + return ( +
+ + {control({ id, describedBy: descriptionId })} + + {hasLabel || hasDescription ? ( +
+ {hasLabel ? ( + + {label} + + ) : null} + {hasDescription ? ( +

+ {description} +

+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/v1/components/ui/toggles/README.md b/frontend/src/v1/components/ui/toggles/README.md new file mode 100644 index 0000000000..5aa49c017c --- /dev/null +++ b/frontend/src/v1/components/ui/toggles/README.md @@ -0,0 +1,181 @@ +# Toggles + +The one way v1 shows a checkbox, a switch, a radio group and a slider +(P1-T05, UN-4300). Import everything from `@v1/components/ui/toggles`: + +```tsx +import { + Checkbox, + Radio, + RadioGroup, + Slider, + Switch, +} from "@v1/components/ui/toggles"; +``` + +| Component | Use it for | Size | +| --- | --- | --- | +| `Checkbox` | An option that is on or off and takes effect on save; "select all" (mixed) | 16 × 16 box | +| `Switch` | A setting that takes effect at once | 20 × 36, 16 thumb | +| `RadioGroup` + `Radio` | One choice from a few options, all visible | 14 radio | +| `Slider` | A number, or a range of two, within known bounds | 6 track, 16 thumb | + +## Rules + +- **Sizes come from the dev-spec board (§ 5).** Checkbox `ui:size-4`, radio + `ui:size-3.5`, switch `ui:h-5 ui:w-9` with a `ui:size-4` thumb, slider + track `ui:h-1.5` and thumb `ui:size-4`. Do not resize them with classes; + `className` sets the outermost element (the row of a `Checkbox`, `Switch` + or `Radio`; the whole field of a `RadioGroup` or `Slider`), never the + control. Each control sits in a 20px box, the label's line height, so it + centres on the label's first line. +- **One focus treatment.** Every focusable part uses + `ui:focus-visible:focus-ring`. Disabled follows board § 6: opacity .5 and + no pointer events, on the control and its label (a disabled `RadioGroup` + dims its heading and every item label too). +- **Mixed is controlled only.** Pass `checked="indeterminate"`; the box then + shows a minus. `defaultChecked` is `boolean` here on purpose: the icon is + chosen from `checked`, so an uncontrolled mixed start would show a tick + while reporting mixed. The icon is chosen in JS, never hidden with CSS. +- **Labels and descriptions.** Pass `label` and `description` rather than + placing text beside a control: the label is tied by `htmlFor`, the + description by `aria-describedby`, and both use the v1 type roles. A + control with no visible label needs `aria-label`. A `RadioGroup` needs a + name: `label` (used as `aria-labelledby`) or `aria-label`. Every slider + thumb needs a name: see `Slider` below. A `label` or `description` of + `false`, `""`, `null` or `undefined` counts as absent. +- **The checkbox icon is the Indicator itself.** v0's global rule forces any + `svg.lucide` whose parent is a `span`, `p`, `label`, `li`, `a`, `td` or + `th` to `1em` (see the v1 README, Styling), and it beats `ui:size-*`. So + the Radix `Indicator` renders `asChild` onto the lucide svg, whose parent + is then the `button`. Never wrap a lucide icon in a `span` in this folder. +- **The row is private.** Label and description are drawn by `ControlRow` + with the same text classes as `Label` and `HelperText` in the P1-T03 form + components. Do not import `ControlRow`; it is not exported. +- **Not inside `Field` yet (P1-T03 / P1-T06).** `Field` is a vertical grid + whose required `label` renders above the control as `