diff --git a/packages/core/src/components/radio-group/index.ts b/packages/core/src/components/radio-group/index.ts new file mode 100644 index 0000000..8701e3f --- /dev/null +++ b/packages/core/src/components/radio-group/index.ts @@ -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"; diff --git a/packages/core/src/components/radio-group/radio-group-context.ts b/packages/core/src/components/radio-group/radio-group-context.ts new file mode 100644 index 0000000..7d39a46 --- /dev/null +++ b/packages/core/src/components/radio-group/radio-group-context.ts @@ -0,0 +1,27 @@ +import type { Accessor } from "solid-js"; +import { createContext, useContext } from "solid-js"; + +export interface RadioGroupContextValue { + value: Accessor; + onValueChange: (value: string) => void; + disabled: Accessor; + name: Accessor; +} + +const RadioGroupContext = createContext(); + +/** + * Returns the RadioGroup context. Throws if used outside . + */ +export function useRadioGroupContext(): RadioGroupContextValue { + const ctx = useContext(RadioGroupContext); + if (!ctx) { + throw new Error( + "[PettyUI] RadioGroup.Item must be used inside .\n" + + " Fix: Wrap RadioGroup.Item elements inside .", + ); + } + return ctx; +} + +export const RadioGroupContextProvider = RadioGroupContext.Provider; diff --git a/packages/core/src/components/radio-group/radio-group-item.tsx b/packages/core/src/components/radio-group/radio-group-item.tsx new file mode 100644 index 0000000..60071f9 --- /dev/null +++ b/packages/core/src/components/radio-group/radio-group-item.tsx @@ -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 { + 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 ( + + ); +} diff --git a/packages/core/src/components/radio-group/radio-group-root.tsx b/packages/core/src/components/radio-group/radio-group-root.tsx new file mode 100644 index 0000000..1990baf --- /dev/null +++ b/packages/core/src/components/radio-group/radio-group-root.tsx @@ -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 { + 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({ + 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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/tests/components/radio-group/radio-group.test.tsx b/packages/core/tests/components/radio-group/radio-group.test.tsx new file mode 100644 index 0000000..d9a1425 --- /dev/null +++ b/packages/core/tests/components/radio-group/radio-group.test.tsx @@ -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(() => ( + + A + + )); + expect(screen.getByRole("radiogroup")).toBeTruthy(); + }); + + it("items have role=radio", () => { + render(() => ( + + A + B + + )); + expect(screen.getAllByRole("radio")).toHaveLength(2); + }); + + it("no item is checked by default", () => { + render(() => ( + + A + + )); + expect(screen.getByRole("radio").getAttribute("aria-checked")).toBe("false"); + }); + + it("defaultValue pre-selects item", () => { + render(() => ( + + A + B + + )); + 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(() => ( + + A + B + + )); + 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(() => ( + + A + + )); + fireEvent.click(screen.getByRole("radio")); + expect(screen.getByRole("radio").getAttribute("aria-checked")).toBe("true"); + }); + + it("ArrowDown moves to next radio", () => { + render(() => ( + + A + B + + )); + 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(() => ( + + A + + )); + fireEvent.click(screen.getByRole("radio")); + expect(screen.getByRole("radio").getAttribute("aria-checked")).toBe("false"); + }); +});