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