Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/src/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions frontend/src/v1/components/ui/toggles/Checkbox.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Checkbox label="Email me" description="Weekly digest" />);
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(
<Checkbox label="Email me" onCheckedChange={onCheckedChange} />,
);
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(
<Checkbox
label="All rows"
checked="indeterminate"
onCheckedChange={onCheckedChange}
/>,
);
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(
<Checkbox label="Email me" disabled onCheckedChange={onCheckedChange} />,
);
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(
<Checkbox
label="Email me"
description="Weekly digest"
aria-describedby="extra"
/>,
);
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(<Checkbox aria-label="Select row" />);
expectOnlyTheBox(container, "Select row");
});

it("treats a false or empty label and description as absent", () => {
const { container } = renderV1(
<Checkbox label={false} description="" aria-label="x" />,
);
expectOnlyTheBox(container, "x");
});

it("centres the box on a 20px first line", () => {
const { container } = renderV1(<Checkbox label="Email me" />);
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 (
<>
<Checkbox aria-label="x" defaultChecked />
{/* @ts-expect-error defaultChecked takes a boolean, not "indeterminate" */}
<Checkbox aria-label="x" defaultChecked="indeterminate" />
</>
);
}
63 changes: 63 additions & 0 deletions frontend/src/v1/components/ui/toggles/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof CheckboxPrimitive.Root>,
"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 (
<ControlRow
id={id}
label={label}
description={description}
disabled={disabled}
className={className}
control={({ id: controlId, describedBy }) => (

Check warning on line 43 in frontend/src/v1/components/ui/toggles/Checkbox.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move this component definition out of the parent component and pass data as props.

See more on https://sonarcloud.io/project/issues?id=Zipstack_unstract&issues=AaG_7WCVNrHyZCvw4JGh&open=AaG_7WCVNrHyZCvw4JGh&pullRequest=2462
<CheckboxPrimitive.Root
id={controlId}
data-slot="checkbox"
disabled={disabled}
checked={checked}
aria-describedby={
[ariaDescribedBy, describedBy].filter(Boolean).join(" ") ||
undefined
}
className="ui:peer ui:size-4 ui:shrink-0 ui:cursor-pointer ui:rounded-sm ui:border ui:border-input ui:bg-background ui:text-primary-foreground ui:focus-visible:focus-ring ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:data-[state=checked]:border-primary ui:data-[state=checked]:bg-primary ui:data-[state=indeterminate]:border-primary ui:data-[state=indeterminate]:bg-primary"
{...props}
>
<CheckboxPrimitive.Indicator asChild>
<Icon className="ui:size-3.5" aria-hidden />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)}
/>
);
}
81 changes: 81 additions & 0 deletions frontend/src/v1/components/ui/toggles/ControlRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-slot="control-row"
className={cn("ui:flex ui:items-start ui:gap-2", className)}
>
<span className="ui:flex ui:h-5 ui:shrink-0 ui:items-center">
{control({ id, describedBy: descriptionId })}
</span>
{hasLabel || hasDescription ? (
<div className="ui:grid ui:gap-0.5">
{hasLabel ? (
<LabelPrimitive.Root
data-slot="control-label"
htmlFor={id}
data-disabled={disabled ? "" : undefined}
className={CONTROL_LABEL_CLASS}
>
{label}
</LabelPrimitive.Root>
) : null}
{hasDescription ? (
<p
id={descriptionId}
data-slot="control-description"
className={CONTROL_DESCRIPTION_CLASS}
>
{description}
</p>
) : null}
</div>
) : null}
</div>
);
}
Loading
Loading