RadioGroup component

Implements headless RadioGroup with root + item sub-components, keyboard navigation (ArrowDown/Up/Left/Right), controlled/uncontrolled value via createControllableSignal, and disabled state. 8 tests passing.
This commit is contained in:
Mats Bosson 2026-03-29 07:50:21 +07:00
parent 46cc41221d
commit c2422d2da0
5 changed files with 247 additions and 0 deletions

View File

@ -0,0 +1,12 @@
import { useRadioGroupContext } from "./radio-group-context";
import { RadioGroupItem } from "./radio-group-item";
import { RadioGroupRoot } from "./radio-group-root";
export const RadioGroup = Object.assign(RadioGroupRoot, {
Item: RadioGroupItem,
useContext: useRadioGroupContext,
});
export type { RadioGroupRootProps } from "./radio-group-root";
export type { RadioGroupItemProps } from "./radio-group-item";
export type { RadioGroupContextValue } from "./radio-group-context";

View File

@ -0,0 +1,27 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export interface RadioGroupContextValue {
value: Accessor<string | undefined>;
onValueChange: (value: string) => void;
disabled: Accessor<boolean>;
name: Accessor<string | undefined>;
}
const RadioGroupContext = createContext<RadioGroupContextValue>();
/**
* Returns the RadioGroup context. Throws if used outside <RadioGroup>.
*/
export function useRadioGroupContext(): RadioGroupContextValue {
const ctx = useContext(RadioGroupContext);
if (!ctx) {
throw new Error(
"[PettyUI] RadioGroup.Item must be used inside <RadioGroup>.\n" +
" Fix: Wrap RadioGroup.Item elements inside <RadioGroup>.",
);
}
return ctx;
}
export const RadioGroupContextProvider = RadioGroupContext.Provider;

View File

@ -0,0 +1,61 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useRadioGroupContext } from "./radio-group-context";
export interface RadioGroupItemProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
disabled?: boolean;
children?: JSX.Element;
}
/**
* A single radio option within a RadioGroup.
* Arrow keys navigate between items; clicking selects.
*/
export function RadioGroupItem(props: RadioGroupItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "children"]);
const ctx = useRadioGroupContext();
const isChecked = () => ctx.value() === local.value;
const isDisabled = () => local.disabled || ctx.disabled();
return (
<button
type="button"
role="radio"
aria-checked={isChecked()}
data-state={isChecked() ? "checked" : "unchecked"}
disabled={isDisabled()}
tabIndex={isChecked() ? 0 : -1}
{...rest}
onClick={(e) => {
if (typeof rest.onClick === "function") rest.onClick(e);
if (!isDisabled()) ctx.onValueChange(local.value);
}}
onKeyDown={(e) => {
if (typeof rest.onKeyDown === "function") rest.onKeyDown(e);
if (isDisabled()) return;
const group = (e.currentTarget as HTMLButtonElement).closest("[role='radiogroup']");
if (!group) return;
const items = Array.from(
group.querySelectorAll<HTMLButtonElement>("[role='radio']:not([disabled])"),
);
const index = items.indexOf(e.currentTarget as HTMLButtonElement);
if (e.key === "ArrowDown" || e.key === "ArrowRight") {
e.preventDefault();
const next = items[(index + 1) % items.length];
next?.focus();
} else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
e.preventDefault();
const prev = items[(index - 1 + items.length) % items.length];
prev?.focus();
}
}}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,57 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { createControllableSignal } from "../../primitives/create-controllable-signal";
import { RadioGroupContextProvider, type RadioGroupContextValue } from "./radio-group-context";
export interface RadioGroupRootProps extends JSX.HTMLAttributes<HTMLDivElement> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
name?: string;
orientation?: "horizontal" | "vertical";
children: JSX.Element;
}
/**
* Root container for a group of mutually exclusive radio buttons.
*/
export function RadioGroupRoot(props: RadioGroupRootProps): JSX.Element {
const [local, rest] = splitProps(props, [
"value",
"defaultValue",
"onValueChange",
"disabled",
"name",
"orientation",
"children",
]);
const [selectedValue, setSelectedValue] = createControllableSignal<string | undefined>({
value: () => local.value,
defaultValue: () => local.defaultValue,
onChange: (v) => {
if (v !== undefined) local.onValueChange?.(v);
},
});
const ctx: RadioGroupContextValue = {
value: selectedValue,
onValueChange: (v) => setSelectedValue(v),
disabled: () => local.disabled ?? false,
name: () => local.name,
};
return (
<RadioGroupContextProvider value={ctx}>
<div
role="radiogroup"
aria-orientation={local.orientation}
data-disabled={local.disabled || undefined}
{...rest}
>
{local.children}
</div>
</RadioGroupContextProvider>
);
}

View File

@ -0,0 +1,90 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { RadioGroup } from "../../../src/components/radio-group/index";
describe("RadioGroup", () => {
it("renders with role=radiogroup", () => {
render(() => (
<RadioGroup>
<RadioGroup.Item value="a">A</RadioGroup.Item>
</RadioGroup>
));
expect(screen.getByRole("radiogroup")).toBeTruthy();
});
it("items have role=radio", () => {
render(() => (
<RadioGroup>
<RadioGroup.Item value="a">A</RadioGroup.Item>
<RadioGroup.Item value="b">B</RadioGroup.Item>
</RadioGroup>
));
expect(screen.getAllByRole("radio")).toHaveLength(2);
});
it("no item is checked by default", () => {
render(() => (
<RadioGroup>
<RadioGroup.Item value="a">A</RadioGroup.Item>
</RadioGroup>
));
expect(screen.getByRole("radio").getAttribute("aria-checked")).toBe("false");
});
it("defaultValue pre-selects item", () => {
render(() => (
<RadioGroup defaultValue="b">
<RadioGroup.Item value="a">A</RadioGroup.Item>
<RadioGroup.Item value="b">B</RadioGroup.Item>
</RadioGroup>
));
const radios = screen.getAllByRole("radio");
expect(radios[0].getAttribute("aria-checked")).toBe("false");
expect(radios[1].getAttribute("aria-checked")).toBe("true");
});
it("clicking an item selects it", () => {
render(() => (
<RadioGroup>
<RadioGroup.Item value="a">A</RadioGroup.Item>
<RadioGroup.Item value="b">B</RadioGroup.Item>
</RadioGroup>
));
fireEvent.click(screen.getAllByRole("radio")[0]);
expect(screen.getAllByRole("radio")[0].getAttribute("aria-checked")).toBe("true");
expect(screen.getAllByRole("radio")[1].getAttribute("aria-checked")).toBe("false");
});
it("clicking a selected item keeps it selected", () => {
render(() => (
<RadioGroup defaultValue="a">
<RadioGroup.Item value="a">A</RadioGroup.Item>
</RadioGroup>
));
fireEvent.click(screen.getByRole("radio"));
expect(screen.getByRole("radio").getAttribute("aria-checked")).toBe("true");
});
it("ArrowDown moves to next radio", () => {
render(() => (
<RadioGroup>
<RadioGroup.Item value="a">A</RadioGroup.Item>
<RadioGroup.Item value="b">B</RadioGroup.Item>
</RadioGroup>
));
const [first] = screen.getAllByRole("radio");
first.focus();
fireEvent.keyDown(first, { key: "ArrowDown" });
expect(document.activeElement).toBe(screen.getAllByRole("radio")[1]);
});
it("disabled item is not selectable", () => {
render(() => (
<RadioGroup>
<RadioGroup.Item value="a" disabled>A</RadioGroup.Item>
</RadioGroup>
));
fireEvent.click(screen.getByRole("radio"));
expect(screen.getByRole("radio").getAttribute("aria-checked")).toBe("false");
});
});