diff --git a/packages/core/src/components/toggle-group/index.ts b/packages/core/src/components/toggle-group/index.ts new file mode 100644 index 0000000..0b85baa --- /dev/null +++ b/packages/core/src/components/toggle-group/index.ts @@ -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"; diff --git a/packages/core/src/components/toggle-group/toggle-group-context.ts b/packages/core/src/components/toggle-group/toggle-group-context.ts new file mode 100644 index 0000000..211a4b0 --- /dev/null +++ b/packages/core/src/components/toggle-group/toggle-group-context.ts @@ -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; +} + +const ToggleGroupContext = createContext(); + +/** + * Returns the ToggleGroup context. Throws if used outside . + */ +export function useToggleGroupContext(): ToggleGroupContextValue { + const ctx = useContext(ToggleGroupContext); + if (!ctx) { + throw new Error( + "[PettyUI] ToggleGroup.Item must be used inside .\n" + + " Fix: Wrap ToggleGroup.Item elements inside .", + ); + } + return ctx; +} + +export const ToggleGroupContextProvider = ToggleGroupContext.Provider; diff --git a/packages/core/src/components/toggle-group/toggle-group-item.tsx b/packages/core/src/components/toggle-group/toggle-group-item.tsx new file mode 100644 index 0000000..3752ecc --- /dev/null +++ b/packages/core/src/components/toggle-group/toggle-group-item.tsx @@ -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 { + 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 ( + + ); +} diff --git a/packages/core/src/components/toggle-group/toggle-group-root.tsx b/packages/core/src/components/toggle-group/toggle-group-root.tsx new file mode 100644 index 0000000..00153a8 --- /dev/null +++ b/packages/core/src/components/toggle-group/toggle-group-root.tsx @@ -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; + +/** + * 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(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 ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/tests/components/toggle-group/toggle-group.test.tsx b/packages/core/tests/components/toggle-group/toggle-group.test.tsx new file mode 100644 index 0000000..3890b2d --- /dev/null +++ b/packages/core/tests/components/toggle-group/toggle-group.test.tsx @@ -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(() => ( + + A + + )); + expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("false"); + }); + + it("pressing an item selects it", () => { + render(() => ( + + A + B + + )); + 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(() => ( + + A + B + + )); + 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(() => ( + + A + + )); + 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(() => ( + + A + B + + )); + 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(() => ( + + A + + )); + fireEvent.click(screen.getByRole("button")); + expect(screen.getByRole("button").getAttribute("aria-pressed")).toBe("false"); + }); +});