ToggleGroup component

Implements headless ToggleGroup with single/multiple selection modes, context-based item coordination, aria-pressed, and data-state attributes.
This commit is contained in:
Mats Bosson 2026-03-29 07:55:44 +07:00
parent b1f0cd2e9d
commit 40e57715f9
5 changed files with 231 additions and 0 deletions

View File

@ -0,0 +1,12 @@
import { useToggleGroupContext } from "./toggle-group-context";
import { ToggleGroupItem } from "./toggle-group-item";
import { ToggleGroupRoot } from "./toggle-group-root";
export const ToggleGroup = Object.assign(ToggleGroupRoot, {
Item: ToggleGroupItem,
useContext: useToggleGroupContext,
});
export type { ToggleGroupRootProps } from "./toggle-group-root";
export type { ToggleGroupItemProps } from "./toggle-group-item";
export type { ToggleGroupContextValue } from "./toggle-group-context";

View File

@ -0,0 +1,26 @@
import type { Accessor } from "solid-js";
import { createContext, useContext } from "solid-js";
export interface ToggleGroupContextValue {
isPressed: (value: string) => boolean;
onItemPress: (value: string) => void;
disabled: Accessor<boolean>;
}
const ToggleGroupContext = createContext<ToggleGroupContextValue>();
/**
* Returns the ToggleGroup context. Throws if used outside <ToggleGroup>.
*/
export function useToggleGroupContext(): ToggleGroupContextValue {
const ctx = useContext(ToggleGroupContext);
if (!ctx) {
throw new Error(
"[PettyUI] ToggleGroup.Item must be used inside <ToggleGroup>.\n" +
" Fix: Wrap ToggleGroup.Item elements inside <ToggleGroup>.",
);
}
return ctx;
}
export const ToggleGroupContextProvider = ToggleGroupContext.Provider;

View File

@ -0,0 +1,34 @@
import type { JSX } from "solid-js";
import { splitProps } from "solid-js";
import { useToggleGroupContext } from "./toggle-group-context";
export interface ToggleGroupItemProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
disabled?: boolean;
children?: JSX.Element;
}
/** A single toggle item within a ToggleGroup. */
export function ToggleGroupItem(props: ToggleGroupItemProps): JSX.Element {
const [local, rest] = splitProps(props, ["value", "disabled", "children"]);
const ctx = useToggleGroupContext();
const isPressed = () => ctx.isPressed(local.value);
const isDisabled = () => local.disabled || ctx.disabled();
return (
<button
type="button"
aria-pressed={isPressed() ? "true" : "false"}
data-state={isPressed() ? "on" : "off"}
disabled={isDisabled()}
{...rest}
onClick={(e) => {
if (typeof rest.onClick === "function") rest.onClick(e);
if (!isDisabled()) ctx.onItemPress(local.value);
}}
>
{local.children}
</button>
);
}

View File

@ -0,0 +1,85 @@
import type { JSX } from "solid-js";
import { createSignal, splitProps } from "solid-js";
import { ToggleGroupContextProvider, type ToggleGroupContextValue } from "./toggle-group-context";
type SingleProps = {
type: "single";
value?: string;
defaultValue?: string;
onValueChange?: (value: string | undefined) => void;
};
type MultipleProps = {
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
export type ToggleGroupRootProps = (SingleProps | MultipleProps) & {
disabled?: boolean;
children: JSX.Element;
} & JSX.HTMLAttributes<HTMLDivElement>;
/**
* A group of toggle buttons. In "single" mode only one can be active at a time.
* In "multiple" mode any number can be active simultaneously.
*/
export function ToggleGroupRoot(props: ToggleGroupRootProps): JSX.Element {
// Cast to access discriminated union fields together
const [local, rest] = splitProps(
props as ToggleGroupRootProps & { type: "single" | "multiple" },
["type", "value", "defaultValue", "onValueChange", "disabled", "children"],
);
// Normalize to string[] internally. Use createSignal since the union type
// is complex to thread through createControllableSignal generics safely.
const getInitialValue = (): string[] => {
const dv = local.defaultValue;
if (dv === undefined) return [];
return local.type === "multiple" ? (dv as string[]) : [dv as string];
};
const [pressedValues, setPressedValues] = createSignal<string[]>(getInitialValue());
// Sync external controlled value
const controlled = (): string[] | undefined => {
const v = local.value;
if (v === undefined) return undefined;
return local.type === "multiple" ? (v as string[]) : v != null ? [v as string] : [];
};
const getValues = (): string[] => controlled() ?? pressedValues();
const ctx: ToggleGroupContextValue = {
isPressed: (value) => getValues().includes(value),
onItemPress: (value) => {
const current = getValues();
let next: string[];
if (local.type === "single") {
next = current.includes(value) ? [] : [value];
} else {
next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
}
// Only update internal state when uncontrolled
if (controlled() === undefined) {
setPressedValues(next);
}
// Call onChange
if (local.type === "multiple") {
(local.onValueChange as ((v: string[]) => void) | undefined)?.(next);
} else {
(local.onValueChange as ((v: string | undefined) => void) | undefined)?.(next[0]);
}
},
disabled: () => local.disabled ?? false,
};
return (
<ToggleGroupContextProvider value={ctx}>
<div data-disabled={local.disabled || undefined} {...rest}>
{local.children}
</div>
</ToggleGroupContextProvider>
);
}

View File

@ -0,0 +1,74 @@
import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { ToggleGroup } from "../../../src/components/toggle-group/index";
describe("ToggleGroup single", () => {
it("no item pressed by default", () => {
render(() => (
<ToggleGroup type="single">
<ToggleGroup.Item value="a">A</ToggleGroup.Item>
</ToggleGroup>
));
expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("false");
});
it("pressing an item selects it", () => {
render(() => (
<ToggleGroup type="single">
<ToggleGroup.Item value="a">A</ToggleGroup.Item>
<ToggleGroup.Item value="b">B</ToggleGroup.Item>
</ToggleGroup>
));
fireEvent.click(screen.getAllByRole("button")[0]);
expect(screen.getAllByRole("button")[0].getAttribute("aria-pressed")).toBe("true");
expect(screen.getAllByRole("button")[1].getAttribute("aria-pressed")).toBe("false");
});
it("pressing another item in single mode deselects first", () => {
render(() => (
<ToggleGroup type="single" defaultValue="a">
<ToggleGroup.Item value="a">A</ToggleGroup.Item>
<ToggleGroup.Item value="b">B</ToggleGroup.Item>
</ToggleGroup>
));
fireEvent.click(screen.getAllByRole("button")[1]);
expect(screen.getAllByRole("button")[0].getAttribute("aria-pressed")).toBe("false");
expect(screen.getAllByRole("button")[1].getAttribute("aria-pressed")).toBe("true");
});
it("data-state reflects pressed state", () => {
render(() => (
<ToggleGroup type="single">
<ToggleGroup.Item value="a">A</ToggleGroup.Item>
</ToggleGroup>
));
expect(screen.getByRole("button").getAttribute("data-state")).toBe("off");
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("button").getAttribute("data-state")).toBe("on");
});
});
describe("ToggleGroup multiple", () => {
it("pressing items toggles them independently", () => {
render(() => (
<ToggleGroup type="multiple">
<ToggleGroup.Item value="a">A</ToggleGroup.Item>
<ToggleGroup.Item value="b">B</ToggleGroup.Item>
</ToggleGroup>
));
fireEvent.click(screen.getAllByRole("button")[0]);
fireEvent.click(screen.getAllByRole("button")[1]);
expect(screen.getAllByRole("button")[0].getAttribute("aria-pressed")).toBe("true");
expect(screen.getAllByRole("button")[1].getAttribute("aria-pressed")).toBe("true");
});
it("pressing a pressed item in multiple mode unpresses it", () => {
render(() => (
<ToggleGroup type="multiple" defaultValue={["a"]}>
<ToggleGroup.Item value="a">A</ToggleGroup.Item>
</ToggleGroup>
));
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("false");
});
});