>,
+) {
+ let ref: HTMLElement | undefined;
+
+ const defaultId = `checkboxgroup-${createUniqueId()}`;
+
+ const mergedProps = mergeDefaultProps(
+ {
+ id: defaultId,
+ orientation: "vertical",
+ },
+ props as CheckboxGroupRootProps,
+ );
+
+ const [local, formControlProps, others] = splitProps(
+ mergedProps,
+ [
+ "ref",
+ "values",
+ "defaultValues",
+ "onChange",
+ "orientation",
+ "aria-labelledby",
+ "aria-describedby",
+ ],
+ FORM_CONTROL_PROP_NAMES,
+ );
+
+ const [selectedValues, setSelectedValues] = createControllableSignal<
+ string[]
+ >({
+ value: () => local.values,
+ defaultValue: () => local.defaultValues,
+ onChange: (value) => {
+ local.onChange?.(value);
+ },
+ });
+
+ const { formControlContext } = createFormControl(formControlProps);
+
+ createFormResetListener(
+ () => ref,
+ () => setSelectedValues(local.defaultValues ?? []),
+ );
+
+ const ariaLabelledBy = () => {
+ return formControlContext.getAriaLabelledBy(
+ access(formControlProps.id),
+ others["aria-label"],
+ local["aria-labelledby"],
+ );
+ };
+
+ const ariaDescribedBy = () => {
+ return formControlContext.getAriaDescribedBy(local["aria-describedby"]);
+ };
+
+ const isValueSelected = (value: string) => {
+ return selectedValues()?.includes(value) || false;
+ };
+
+ const context: CheckboxGroupContextValue = {
+ ariaDescribedBy,
+ isValueSelected,
+ generateId: createGenerateId(() => access(formControlProps.id)!),
+ handleValue: (value) => {
+ if (formControlContext.isReadOnly() || formControlContext.isDisabled()) {
+ return;
+ }
+
+ const selectedCheckboxesValues = selectedValues() || [];
+
+ if (isValueSelected(value)) {
+ setSelectedValues(
+ selectedCheckboxesValues.filter((val) => val !== value),
+ );
+ } else setSelectedValues([...selectedCheckboxesValues, value]);
+
+ // Sync all checkbox inputs' checked state in the group with the selectedValues values.
+ // This ensures the checked state is in sync (e.g., when using a controlled checkbox group).
+ if (ref) {
+ for (const el of ref.querySelectorAll("[type='checkbox']")) {
+ const checkbox = el as HTMLInputElement;
+ checkbox.checked = selectedCheckboxesValues.includes(checkbox.value);
+ }
+ }
+ },
+ };
+
+ return (
+
+
+
+ as="div"
+ ref={mergeRefs((el) => (ref = el), local.ref)}
+ role="group"
+ id={access(formControlProps.id)!}
+ aria-invalid={
+ formControlContext.validationState() === "invalid" || undefined
+ }
+ aria-required={formControlContext.isRequired() || undefined}
+ aria-disabled={formControlContext.isDisabled() || undefined}
+ aria-readonly={formControlContext.isReadOnly() || undefined}
+ aria-orientation={local.orientation}
+ aria-labelledby={ariaLabelledBy()}
+ aria-describedby={ariaDescribedBy()}
+ {...formControlContext.dataset()}
+ {...others}
+ />
+
+
+ );
+}
diff --git a/packages/core/src/checkbox-group/checkbox-group.test.tsx b/packages/core/src/checkbox-group/checkbox-group.test.tsx
new file mode 100644
index 000000000..15881e330
--- /dev/null
+++ b/packages/core/src/checkbox-group/checkbox-group.test.tsx
@@ -0,0 +1,1600 @@
+/*
+ * Portions of this file are based on code from react-spectrum.
+ * Apache License Version 2.0, Copyright 2020 Adobe.
+ *
+ * Credits to the React Spectrum team:
+ * https://github.com/adobe/react-spectrum/blob/810579b671791f1593108f62cdc1893de3a220e3/packages/@react-spectrum/checkbox/test/Checkbox.test.js
+ */
+
+import { installPointerEvent } from "@kobalte/tests";
+import { fireEvent, render } from "@solidjs/testing-library";
+import { vi } from "vitest";
+
+import * as CheckboxGroup from ".";
+
+describe("CheckboxGroup", () => {
+ installPointerEvent();
+
+ it("handles defaults", async () => {
+ const onChangeSpy = vi.fn();
+
+ const { getByRole, getAllByRole, getByLabelText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(checkboxGroup).toBeInTheDocument();
+ expect(inputs.length).toBe(3);
+
+ expect(inputs[0].value).toBe("dogs");
+ expect(inputs[1].value).toBe("cats");
+ expect(inputs[2].value).toBe("dragons");
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeFalsy();
+ expect(inputs[2].checked).toBeFalsy();
+
+ const dragons = getByLabelText("Dragons");
+
+ fireEvent.click(dragons);
+ await Promise.resolve();
+
+ expect(onChangeSpy).toHaveBeenCalledTimes(1);
+ expect(onChangeSpy).toHaveBeenCalledWith(["dragons"]);
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeFalsy();
+ expect(inputs[2].checked).toBeTruthy();
+ });
+
+ it("can have a default value", async () => {
+ const onChangeSpy = vi.fn();
+
+ const { getByRole, getAllByRole, getByLabelText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(checkboxGroup).toBeTruthy();
+ expect(inputs.length).toBe(3);
+ expect(onChangeSpy).not.toHaveBeenCalled();
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeTruthy();
+ expect(inputs[2].checked).toBeFalsy();
+
+ const dragons = getByLabelText("Dragons");
+
+ fireEvent.click(dragons);
+ await Promise.resolve();
+
+ expect(onChangeSpy).toHaveBeenCalledTimes(1);
+ expect(onChangeSpy).toHaveBeenCalledWith(["cats", "dragons"]);
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeTruthy();
+ expect(inputs[2].checked).toBeTruthy();
+ });
+
+ it("value can be controlled", async () => {
+ const onChangeSpy = vi.fn();
+ const { getAllByRole, getByLabelText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeTruthy();
+ expect(inputs[2].checked).toBeFalsy();
+
+ const dragons = getByLabelText("Dragons");
+
+ fireEvent.click(dragons);
+ await Promise.resolve();
+
+ expect(onChangeSpy).toHaveBeenCalledTimes(1);
+ expect(onChangeSpy).toHaveBeenCalledWith(["cats", "dragons"]);
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeTruthy();
+
+ // false because `value` is controlled.
+ expect(inputs[2].checked).toBeFalsy();
+ });
+
+ // FIXME:
+ it("can select value by clicking on the item control", async () => {
+ const onChangeSpy = vi.fn();
+
+ const { getByRole, getAllByRole, getByTestId } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(checkboxGroup).toBeTruthy();
+ expect(inputs.length).toBe(3);
+ expect(onChangeSpy).not.toHaveBeenCalled();
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeFalsy();
+ expect(inputs[2].checked).toBeFalsy();
+
+ const dragonsControl = getByTestId("dragons-control");
+ expect(inputs[2].checked).toBeFalsy();
+
+ fireEvent.click(dragonsControl);
+ await Promise.resolve();
+
+ expect(onChangeSpy).toHaveBeenCalledTimes(1);
+ expect(onChangeSpy).toHaveBeenCalledWith(["dragons"]);
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeFalsy();
+ expect(inputs[2].checked).toBeTruthy();
+ });
+
+ // FIXME:
+ it("can select value by pressing the Space key on the item control", async () => {
+ const onChangeSpy = vi.fn();
+
+ const { getByRole, getAllByRole, getByTestId } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(checkboxGroup).toBeTruthy();
+ expect(inputs.length).toBe(3);
+ expect(onChangeSpy).not.toHaveBeenCalled();
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeFalsy();
+ expect(inputs[2].checked).toBeFalsy();
+
+ const dragonsControl = getByTestId("dragons-control");
+
+ fireEvent.keyDown(dragonsControl, { key: " " });
+ fireEvent.keyUp(dragonsControl, { key: " " });
+ await Promise.resolve();
+
+ expect(onChangeSpy).toHaveBeenCalledTimes(1);
+ expect(onChangeSpy).toHaveBeenCalledWith(["dragons"]);
+
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeFalsy();
+ expect(inputs[2].checked).toBeTruthy();
+ });
+
+ it("name can be controlled", () => {
+ const { getAllByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(inputs[0]).toHaveAttribute("name", "test-name");
+ expect(inputs[1]).toHaveAttribute("name", "test-name");
+ expect(inputs[2]).toHaveAttribute("name", "test-name");
+ });
+
+ it("supports visible label", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const label = getByText("Favorite Pets");
+
+ expect(checkboxGroup).toHaveAttribute("aria-labelledby", label.id);
+ expect(label).toBeInstanceOf(HTMLSpanElement);
+ expect(label).not.toHaveAttribute("for");
+ });
+
+ it("supports 'aria-labelledby'", () => {
+ const { getByRole } = render(() => (
+
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-labelledby", "foo");
+ });
+
+ it("should combine 'aria-labelledby' if visible label is also provided", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const label = getByText("Favorite Pets");
+
+ expect(checkboxGroup).toHaveAttribute("aria-labelledby", `foo ${label.id}`);
+ });
+
+ it("supports 'aria-label'", () => {
+ const { getByRole } = render(() => (
+
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-label", "My Favorite Pets");
+ });
+
+ it("should combine 'aria-labelledby' if visible label and 'aria-label' is also provided", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const label = getByText("Favorite Pets");
+
+ expect(checkboxGroup).toHaveAttribute(
+ "aria-labelledby",
+ `foo ${label.id} ${checkboxGroup.id}`,
+ );
+ });
+
+ it("supports visible description", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+ Description
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const description = getByText("Description");
+
+ expect(description.id).toBeDefined();
+ expect(checkboxGroup.id).toBeDefined();
+ expect(checkboxGroup).toHaveAttribute("aria-describedby", description.id);
+
+ // check that generated ids are unique
+ expect(description.id).not.toBe(checkboxGroup.id);
+ });
+
+ it("supports visible description on single checkbox", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+ Description
+
+
+
+
+ ));
+
+ const checkbox = getByRole("checkbox") as HTMLInputElement;
+ const itemDescription = getByText("Description");
+
+ expect(itemDescription.id).toBeDefined();
+ expect(checkbox.id).toBeDefined();
+ expect(checkbox).toHaveAttribute("aria-describedby", itemDescription.id);
+ });
+
+ it("supports 'aria-describedby'", () => {
+ const { getByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-describedby", "foo");
+ });
+
+ it("should combine 'aria-describedby' if visible description", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+ Description
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const description = getByText("Description");
+
+ expect(checkboxGroup).toHaveAttribute(
+ "aria-describedby",
+ `${description.id} foo`,
+ );
+ });
+
+ it("supports visible error message when invalid", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+ ErrorMessage
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const errorMessage = getByText("ErrorMessage");
+
+ expect(errorMessage.id).toBeDefined();
+ expect(checkboxGroup.id).toBeDefined();
+ expect(checkboxGroup).toHaveAttribute("aria-describedby", errorMessage.id);
+
+ // check that generated ids are unique
+ expect(errorMessage.id).not.toBe(checkboxGroup.id);
+ });
+
+ it("should not be described by error message when not invalid", () => {
+ const { getByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+ ErrorMessage
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).not.toHaveAttribute("aria-describedby");
+ });
+
+ it("should combine 'aria-describedby' if visible error message when invalid", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+ ErrorMessage
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const errorMessage = getByText("ErrorMessage");
+
+ expect(checkboxGroup).toHaveAttribute(
+ "aria-describedby",
+ `${errorMessage.id} foo`,
+ );
+ });
+
+ it("should combine 'aria-describedby' if visible description and error message when invalid", () => {
+ const { getByRole, getByText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+ Description
+ ErrorMessage
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+ const description = getByText("Description");
+ const errorMessage = getByText("ErrorMessage");
+
+ expect(checkboxGroup).toHaveAttribute(
+ "aria-describedby",
+ `${description.id} ${errorMessage.id} foo`,
+ );
+ });
+
+ it("should not have form control 'data-*' attributes by default", async () => {
+ const { getByRole } = render(() => (
+
+
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).not.toHaveAttribute("data-valid");
+ expect(checkboxGroup).not.toHaveAttribute("data-invalid");
+ expect(checkboxGroup).not.toHaveAttribute("data-required");
+ expect(checkboxGroup).not.toHaveAttribute("data-disabled");
+ expect(checkboxGroup).not.toHaveAttribute("data-readonly");
+ });
+
+ it("should have 'data-valid' attribute when valid", async () => {
+ const { getByRole } = render(() => (
+
+
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("data-valid");
+ });
+
+ it("should have 'data-invalid' attribute when invalid", async () => {
+ const { getByRole } = render(() => (
+
+
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("data-invalid");
+ });
+
+ it("should have 'data-required' attribute when required", async () => {
+ const { getByRole } = render(() => (
+
+
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("data-required");
+ });
+
+ it("should have 'data-disabled' attribute when disabled", async () => {
+ const { getByRole } = render(() => (
+
+
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("data-disabled");
+ });
+
+ it("should have 'data-readonly' attribute when readonly", async () => {
+ const { getByRole } = render(() => (
+
+
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("data-readonly");
+ });
+
+ it("sets 'aria-orientation' by default", () => {
+ const { getByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-orientation", "vertical");
+ });
+
+ it("sets 'aria-orientation' based on the 'orientation' prop", () => {
+ const { getByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-orientation", "horizontal");
+ });
+
+ it("sets 'aria-invalid' when 'validationState=invalid'", () => {
+ const { getByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-invalid", "true");
+ });
+
+ it("passes through 'aria-errormessage'", () => {
+ const { getByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-invalid", "true");
+ expect(checkboxGroup).toHaveAttribute("aria-errormessage", "test");
+ });
+
+ it("sets 'aria-required' when 'isRequired' is true", () => {
+ const { getByRole, getAllByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-required", "true");
+
+ const inputs = getAllByRole("checkbox");
+
+ for (const input of inputs) {
+ expect(input).toHaveAttribute("aria-required");
+ }
+ });
+
+ it("sets 'aria-disabled' and makes checkboxs disabled when 'isDisabled' is true", async () => {
+ const groupOnChangeSpy = vi.fn();
+
+ const { getByRole, getAllByRole, getByLabelText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).toHaveAttribute("aria-disabled", "true");
+
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(inputs[0]).toHaveAttribute("disabled");
+ expect(inputs[1]).toHaveAttribute("disabled");
+ expect(inputs[2]).toHaveAttribute("disabled");
+
+ const dragons = getByLabelText("Dragons");
+
+ fireEvent.click(dragons);
+ await Promise.resolve();
+
+ expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
+ expect(inputs[2].checked).toBeFalsy();
+ });
+
+ it("can have a single disabled checkbox", async () => {
+ const groupOnChangeSpy = vi.fn();
+
+ const { getByText, getAllByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(inputs[0]).not.toHaveAttribute("disabled");
+ expect(inputs[1]).toHaveAttribute("disabled");
+ expect(inputs[2]).not.toHaveAttribute("disabled");
+
+ const dogsLabel = getByText("Dogs") as HTMLLabelElement;
+ const catsLabel = getByText("Cats") as HTMLLabelElement;
+
+ fireEvent.click(catsLabel);
+ await Promise.resolve();
+
+ expect(inputs[1].checked).toBeFalsy();
+
+ expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
+ expect(inputs[0].checked).toBeFalsy();
+ expect(inputs[1].checked).toBeFalsy();
+ expect(inputs[2].checked).toBeFalsy();
+
+ fireEvent.click(dogsLabel);
+ await Promise.resolve();
+
+ expect(groupOnChangeSpy).toHaveBeenCalledTimes(1);
+ expect(groupOnChangeSpy).toHaveBeenCalledWith(["dogs"]);
+ expect(inputs[0].checked).toBeTruthy();
+ expect(inputs[1].checked).toBeFalsy();
+ expect(inputs[2].checked).toBeFalsy();
+ });
+
+ it("doesn't set 'aria-disabled' or make checkboxs disabled by default", () => {
+ const { getByRole, getAllByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).not.toHaveAttribute("aria-disabled");
+
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(inputs[0]).not.toHaveAttribute("disabled");
+ expect(inputs[1]).not.toHaveAttribute("disabled");
+ expect(inputs[2]).not.toHaveAttribute("disabled");
+ });
+
+ it("doesn't set 'aria-disabled' or make checkboxs disabled when 'isDisabled' is false", () => {
+ const { getByRole, getAllByRole } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ expect(checkboxGroup).not.toHaveAttribute("aria-disabled");
+
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(inputs[0]).not.toHaveAttribute("disabled");
+ expect(inputs[1]).not.toHaveAttribute("disabled");
+ expect(inputs[2]).not.toHaveAttribute("disabled");
+ });
+
+ it("sets 'aria-readonly=true' on checkbox group", async () => {
+ const groupOnChangeSpy = vi.fn();
+ const { getByRole, getAllByRole, getByLabelText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const checkboxGroup = getByRole("group");
+
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+
+ expect(checkboxGroup).toHaveAttribute("aria-readonly", "true");
+ expect(inputs[2].checked).toBeFalsy();
+
+ const dragons = getByLabelText("Dragons");
+
+ fireEvent.click(dragons);
+ await Promise.resolve();
+
+ expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
+ expect(inputs[2].checked).toBeFalsy();
+ });
+
+ it("should not update state for readonly checkbox group", async () => {
+ const groupOnChangeSpy = vi.fn();
+
+ const { getAllByRole, getByLabelText } = render(() => (
+
+ Favorite Pets
+
+
+
+
+ Dogs
+
+
+
+
+ Cats
+
+
+
+
+ Dragons
+
+
+
+ ));
+
+ const inputs = getAllByRole("checkbox") as HTMLInputElement[];
+ const dragons = getByLabelText("Dragons");
+
+ fireEvent.click(dragons);
+ await Promise.resolve();
+
+ expect(groupOnChangeSpy).toHaveBeenCalledTimes(0);
+ expect(inputs[2].checked).toBeFalsy();
+ });
+
+ describe("Checkbox", () => {
+ it("should generate default ids", () => {
+ const { getByTestId } = render(() => (
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const checkbox = getByTestId("checkbox");
+ const input = getByTestId("input");
+ const control = getByTestId("control");
+ const label = getByTestId("label");
+
+ expect(checkbox.id).toBeDefined();
+ expect(input.id).toBe(`${checkbox.id}-input`);
+ expect(control.id).toBe(`${checkbox.id}-control`);
+ expect(label.id).toBe(`${checkbox.id}-label`);
+ });
+
+ it("should generate ids based on checkbox id", () => {
+ const { getByTestId } = render(() => (
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const checkbox = getByTestId("checkbox");
+ const input = getByTestId("input");
+ const control = getByTestId("control");
+ const label = getByTestId("label");
+
+ expect(checkbox.id).toBe("foo");
+ expect(input.id).toBe("foo-input");
+ expect(control.id).toBe("foo-control");
+ expect(label.id).toBe("foo-label");
+ });
+
+ it("supports custom ids", () => {
+ const { getByTestId } = render(() => (
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const checkbox = getByTestId("checkbox");
+ const input = getByTestId("input");
+ const control = getByTestId("control");
+ const label = getByTestId("label");
+
+ expect(checkbox.id).toBe("custom-checkbox-id");
+ expect(input.id).toBe("custom-input-id");
+ expect(control.id).toBe("custom-control-id");
+ expect(label.id).toBe("custom-label-id");
+ });
+
+ it("supports 'aria-label'", () => {
+ const { getByRole } = render(() => (
+
+
+
+
+ Cats
+
+
+ ));
+
+ const checkbox = getByRole("checkbox") as HTMLInputElement;
+
+ expect(checkbox).toHaveAttribute("aria-label", "Label");
+ });
+
+ it("supports 'aria-labelledby'", () => {
+ const { getByRole, getByTestId } = render(() => (
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const checkboxLabel = getByTestId("checkbox-label");
+ const checkbox = getByRole("checkbox") as HTMLInputElement;
+
+ expect(checkbox).toHaveAttribute(
+ "aria-labelledby",
+ `foo ${checkboxLabel.id}`,
+ );
+ });
+
+ it("should combine 'aria-label' and 'aria-labelledby'", () => {
+ const { getByRole, getByTestId } = render(() => (
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const checkboxLabel = getByTestId("checkbox-label");
+ const checkbox = getByRole("checkbox") as HTMLInputElement;
+
+ expect(checkbox).toHaveAttribute(
+ "aria-labelledby",
+ `foo ${checkboxLabel.id} ${checkbox.id}`,
+ );
+ });
+
+ it("supports 'aria-describedby'", () => {
+ const { getByRole } = render(() => (
+
+
+
+
+ Cats
+
+
+ ));
+
+ const checkbox = getByRole("checkbox") as HTMLInputElement;
+
+ expect(checkbox).toHaveAttribute("aria-describedby", "foo");
+ });
+
+ it("should combine 'aria-describedby' from both checkbox and checkbox group", () => {
+ const { getByRole, getByTestId } = render(() => (
+
+
+
+
+ Cats
+
+
+ Description
+
+
+ ));
+
+ const checkbox = getByRole("checkbox") as HTMLInputElement;
+ const description = getByTestId("description");
+
+ expect(checkbox).toHaveAttribute(
+ "aria-describedby",
+ `foo ${description.id}`,
+ );
+ });
+
+ describe("indicator", () => {
+ it("should not display indicator by default", async () => {
+ const { queryByTestId } = render(() => (
+
+
+
+
+
+
+
+
+ ));
+
+ expect(queryByTestId("indicator")).toBeNull();
+ });
+
+ it("should display indicator when 'selected'", async () => {
+ const { getByRole, queryByTestId, getByTestId } = render(() => (
+
+
+
+
+
+
+
+
+ ));
+
+ const input = getByRole(
+ "checkbox",
+ ) as HTMLInputElement as HTMLInputElement;
+
+ expect(input.checked).toBeFalsy();
+ expect(queryByTestId("indicator")).toBeNull();
+
+ fireEvent.click(input);
+ await Promise.resolve();
+
+ expect(input.checked).toBeTruthy();
+ expect(getByTestId("indicator")).toBeInTheDocument();
+ });
+
+ it("should display indicator when 'forceMount'", async () => {
+ const { getByTestId } = render(() => (
+
+
+
+
+
+
+
+
+ ));
+
+ expect(getByTestId("indicator")).toBeInTheDocument();
+ });
+ });
+
+ describe("data-attributes", () => {
+ it("should have 'data-valid' attribute on checkbox elements when checkbox group is valid", async () => {
+ const { getAllByTestId } = render(() => (
+
+
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const elements = getAllByTestId(/^checkbox/);
+
+ for (const el of elements) {
+ expect(el).toHaveAttribute("data-valid");
+ }
+ });
+
+ it("should have 'data-invalid' attribute on checkboxs when checkbox group is invalid", async () => {
+ const { getAllByTestId } = render(() => (
+
+
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const elements = getAllByTestId(/^checkbox/);
+
+ for (const el of elements) {
+ expect(el).toHaveAttribute("data-invalid");
+ }
+ });
+
+ it("should have 'data-required' attribute on checkboxs when checkbox group is required", async () => {
+ const { getAllByTestId } = render(() => (
+
+
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const elements = getAllByTestId(/^checkbox/);
+
+ for (const el of elements) {
+ expect(el).toHaveAttribute("data-required");
+ }
+ });
+
+ it("should have 'data-readonly' attribute on checkboxs when checkbox group is readonly", async () => {
+ const { getAllByTestId } = render(() => (
+
+
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const elements = getAllByTestId(/^checkbox/);
+
+ for (const el of elements) {
+ expect(el).toHaveAttribute("data-readonly");
+ }
+ });
+
+ it("should have 'data-disabled' attribute on checkboxs when checkbox group is disabled", async () => {
+ const { getAllByTestId } = render(() => (
+
+
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const elements = getAllByTestId(/^checkbox/);
+
+ for (const el of elements) {
+ expect(el).toHaveAttribute("data-disabled");
+ }
+ });
+
+ it("should have 'data-disabled' attribute on single disabled checkbox", async () => {
+ const { getAllByTestId } = render(() => (
+
+
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const elements = getAllByTestId(/^checkbox/);
+
+ for (const el of elements) {
+ expect(el).toHaveAttribute("data-disabled");
+ }
+ });
+
+ it("should have 'data-checked' attribute on checked checkbox", async () => {
+ const { getAllByTestId } = render(() => (
+
+
+
+
+
+
+
+ Cats
+
+
+
+ ));
+
+ const elements = getAllByTestId(/^checkbox/);
+
+ for (const el of elements) {
+ expect(el).toHaveAttribute("data-checked");
+ }
+ });
+ });
+ });
+});
diff --git a/packages/core/src/checkbox-group/index.tsx b/packages/core/src/checkbox-group/index.tsx
new file mode 100644
index 000000000..5a555ce37
--- /dev/null
+++ b/packages/core/src/checkbox-group/index.tsx
@@ -0,0 +1,137 @@
+import {
+ type CheckboxControlCommonProps as CheckboxGroupItemControlCommonProps,
+ type CheckboxControlOptions as CheckboxGroupItemControlOptions,
+ type CheckboxControlProps as CheckboxGroupItemControlProps,
+ type CheckboxControlRenderProps as CheckboxGroupItemControlRenderProps,
+ CheckboxControl as ItemControl,
+} from "../checkbox/checkbox-control";
+import {
+ type CheckboxDescriptionCommonProps as CheckboxGroupItemDescriptionCommonProps,
+ type CheckboxDescriptionOptions as CheckboxGroupItemDescriptionOptions,
+ type CheckboxDescriptionProps as CheckboxGroupItemDescriptionProps,
+ type CheckboxDescriptionRenderProps as CheckboxGroupItemDescriptionRenderProps,
+ CheckboxDescription as ItemDescription,
+} from "../checkbox/checkbox-description";
+import {
+ type CheckboxIndicatorCommonProps as CheckboxGroupItemIndicatorCommonProps,
+ type CheckboxIndicatorOptions as CheckboxGroupItemIndicatorOptions,
+ type CheckboxIndicatorProps as CheckboxGroupItemIndicatorProps,
+ type CheckboxIndicatorRenderProps as CheckboxGroupItemIndicatorRenderProps,
+ CheckboxIndicator as ItemIndicator,
+} from "../checkbox/checkbox-indicator";
+import {
+ type CheckboxLabelCommonProps as CheckboxGroupItemLabelCommonProps,
+ type CheckboxLabelOptions as CheckboxGroupItemLabelOptions,
+ type CheckboxLabelProps as CheckboxGroupItemLabelProps,
+ type CheckboxLabelRenderProps as CheckboxGroupItemLabelRenderProps,
+ CheckboxLabel as ItemLabel,
+} from "../checkbox/checkbox-label";
+import {
+ type FormControlDescriptionCommonProps as CheckboxGroupDescriptionCommonProps,
+ type FormControlDescriptionOptions as CheckboxGroupDescriptionOptions,
+ type FormControlDescriptionProps as CheckboxGroupDescriptionProps,
+ type FormControlDescriptionRenderProps as CheckboxGroupDescriptionRenderProps,
+ type FormControlErrorMessageCommonProps as CheckboxGroupErrorMessageCommonProps,
+ type FormControlErrorMessageOptions as CheckboxGroupErrorMessageOptions,
+ type FormControlErrorMessageProps as CheckboxGroupErrorMessageProps,
+ type FormControlErrorMessageRenderProps as CheckboxGroupErrorMessageRenderProps,
+ FormControlDescription as Description,
+ FormControlErrorMessage as ErrorMessage,
+} from "../form-control";
+import {
+ type CheckboxGroupItemCommonProps,
+ type CheckboxGroupItemOptions,
+ type CheckboxGroupItemProps,
+ type CheckboxGroupItemRenderProps,
+ CheckboxGroupItem as Item,
+} from "./checkbox-group-item";
+import {
+ type CheckboxGroupItemInputCommonProps,
+ type CheckboxGroupItemInputOptions,
+ type CheckboxGroupItemInputProps,
+ type CheckboxGroupItemInputRenderProps,
+ CheckboxGroupItemInput as ItemInput,
+} from "./checkbox-group-item-input";
+
+import {
+ type CheckboxGroupLabelCommonProps,
+ type CheckboxGroupLabelOptions,
+ type CheckboxGroupLabelProps,
+ type CheckboxGroupLabelRenderProps,
+ CheckboxGroupLabel as Label,
+} from "./checkbox-group-label";
+import {
+ type CheckboxGroupRootCommonProps,
+ type CheckboxGroupRootOptions,
+ type CheckboxGroupRootProps,
+ type CheckboxGroupRootRenderProps,
+ CheckboxGroupRoot as Root,
+} from "./checkbox-group-root";
+
+export type {
+ CheckboxGroupDescriptionOptions,
+ CheckboxGroupDescriptionCommonProps,
+ CheckboxGroupDescriptionRenderProps,
+ CheckboxGroupDescriptionProps,
+ CheckboxGroupErrorMessageOptions,
+ CheckboxGroupErrorMessageCommonProps,
+ CheckboxGroupErrorMessageRenderProps,
+ CheckboxGroupErrorMessageProps,
+ CheckboxGroupItemControlOptions,
+ CheckboxGroupItemControlCommonProps,
+ CheckboxGroupItemControlRenderProps,
+ CheckboxGroupItemControlProps,
+ CheckboxGroupItemDescriptionOptions,
+ CheckboxGroupItemDescriptionCommonProps,
+ CheckboxGroupItemDescriptionRenderProps,
+ CheckboxGroupItemDescriptionProps,
+ CheckboxGroupItemIndicatorOptions,
+ CheckboxGroupItemIndicatorCommonProps,
+ CheckboxGroupItemIndicatorRenderProps,
+ CheckboxGroupItemIndicatorProps,
+ CheckboxGroupItemInputOptions,
+ CheckboxGroupItemInputCommonProps,
+ CheckboxGroupItemInputRenderProps,
+ CheckboxGroupItemInputProps,
+ CheckboxGroupItemLabelOptions,
+ CheckboxGroupItemLabelCommonProps,
+ CheckboxGroupItemLabelRenderProps,
+ CheckboxGroupItemLabelProps,
+ CheckboxGroupItemOptions,
+ CheckboxGroupItemCommonProps,
+ CheckboxGroupItemRenderProps,
+ CheckboxGroupItemProps,
+ CheckboxGroupLabelOptions,
+ CheckboxGroupLabelCommonProps,
+ CheckboxGroupLabelRenderProps,
+ CheckboxGroupLabelProps,
+ CheckboxGroupRootOptions,
+ CheckboxGroupRootCommonProps,
+ CheckboxGroupRootRenderProps,
+ CheckboxGroupRootProps,
+};
+
+export {
+ Description,
+ ErrorMessage,
+ Item,
+ ItemControl,
+ ItemDescription,
+ ItemIndicator,
+ ItemInput,
+ ItemLabel,
+ Label,
+ Root,
+};
+
+export const CheckboxGroup = Object.assign(Root, {
+ Description,
+ ErrorMessage,
+ Item,
+ ItemControl,
+ ItemDescription,
+ ItemIndicator,
+ ItemInput,
+ ItemLabel,
+ Label,
+});
diff --git a/packages/core/src/checkbox/checkbox-root.tsx b/packages/core/src/checkbox/checkbox-root.tsx
index eeb8d17ba..097060e14 100644
--- a/packages/core/src/checkbox/checkbox-root.tsx
+++ b/packages/core/src/checkbox/checkbox-root.tsx
@@ -45,7 +45,7 @@ import {
type CheckboxDataSet,
} from "./checkbox-context";
-interface CheckboxRootState {
+export interface CheckboxRootState {
/** Whether the checkbox is checked or not. */
checked: Accessor;
@@ -102,12 +102,15 @@ export interface CheckboxRootOptions {
* Can be a `JSX.Element` or a _render prop_ for having access to the internal state.
*/
children?: JSX.Element | ((state: CheckboxRootState) => JSX.Element);
+
+ noRole?: boolean;
}
export interface CheckboxRootCommonProps {
id: string;
ref: T | ((el: T) => void);
onPointerDown: JSX.EventHandlerUnion;
+ role: string | undefined;
}
export interface CheckboxRootRenderProps
@@ -115,7 +118,6 @@ export interface CheckboxRootRenderProps
FormControlDataSet,
CheckboxDataSet {
children: JSX.Element;
- role: "group";
}
export type CheckboxRootProps<
@@ -136,6 +138,7 @@ export function CheckboxRoot(
{
value: "on",
id: defaultId,
+ noRole: false,
},
props as CheckboxRootProps,
);
@@ -151,6 +154,7 @@ export function CheckboxRoot(
"indeterminate",
"onChange",
"onPointerDown",
+ "noRole",
],
FORM_CONTROL_PROP_NAMES,
);
@@ -208,7 +212,7 @@ export function CheckboxRoot(
as="div"
ref={mergeRefs((el) => (ref = el), local.ref)}
- role="group"
+ role={local.noRole ? undefined : "group"}
id={access(formControlProps.id)}
onPointerDown={onPointerDown}
{...formControlContext.dataset()}
diff --git a/packages/core/src/checkbox/index.tsx b/packages/core/src/checkbox/index.tsx
index 59effd3f8..f55377369 100644
--- a/packages/core/src/checkbox/index.tsx
+++ b/packages/core/src/checkbox/index.tsx
@@ -45,6 +45,7 @@ import {
type CheckboxRootOptions,
type CheckboxRootProps,
type CheckboxRootRenderProps,
+ type CheckboxRootState,
CheckboxRoot as Root,
} from "./checkbox-root";
@@ -75,6 +76,9 @@ export type {
CheckboxLabelProps,
CheckboxRootOptions,
CheckboxRootProps,
+ CheckboxRootState,
+ CheckboxRootCommonProps,
+ CheckboxRootRenderProps,
};
export { Control, Description, ErrorMessage, Indicator, Input, Label, Root };
diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx
index 4a72a633e..3f7c5dd94 100644
--- a/packages/core/src/index.tsx
+++ b/packages/core/src/index.tsx
@@ -33,6 +33,7 @@ export * as Pagination from "./pagination";
export * as Popover from "./popover";
export * as Progress from "./progress";
export * as RadioGroup from "./radio-group";
+export * as CheckboxGroup from "./checkbox-group";
export * as Select from "./select";
export * as Separator from "./separator";
export * as Skeleton from "./skeleton";