diff --git a/.changeset/grumpy-clouds-remember.md b/.changeset/grumpy-clouds-remember.md new file mode 100644 index 000000000..56c0b5c50 --- /dev/null +++ b/.changeset/grumpy-clouds-remember.md @@ -0,0 +1,5 @@ +--- +"bits-ui": minor +--- + +feat: `Checkbox.Group` and `Checkbox.GroupLabel` components diff --git a/packages/bits-ui/src/lib/app.d.ts b/packages/bits-ui/src/lib/app.d.ts index af1dcb8c8..8fd51c4ed 100644 --- a/packages/bits-ui/src/lib/app.d.ts +++ b/packages/bits-ui/src/lib/app.d.ts @@ -12,4 +12,6 @@ declare global { var bitsEscapeLayers: Map>; // eslint-disable-next-line vars-on-top, no-var var bitsTextSelectionLayers: Map>; + // eslint-disable-next-line vars-on-top, no-var + var bitsIdCounter: { current: number }; } diff --git a/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts b/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts index cab8edef5..ab701314b 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts +++ b/packages/bits-ui/src/lib/bits/checkbox/checkbox.svelte.ts @@ -1,5 +1,6 @@ import { srOnlyStyles, styleToString, useRefById } from "svelte-toolbelt"; import type { HTMLButtonAttributes } from "svelte/elements"; +import { watch } from "runed"; import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js"; import type { BitsKeyboardEvent, BitsMouseEvent, WithRefProps } from "$lib/internal/types.js"; import { getAriaChecked, getAriaRequired, getDataDisabled } from "$lib/internal/attrs.js"; @@ -7,6 +8,103 @@ import { kbd } from "$lib/internal/kbd.js"; import { createContext } from "$lib/internal/create-context.js"; const CHECKBOX_ROOT_ATTR = "data-checkbox-root"; +const CHECKBOX_GROUP_ATTR = "data-checkbox-group"; +const CHECKBOX_GROUP_LABEL_ATTR = "data-checkbox-group-label"; + +type CheckboxGroupStateProps = WithRefProps< + ReadableBoxedValues<{ + name: string | undefined; + disabled: boolean; + required: boolean; + }> & + WritableBoxedValues<{ + value: string[]; + }> +>; + +class CheckboxGroupState { + id: CheckboxGroupStateProps["id"]; + ref: CheckboxGroupStateProps["ref"]; + value: CheckboxGroupStateProps["value"]; + disabled: CheckboxGroupStateProps["disabled"]; + required: CheckboxGroupStateProps["required"]; + name: CheckboxGroupStateProps["name"]; + labelId = $state(undefined); + + constructor(props: CheckboxGroupStateProps) { + this.id = props.id; + this.ref = props.ref; + this.value = props.value; + this.disabled = props.disabled; + this.required = props.required; + this.name = props.name; + + useRefById({ + id: this.id, + ref: this.ref, + }); + } + + addValue(checkboxValue: string | undefined) { + if (!checkboxValue) return; + if (!this.value.current.includes(checkboxValue)) { + this.value.current.push(checkboxValue); + } + } + + removeValue(checkboxValue: string | undefined) { + if (!checkboxValue) return; + const index = this.value.current.indexOf(checkboxValue); + if (index === -1) return; + this.value.current.splice(index, 1); + } + + props = $derived.by( + () => + ({ + id: this.id.current, + role: "group", + "aria-labelledby": this.labelId, + "data-disabled": getDataDisabled(this.disabled.current), + [CHECKBOX_GROUP_ATTR]: "", + }) as const + ); +} + +type CheckboxGroupLabelStateProps = WithRefProps; + +class CheckboxGroupLabelState { + id: CheckboxGroupLabelStateProps["id"]; + ref: CheckboxGroupLabelStateProps["ref"]; + group: CheckboxGroupState; + + constructor(props: CheckboxGroupLabelStateProps, group: CheckboxGroupState) { + this.id = props.id; + this.ref = props.ref; + this.group = group; + + useRefById({ + id: this.id, + ref: this.ref, + onRefChange: (node) => { + if (node) { + group.labelId = node.id; + } else { + group.labelId = undefined; + } + }, + }); + } + + props = $derived.by( + () => + ({ + id: this.id.current, + "data-disabled": getDataDisabled(this.group.disabled.current), + [CHECKBOX_GROUP_LABEL_ATTR]: "", + }) as const + ); +} type CheckboxRootStateProps = WithRefProps< ReadableBoxedValues<{ @@ -27,22 +125,44 @@ class CheckboxRootState { #ref: CheckboxRootStateProps["ref"]; #type: CheckboxRootStateProps["type"]; checked: CheckboxRootStateProps["checked"]; - disabled: CheckboxRootStateProps["disabled"]; - required: CheckboxRootStateProps["required"]; - name: CheckboxRootStateProps["name"]; + #disabled: CheckboxRootStateProps["disabled"]; + #required: CheckboxRootStateProps["required"]; + #name: CheckboxRootStateProps["name"]; value: CheckboxRootStateProps["value"]; indeterminate: CheckboxRootStateProps["indeterminate"]; + group: CheckboxGroupState | null = null; + + trueName = $derived.by(() => { + if (this.group && this.group.name.current) { + return this.group.name.current; + } else { + return this.#name.current; + } + }); + trueRequired = $derived.by(() => { + if (this.group && this.group.required.current) { + return true; + } + return this.#required.current; + }); + trueDisabled = $derived.by(() => { + if (this.group && this.group.disabled.current) { + return true; + } + return this.#disabled.current; + }); - constructor(props: CheckboxRootStateProps) { + constructor(props: CheckboxRootStateProps, group: CheckboxGroupState | null = null) { this.checked = props.checked; - this.disabled = props.disabled; - this.required = props.required; - this.name = props.name; + this.#disabled = props.disabled; + this.#required = props.required; + this.#name = props.name; this.value = props.value; this.#ref = props.ref; this.#id = props.id; this.indeterminate = props.indeterminate; this.#type = props.type; + this.group = group; this.onkeydown = this.onkeydown.bind(this); this.onclick = this.onclick.bind(this); @@ -50,10 +170,30 @@ class CheckboxRootState { id: this.#id, ref: this.#ref, }); + + watch( + [() => $state.snapshot(this.group?.value.current), () => this.value.current], + ([groupValue, value]) => { + if (!groupValue || !value) return; + this.checked.current = groupValue.includes(value); + } + ); + + watch( + () => this.checked.current, + (checked) => { + if (!this.group) return; + if (checked) { + this.group?.addValue(this.value.current); + } else { + this.group?.removeValue(this.value.current); + } + } + ); } onkeydown(e: BitsKeyboardEvent) { - if (this.disabled.current) return; + if (this.#disabled.current) return; if (e.key === kbd.ENTER) e.preventDefault(); if (e.key === kbd.SPACE) { e.preventDefault(); @@ -71,20 +211,25 @@ class CheckboxRootState { } onclick(_: BitsMouseEvent) { - if (this.disabled.current) return; + if (this.#disabled.current) return; this.#toggle(); } + snippetProps = $derived.by(() => ({ + checked: this.checked.current, + indeterminate: this.indeterminate.current, + })); + props = $derived.by( () => ({ id: this.#id.current, role: "checkbox", type: this.#type.current, - disabled: this.disabled.current, + disabled: this.trueDisabled, "aria-checked": getAriaChecked(this.checked.current, this.indeterminate.current), - "aria-required": getAriaRequired(this.required.current), - "data-disabled": getDataDisabled(this.disabled.current), + "aria-required": getAriaRequired(this.trueRequired), + "data-disabled": getDataDisabled(this.trueDisabled), "data-state": getCheckboxDataState( this.checked.current, this.indeterminate.current @@ -103,7 +248,20 @@ class CheckboxRootState { class CheckboxInputState { root: CheckboxRootState; - shouldRender = $derived.by(() => Boolean(this.root.name.current)); + trueChecked = $derived.by(() => { + if (this.root.group) { + if ( + this.root.value.current !== undefined && + this.root.group.value.current.includes(this.root.value.current) + ) { + return true; + } + return false; + } + return this.root.checked.current; + }); + + shouldRender = $derived.by(() => Boolean(this.root.trueName)); constructor(root: CheckboxRootState) { this.root = root; @@ -114,9 +272,9 @@ class CheckboxInputState { ({ type: "checkbox", checked: this.root.checked.current === true, - disabled: this.root.disabled.current, - required: this.root.required.current, - name: this.root.name.current, + disabled: this.root.trueDisabled, + required: this.root.trueRequired, + name: this.root.trueName, value: this.root.value.current, "aria-hidden": "true", style: styleToString(srOnlyStyles), @@ -139,11 +297,22 @@ function getCheckboxDataState(checked: boolean, indeterminate: boolean) { // CONTEXT METHODS // +const [setCheckboxGroupContext, getCheckboxGroupContext] = + createContext("Checkbox.Group"); + const [setCheckboxRootContext, getCheckboxRootContext] = createContext("Checkbox.Root"); +export function useCheckboxGroup(props: CheckboxGroupStateProps) { + return setCheckboxGroupContext(new CheckboxGroupState(props)); +} + export function useCheckboxRoot(props: CheckboxRootStateProps) { - return setCheckboxRootContext(new CheckboxRootState(props)); + return setCheckboxRootContext(new CheckboxRootState(props, getCheckboxGroupContext(null))); +} + +export function useCheckboxGroupLabel(props: CheckboxGroupLabelStateProps) { + return new CheckboxGroupLabelState(props, getCheckboxGroupContext()); } export function useCheckboxInput(): CheckboxInputState { diff --git a/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group-label.svelte b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group-label.svelte new file mode 100644 index 000000000..ced784810 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group-label.svelte @@ -0,0 +1,32 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + + {@render children?.()} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group.svelte b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group.svelte new file mode 100644 index 000000000..ff9596dc0 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox-group.svelte @@ -0,0 +1,45 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render children?.()} +
+{/if} diff --git a/packages/bits-ui/src/lib/bits/checkbox/components/checkbox.svelte b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox.svelte index df87f5b73..d195794bb 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/components/checkbox.svelte +++ b/packages/bits-ui/src/lib/bits/checkbox/components/checkbox.svelte @@ -65,15 +65,11 @@ {#if child} {@render child({ props: mergedProps, - checked: rootState.checked.current, - indeterminate: rootState.indeterminate.current, + ...rootState.snippetProps, })} {:else} {/if} diff --git a/packages/bits-ui/src/lib/bits/checkbox/exports.ts b/packages/bits-ui/src/lib/bits/checkbox/exports.ts index 9d448ab93..6dac3881c 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/exports.ts +++ b/packages/bits-ui/src/lib/bits/checkbox/exports.ts @@ -1,2 +1,8 @@ export { default as Root } from "./components/checkbox.svelte"; -export type { CheckboxRootProps as RootProps } from "./types.js"; +export { default as Group } from "./components/checkbox-group.svelte"; +export { default as GroupLabel } from "./components/checkbox-group-label.svelte"; +export type { + CheckboxRootProps as RootProps, + CheckboxGroupProps as GroupProps, + CheckboxGroupLabelProps as GroupLabelProps, +} from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/checkbox/types.ts b/packages/bits-ui/src/lib/bits/checkbox/types.ts index 3db4b9afd..2a3df92f4 100644 --- a/packages/bits-ui/src/lib/bits/checkbox/types.ts +++ b/packages/bits-ui/src/lib/bits/checkbox/types.ts @@ -1,5 +1,9 @@ import type { OnChangeFn, WithChild, Without } from "$lib/internal/types.js"; -import type { BitsPrimitiveButtonAttributes } from "$lib/shared/attributes.js"; +import type { + BitsPrimitiveButtonAttributes, + BitsPrimitiveDivAttributes, + BitsPrimitiveSpanAttributes, +} from "$lib/shared/attributes.js"; export type CheckboxRootSnippetProps = { checked: boolean; indeterminate: boolean }; @@ -29,7 +33,9 @@ export type CheckboxRootPropsWithoutHTML = WithChild< name?: any; /** - * The value of the checkbox used in form submission. + * The value of the checkbox used in form submission and to identify + * the checkbox when in a `Checkbox.Group`. If not provided while in a + * `Checkbox.Group`, the checkbox will use a random identifier. * * @defaultValue undefined */ @@ -86,3 +92,63 @@ export type CheckboxRootPropsWithoutHTML = WithChild< export type CheckboxRootProps = CheckboxRootPropsWithoutHTML & Without; + +export type CheckboxGroupPropsWithoutHTML = WithChild<{ + /** + * Whether the checkbox group is disabled. + * This will disable all checkboxes in the group. + * + * @defaultValue false + */ + disabled?: boolean | null | undefined; + + /** + * Whether the checkbox group is required (for form validation). + * This will mark all checkboxes in the group as required. + * + * @defaultValue false + */ + required?: boolean; + + /** + * The name of the checkbox used in form submission. + * If not provided, the hidden input will not be rendered. + * This will be used as the name for all checkboxes in the group. + * + * @defaultValue undefined + */ + // eslint-disable-next-line ts/no-explicit-any + name?: any; + + /** + * The value of the checkbox group, indicating which + * of the checkboxes in the group are checked. + * + * @bindable + * @defaultValue [] + */ + value?: string[]; + + /** + * A callback function called when the value changes. + */ + onValueChange?: OnChangeFn; + + /** + * Whether or not the checkbox group value is controlled or not. If `true`, the + * checkbox group will not update the value internally, instead it will call + * `onValueChange` when it would have otherwise, and it is up to you to update + * the `value` prop that is passed to the component. + * + * @defaultValue false + */ + controlledValue?: boolean; +}>; + +export type CheckboxGroupProps = CheckboxGroupPropsWithoutHTML & + Without; + +export type CheckboxGroupLabelPropsWithoutHTML = WithChild; + +export type CheckboxGroupLabelProps = CheckboxGroupLabelPropsWithoutHTML & + Without; diff --git a/packages/bits-ui/src/lib/internal/use-id.ts b/packages/bits-ui/src/lib/internal/use-id.ts index 0672c7801..4e7df7fba 100644 --- a/packages/bits-ui/src/lib/internal/use-id.ts +++ b/packages/bits-ui/src/lib/internal/use-id.ts @@ -1,9 +1,9 @@ -let count = 0; +globalThis.bitsIdCounter ??= { current: 0 }; /** * Generates a unique ID based on a global counter. */ export function useId(prefix = "bits") { - count++; - return `${prefix}-${count}`; + globalThis.bitsIdCounter.current++; + return `${prefix}-${globalThis.bitsIdCounter.current}`; } diff --git a/packages/tests/src/tests/checkbox/checkbox-group-test.svelte b/packages/tests/src/tests/checkbox/checkbox-group-test.svelte new file mode 100644 index 000000000..5ef1aaa56 --- /dev/null +++ b/packages/tests/src/tests/checkbox/checkbox-group-test.svelte @@ -0,0 +1,57 @@ + + +{#snippet MyCheckbox({ itemValue }: { itemValue: string })} + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + indeterminate + {:else} + {checked} + {/if} + + {/snippet} + +{/snippet} + +
+
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + onFormSubmit?.(formData); + }} + > +

{value}

+ + My Group + {#each items as itemValue} + {@render MyCheckbox({ itemValue })} + {/each} + + +
+ +
diff --git a/packages/tests/src/tests/checkbox/checkbox.test.ts b/packages/tests/src/tests/checkbox/checkbox.test.ts index 3cec1385a..dd9fda194 100644 --- a/packages/tests/src/tests/checkbox/checkbox.test.ts +++ b/packages/tests/src/tests/checkbox/checkbox.test.ts @@ -1,13 +1,16 @@ import { render } from "@testing-library/svelte/svelte5"; import { axe } from "jest-axe"; -import { describe, it } from "vitest"; -import { tick } from "svelte"; +import { describe, it, vi } from "vitest"; +import { type ComponentProps, tick } from "svelte"; import type { Checkbox } from "bits-ui"; import { getTestKbd, setupUserEvents } from "../utils.js"; import CheckboxTest from "./checkbox-test.svelte"; +import CheckboxGroupTest from "./checkbox-group-test.svelte"; const kbd = getTestKbd(); +const groupItems = ["a", "b", "c", "d"]; + function setup(props?: Checkbox.RootProps) { const user = setupUserEvents(); const returned = render(CheckboxTest, props); @@ -21,6 +24,39 @@ function setup(props?: Checkbox.RootProps) { }; } +function setupGroup(props: ComponentProps = {}) { + const items = props.items ?? groupItems; + const user = setupUserEvents(); + const returned = render(CheckboxGroupTest, { + ...props, + items, + }); + const group = returned.getByTestId("group"); + const groupLabel = returned.getByTestId("group-label"); + const submit = returned.getByTestId("submit"); + const binding = returned.getByTestId("binding"); + const updateBtn = returned.getByTestId("update"); + + const getCheckbox = (v: string) => returned.getByTestId(`${v}-checkbox`); + const getIndicator = (v: string) => returned.getByTestId(`${v}-indicator`); + const checkboxes = items.map((v) => getCheckbox(v)); + const indicators = items.map((v) => returned.getByTestId(`${v}-indicator`)); + + return { + ...returned, + group, + groupLabel, + getCheckbox, + getIndicator, + checkboxes, + indicators, + user, + submit, + binding, + updateBtn, + }; +} + describe("checkbox", () => { it("should have no accessibility violations", async () => { const { container } = render(CheckboxTest); @@ -32,7 +68,7 @@ describe("checkbox", () => { expect(root).toHaveAttribute("data-checkbox-root"); }); - it("should not render the checkbox input if a name prop isnt passed", async () => { + it("should not render the checkbox input if a name prop isn't passed", async () => { const { input } = setup({ name: "" }); expect(input).not.toBeInTheDocument(); }); @@ -61,15 +97,13 @@ describe("checkbox", () => { it("should toggle when clicked", async () => { const { getByTestId, root, input, user } = setup(); const indicator = getByTestId("indicator"); - expect(root).toHaveAttribute("data-state", "unchecked"); - expect(root).toHaveAttribute("aria-checked", "false"); + expectUnchecked(root); expect(input.checked).toBe(false); expect(indicator).toHaveTextContent("false"); expect(indicator).not.toHaveTextContent("true"); expect(indicator).not.toHaveTextContent("indeterminate"); await user.click(root); - expect(root).toHaveAttribute("data-state", "checked"); - expect(root).toHaveAttribute("aria-checked", "true"); + expectChecked(root); expect(input.checked).toBe(true); expect(indicator).toHaveTextContent("true"); expect(indicator).not.toHaveTextContent("false"); @@ -78,29 +112,25 @@ describe("checkbox", () => { it("should toggle when the `Space` key is pressed", async () => { const { root, input, user } = setup(); - expect(root).toHaveAttribute("data-state", "unchecked"); - expect(root).toHaveAttribute("aria-checked", "false"); + expectUnchecked(root); expect(input.checked).toBe(false); root.focus(); await user.keyboard(kbd.SPACE); - expect(root).toHaveAttribute("data-state", "checked"); - expect(root).toHaveAttribute("aria-checked", "true"); + expectChecked(root); expect(input.checked).toBe(true); }); it("should not toggle when the `Enter` key is pressed", async () => { const { getByTestId, root, input, user } = setup(); const indicator = getByTestId("indicator"); - expect(root).toHaveAttribute("data-state", "unchecked"); - expect(root).toHaveAttribute("aria-checked", "false"); + expectUnchecked(root); expect(input.checked).toBe(false); expect(indicator).toHaveTextContent("false"); expect(indicator).not.toHaveTextContent("true"); expect(indicator).not.toHaveTextContent("indeterminate"); root.focus(); await user.keyboard(kbd.ENTER); - expect(root).not.toHaveAttribute("data-state", "checked"); - expect(root).not.toHaveAttribute("aria-checked", "true"); + expectUnchecked(root); expect(indicator).toHaveTextContent("false"); expect(indicator).not.toHaveTextContent("true"); expect(indicator).not.toHaveTextContent("indeterminate"); @@ -109,13 +139,11 @@ describe("checkbox", () => { it("should be disabled when the `disabled` prop is passed", async () => { const { root, input, user } = setup({ disabled: true }); - expect(root).toHaveAttribute("data-state", "unchecked"); - expect(root).toHaveAttribute("aria-checked", "false"); + expectUnchecked(root); expect(input.checked).toBe(false); expect(input.disabled).toBe(true); await user.click(root); - expect(root).toHaveAttribute("data-state", "unchecked"); - expect(root).toHaveAttribute("aria-checked", "false"); + expectUnchecked(root); expect(root).toBeDisabled(); expect(input.checked).toBe(false); }); @@ -145,3 +173,116 @@ describe("checkbox", () => { expect(binding).toHaveTextContent("true"); }); }); + +describe("checkbox group", () => { + it("should have no accessibility violations", async () => { + const { container } = render(CheckboxGroupTest); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should have bits data attrs", async () => { + const { group, groupLabel } = setupGroup(); + expect(group).toHaveAttribute("data-checkbox-group"); + expect(groupLabel).toHaveAttribute("data-checkbox-group-label"); + }); + + it("should handle default values appropriately", async () => { + const t = setupGroup({ + value: ["a", "b"], + }); + + const [a, b, c, d] = t.checkboxes; + expectChecked(a, b); + expectUnchecked(c, d); + + await t.user.click(a); + expectUnchecked(a); + await t.user.click(d); + expectChecked(d); + }); + + it("should submit the form data correctly using the checkbox values and group name", async () => { + let submittedValues: string[] | undefined; + const t = setupGroup({ + name: "myGroup", + onFormSubmit: (fd) => { + submittedValues = fd.getAll("myGroup") as string[]; + }, + }); + const [a, b] = t.checkboxes; + await t.user.click(a); + expectChecked(a); + await t.user.click(t.submit); + expect(submittedValues).toEqual(["a"]); + await t.user.click(b); + await t.user.click(t.submit); + expect(submittedValues).toEqual(["a", "b"]); + }); + + it("should handle binding value", async () => { + const t = setupGroup(); + + const [a, b, _, d] = t.checkboxes; + expect(t.binding).toHaveTextContent(""); + await t.user.click(a); + expect(t.binding).toHaveTextContent("a"); + await t.user.click(b); + expect(t.binding).toHaveTextContent("a,b"); + await t.user.click(a); + expect(t.binding).toHaveTextContent("b"); + await t.user.click(d); + expect(t.binding).toHaveTextContent("b,d"); + }); + + it("should handle programmatic value changes", async () => { + const t = setupGroup({ + value: ["a", "b"], + }); + + const [a, b, c, d] = t.checkboxes; + expectChecked(a, b); + await t.user.click(t.updateBtn); + expectUnchecked(a, b); + expectChecked(c, d); + }); + + it("should propagate disabled state to children checkboxes", async () => { + const t = setupGroup({ + disabled: true, + required: true, + }); + + for (const checkbox of t.checkboxes) { + expect(checkbox).toBeDisabled(); + expect(checkbox).toHaveAttribute("aria-required", "true"); + } + }); + + it("should allow disabling a single item in the group", async () => { + const t = setupGroup({ + disabledItems: ["a"], + }); + + const [a, ...rest] = t.checkboxes; + + expect(a).toBeDisabled(); + + for (const checkbox of rest) { + expect(checkbox).not.toBeDisabled(); + } + }); +}); + +function expectChecked(...nodes: HTMLElement[]) { + for (const n of nodes) { + expect(n).toHaveAttribute("data-state", "checked"); + expect(n).toHaveAttribute("aria-checked", "true"); + } +} + +function expectUnchecked(...nodes: HTMLElement[]) { + for (const n of nodes) { + expect(n).toHaveAttribute("data-state", "unchecked"); + expect(n).toHaveAttribute("aria-checked", "false"); + } +} diff --git a/sites/docs/content/components/checkbox.md b/sites/docs/content/components/checkbox.md index 85c60a940..197f9379c 100644 --- a/sites/docs/content/components/checkbox.md +++ b/sites/docs/content/components/checkbox.md @@ -4,7 +4,7 @@ description: Allow users to switch between checked, unchecked, and indeterminate --- @@ -321,4 +321,142 @@ If you want to make the checkbox required, you can use the `required` prop. This will apply the `required` attribute to the hidden input element, ensuring that proper form submission is enforced. +## Checkbox Groups + +You can use the `Checkbox.Group` component to create a checkbox group. + +```svelte + + + + Notifications + + + + +``` + + + +{#snippet preview()} + +{/snippet} + + + +### Managing Value State + +Bits UI offers several approaches to manage and synchronize a Checkbox Group's value state, catering to different levels of control and integration needs. + +#### 1. Two-Way Binding + +For seamless state synchronization, use Svelte's `bind:value` directive. This method automatically keeps your local state in sync with the group's internal state. + +```svelte + + + + + + Items + + + + +``` + +##### Key Benefits + +- Simplifies state management +- Automatically updates `myValue` when the accordion changes (e.g., via clicking on an item's trigger) +- Allows external control (e.g., opening an item via a separate button) + +#### 2. Change Handler + +For more granular control or to perform additional logic on state changes, use the `onValueChange` prop. This approach is useful when you need to execute custom logic alongside state updates. + +```svelte + + + { + myValue = value; + // additional logic here. + }} +> + Items + + + + +``` + +#### Use Cases + +- Implementing custom behaviors on value change +- Integrating with external state management solutions +- Triggering side effects (e.g., logging, data fetching) + +#### 3. Fully Controlled + +For complete control over the Checkbox Group's value state, use the `controlledValue` prop. This approach requires you to manually manage the value state, giving you full control over when and how the group responds to value change events. + +To implement controlled state: + +1. Set the `controlledValue` prop to `true` on the `Checkbox.Group` component. +2. Provide a `value` prop to `Checkbox.Group`, which should be a variable holding the current state. +3. Implement an `onValueChange` handler to update the state when the internal state changes. + +```svelte + + + (myValue = v)}> + + +``` + +##### When to Use + +- Implementing complex logic +- Coordinating multiple UI elements +- Debugging state-related issues + + + +While powerful, fully controlled state should be used judiciously as it increases complexity and can cause unexpected behaviors if not handled carefully. + +For more in-depth information on controlled components and advanced state management techniques, refer to our [Controlled State](/docs/controlled-state) documentation. + + + +### HTML Forms + +To render hidden `` elements for the various checkboxes within a group, pass a `name` to `Checkbox.Group`. All descendent checkboxes will then render hidden inputs with the same name. + +```svelte /name="notifications"/ + + + +``` + +When a `Checkbox.Group` component is used, its descendent `Checkbox.Root` components will use certain properties from the group, such as the `name`, `required`, and `disabled`. + diff --git a/sites/docs/src/lib/components/demos/checkbox-demo-group.svelte b/sites/docs/src/lib/components/demos/checkbox-demo-group.svelte new file mode 100644 index 000000000..c0acafb54 --- /dev/null +++ b/sites/docs/src/lib/components/demos/checkbox-demo-group.svelte @@ -0,0 +1,49 @@ + + + + + Notifications + +
+ {@render MyCheckbox({ label: "Marketing", value: "marketing" })} + {@render MyCheckbox({ label: "Promotions", value: "promotions" })} + {@render MyCheckbox({ label: "News", value: "news" })} + {@render MyCheckbox({ label: "Updates", value: "updates" })} +
+
+ +{#snippet MyCheckbox({ value, label }: { value: string; label: string })} + {@const id = useId()} +
+ + {#snippet children({ checked, indeterminate })} +
+ {#if indeterminate} + + {:else if checked} + + {/if} +
+ {/snippet} +
+ + {label} + +
+{/snippet} diff --git a/sites/docs/src/lib/components/demos/index.ts b/sites/docs/src/lib/components/demos/index.ts index a4805787e..37ff648ac 100644 --- a/sites/docs/src/lib/components/demos/index.ts +++ b/sites/docs/src/lib/components/demos/index.ts @@ -8,6 +8,7 @@ export { default as ButtonDemo } from "./button-demo.svelte"; export { default as CalendarDemo } from "./calendar-demo.svelte"; export { default as CheckboxDemo } from "./checkbox-demo.svelte"; export { default as CheckboxDemoCustom } from "./checkbox-demo-custom.svelte"; +export { default as CheckboxDemoGroup } from "./checkbox-demo-group.svelte"; export { default as CollapsibleDemo } from "./collapsible-demo.svelte"; export { default as CollapsibleDemoTransitions } from "./collapsible-demo-transitions.svelte"; export { default as ComboboxDemo } from "./combobox-demo.svelte"; diff --git a/sites/docs/src/lib/content/api-reference/checkbox.api.ts b/sites/docs/src/lib/content/api-reference/checkbox.api.ts index 94f17481b..bdc963de3 100644 --- a/sites/docs/src/lib/content/api-reference/checkbox.api.ts +++ b/sites/docs/src/lib/content/api-reference/checkbox.api.ts @@ -1,16 +1,23 @@ -import type { CheckboxRootPropsWithoutHTML } from "bits-ui"; +import type { + CheckboxGroupLabelPropsWithoutHTML, + CheckboxGroupPropsWithoutHTML, + CheckboxRootPropsWithoutHTML, +} from "bits-ui"; import { controlledCheckedProp, controlledIndeterminateProp, + controlledValueProp, createApiSchema, createBooleanProp, createDataAttrSchema, createEnumDataAttr, createFunctionProp, + createPropSchema, createStringProp, withChildProps, } from "./helpers.js"; import { + CheckboxGroupOnValueChangeProp, CheckboxRootChildSnippetProps, CheckboxRootChildrenSnippetProps, CheckboxRootOnCheckedChangeProp, @@ -86,4 +93,67 @@ export const root = createApiSchema({ ], }); -export const checkbox = [root]; +export const group = createApiSchema({ + title: "Group", + description: "A group that synchronizes its value state with its descendant checkboxes.", + props: { + value: createPropSchema({ + description: + "The value of the group. This is an array of the values of the checked checkboxes within the group.", + bindable: true, + default: "[]", + type: "string[]", + }), + onValueChange: createFunctionProp({ + definition: CheckboxGroupOnValueChangeProp, + description: "A callback that is fired when the checkbox group's value state changes.", + }), + controlledValue: controlledValueProp, + disabled: createBooleanProp({ + default: C.FALSE, + description: + "Whether or not the checkbox group is disabled. If `true`, all checkboxes within the group will be disabled. To disable a specific checkbox in the group, pass the `disabled` prop to the checkbox.", + }), + required: createBooleanProp({ + default: C.FALSE, + description: "Whether or not the checkbox group is required for form submission.", + }), + name: createStringProp({ + description: + "The name of the checkbox group. If provided a hidden input will be rendered to use for form submission.", + }), + ...withChildProps({ + elType: "HTMLDivElement", + }), + }, + dataAttributes: [ + createDataAttrSchema({ + name: "disabled", + description: "Present when the checkbox group is disabled.", + }), + createDataAttrSchema({ + name: "checkbox-group", + description: "Present on the group element.", + }), + ], +}); + +export const groupLabel = createApiSchema({ + title: "GroupLabel", + description: "An accessible label for the checkbox group.", + props: withChildProps({ + elType: "HTMLLabelElement", + }), + dataAttributes: [ + createDataAttrSchema({ + name: "disabled", + description: "Present when the checkbox group is disabled.", + }), + createDataAttrSchema({ + name: "checkbox-group-label", + description: "Present on the label element.", + }), + ], +}); + +export const checkbox = [root, group, groupLabel]; diff --git a/sites/docs/src/lib/content/api-reference/extended-types/checkbox/checkbox-group-on-value-change-prop.md b/sites/docs/src/lib/content/api-reference/extended-types/checkbox/checkbox-group-on-value-change-prop.md new file mode 100644 index 000000000..6abe12867 --- /dev/null +++ b/sites/docs/src/lib/content/api-reference/extended-types/checkbox/checkbox-group-on-value-change-prop.md @@ -0,0 +1,3 @@ +```ts +(value: string[]) => void; +``` diff --git a/sites/docs/src/lib/content/api-reference/extended-types/checkbox/index.ts b/sites/docs/src/lib/content/api-reference/extended-types/checkbox/index.ts index 08ded0c7e..84eda16d8 100644 --- a/sites/docs/src/lib/content/api-reference/extended-types/checkbox/index.ts +++ b/sites/docs/src/lib/content/api-reference/extended-types/checkbox/index.ts @@ -3,3 +3,4 @@ export { default as CheckboxRootStateDataAttr } from "./checkbox-root-state-data export { default as CheckboxRootOnIndeterminateChangeProp } from "./checkbox-root-on-indeterminate-change.md"; export { default as CheckboxRootChildSnippetProps } from "./checkbox-root-child-snippet-props.md"; export { default as CheckboxRootChildrenSnippetProps } from "./checkbox-root-children-snippet-props.md"; +export { default as CheckboxGroupOnValueChangeProp } from "./checkbox-group-on-value-change-prop.md";