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:
parent
46cc41221d
commit
c2422d2da0
12
packages/core/src/components/radio-group/index.ts
Normal file
12
packages/core/src/components/radio-group/index.ts
Normal 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";
|
||||||
@ -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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user