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