diff --git a/apps/docs/src/examples/checkbox-group.module.css b/apps/docs/src/examples/checkbox-group.module.css new file mode 100644 index 000000000..556525041 --- /dev/null +++ b/apps/docs/src/examples/checkbox-group.module.css @@ -0,0 +1,39 @@ +@import "./checkbox.module.css"; + +.checkbox-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.checkbox-group__label { + color: hsl(240 6% 10%); + font-size: 14px; + font-weight: 500; + user-select: none; +} + +.checkbox-group__description { + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; +} + +.checkbox-group__error-message { + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; +} + +.checkbox-group__items { + display: flex; + gap: 16px; +} + +[data-kb-theme="dark"] .checkbox-group__label { + color: hsl(240 5% 84%); +} + +[data-kb-theme="dark"] .checkbox-group__description { + color: hsl(240 5% 65%); +} diff --git a/apps/docs/src/examples/checkbox-group.tsx b/apps/docs/src/examples/checkbox-group.tsx new file mode 100644 index 000000000..6a6cfff41 --- /dev/null +++ b/apps/docs/src/examples/checkbox-group.tsx @@ -0,0 +1,233 @@ +import { CheckboxGroup } from "@kobalte/core/checkbox-group"; +import { For, createSignal } from "solid-js"; + +import { CheckIcon } from "../components"; +import style from "./checkbox-group.module.css"; + +export function BasicExample() { + return ( + + + Subscribe to topics + + + + ); +} + +export function DefaultValueExample() { + return ( + + + Subscribe to topics + + + + ); +} + +export function ControlledExample() { + const [value, setValue] = createSignal(["News"]); + + return ( + <> + + + What would you like to subscribe to? + + + + + ); +} + +export function DescriptionExample() { + return ( + + + What would you like to subscribe to? + + + + Select the types of updates you'd like to receive. + + + ); +} + +export function ErrorMessageExample() { + const [value, setValue] = createSignal(["News"]); + + return ( + + + What would you like to subscribe to? + + + + Please select News to stay informed. + + + ); +} + +export function HTMLFormExample() { + let formRef: HTMLFormElement | undefined; + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const formData = new FormData(formRef); + const subscriptions = Array.from(formData.getAll("subscriptions")); + + const result = { + ...Object.fromEntries(formData), + subscriptions, + }; + + alert(JSON.stringify(result, null, 2)); + }; + + return ( +
+ + + What would you like to subscribe to? + + + +
+ + +
+
+ ); +} diff --git a/apps/docs/src/routes/docs/core.tsx b/apps/docs/src/routes/docs/core.tsx index d4b1efb21..5ab565769 100644 --- a/apps/docs/src/routes/docs/core.tsx +++ b/apps/docs/src/routes/docs/core.tsx @@ -61,6 +61,11 @@ const CORE_NAV_SECTIONS: NavSection[] = [ title: "Checkbox", href: "/docs/core/components/checkbox", }, + { + title: "Checkbox Group", + href: "/docs/core/components/checkbox-group", + status: "new", + }, { title: "Collapsible", href: "/docs/core/components/collapsible", diff --git a/apps/docs/src/routes/docs/core/components/checkbox-group.mdx b/apps/docs/src/routes/docs/core/components/checkbox-group.mdx new file mode 100644 index 000000000..5e48e5195 --- /dev/null +++ b/apps/docs/src/routes/docs/core/components/checkbox-group.mdx @@ -0,0 +1,478 @@ +import { Preview, TabsSnippets, Kbd } from "../../../../components"; +import { + ControlledExample, + DefaultValueExample, + HTMLFormExample, + BasicExample, + DescriptionExample, + ErrorMessageExample, +} from "../../../../examples/checkbox-group"; + +# Checkbox Group + +A set of checkboxes, where more than one of the checkbox can be checked at a time. + +## Import + +```ts +import { CheckboxGroup } from "@kobalte/core/checkbox-group"; +// or +import { Root, Label, ... } from "@kobalte/core/checkbox-group"; +// or (deprecated) +import { CheckboxGroup } from "@kobalte/core"; +``` + +## Features + +- Each checkbox is built with a native HTML `` element, which is visually hidden to allow custom styling. +- Syncs with form reset events. +- Group and checkbox labeling support for assistive technology. +- Can be controlled or uncontrolled. + +## Anatomy + +The checkbox group consists of: + +- **CheckboxGroup**: The root container for the checkbox group. +- **CheckboxGroup.Label**: The label that gives the user information on the checkbox group. +- **CheckboxGroup.Description**: The description that gives the user more information on the checkbox group. +- **CheckboxGroup.ErrorMessage**: The error message that gives the user information about how to fix a validation error on the checkbox group. + +The checkbox item consists of: + +- **CheckboxGroup.Item**: The root container for a checkbox. +- **CheckboxGroup.ItemInput**: The native html input that is visually hidden in the checkbox. +- **CheckboxGroup.ItemControl**: The element that visually represents a checkbox. +- **CheckboxGroup.ItemIndicator**: The visual indicator rendered when the checkbox is in a checked state. +- **CheckboxGroup.ItemLabel**: The label that gives the user information on the checkbox. +- **CheckboxGroup.ItemDescription**: The description that gives the user more information on the checkbox. + +```tsx + + + + + + + + + + + + + +``` + +## Example + + + + + + + + index.tsx + style.css + + {/* */} + + ```tsx + import { CheckboxGroup } from "@kobalte/core/checkbox-group"; + import "./style.css"; + + function App() { + return ( + + + Subscribe to topics + + + + ); + } + ``` + + + + ```css + .checkbox-group { + display: flex; + flex-direction: column; + gap: 8px; + } + + .checkbox-group__label { + color: hsl(240 6% 10%); + font-size: 14px; + font-weight: 500; + user-select: none; + } + + .checkbox-group__description { + color: hsl(240 5% 26%); + font-size: 12px; + user-select: none; + } + + .checkbox-group__error-message { + color: hsl(0 72% 51%); + font-size: 12px; + user-select: none; + } + + .checkbox-group__items { + display: flex; + gap: 16px; + } + + [data-kb-theme="dark"] .checkbox-group__label { + color: hsl(240 5% 84%); + } + + [data-kb-theme="dark"] .checkbox-group__description { + color: hsl(240 5% 65%); + } + + .checkbox { + display: inline-flex; + align-items: center; + } + + .checkbox__control { + height: 20px; + width: 20px; + border-radius: 6px; + border: 1px solid hsl(240 5% 84%); + background-color: hsl(240 6% 90%); + } + + .checkbox__input:focus-visible + .checkbox__control { + outline: 2px solid hsl(200 98% 39%); + outline-offset: 2px; + } + + .checkbox__control[data-checked] { + border-color: hsl(200 98% 39%); + background-color: hsl(200 98% 39%); + color: white; + } + + .checkbox__control[data-invalid] { + border-color: hsl(0 72% 51%); + } + + .checkbox__label { + margin-left: 6px; + color: hsl(240 6% 10%); + font-size: 14px; + user-select: none; + } + ``` + + + {/* */} + + +## Usage + +// FIXME: finalize and update all examples below + +### Default value + +An initial, uncontrolled value can be provided using the `defaultValue` prop, which accepts a value corresponding with the `value` prop of each checkbox. + + + + + +```tsx {0,4} + + Subscribe to topics +
+ + {topic => ( + + + + + + + + {topic} + + )} + +
+
+``` + +The `role="presentation"` is required for all non content elements between the `CheckboxGroup` and `CheckboxGroup.Item` due to a bug in Chromium based browsers that incorrectly parse semantics and break screen readers. + +### Controlled value + +The `value` prop, which accepts a value corresponding with the `value` prop of each checkbox, can be used to make the value controlled. The `onChange` event is fired when the user selects a checkbox, and receives the new value. + + + + + +```tsx {3,7,11} +import { createSignal } from "solid-js"; + +function ControlledExample() { + const [value, setValue] = createSignal(["News"]); + + return ( + + What would you like to subscribe to? +
+ + {option => ( + + + + + + + + {option} + + )} + +
+
+ ); +} +``` + +### Description + +The `CheckboxGroup.Description` component can be used to associate additional help text with a checkbox group. + + + + + +```tsx {7} + + What would you like to subscribe to? +
+ + {option => ( + + + + + + + + {option} + + )} + +
+ + Select the types of updates you'd like to receive. + +
+``` + +### Error message + +The `CheckboxGroup.ErrorMessage` component can be used to help the user fix a validation error. It should be combined with the `validationState` prop to semantically mark the checkbox group as invalid for assistive technologies. + +By default, it will render only when the `validationState` prop is set to `invalid`, use the `forceMount` prop to always render the error message (ex: for usage with animation libraries). + + + + + +```tsx {9,17} +import { createSignal } from "solid-js"; + +function ErrorMessageExample() { + const [value, setValue] = createSignal(["News"]); + + return ( + + What would you like to subscribe to? +
+ + {option => ( + + + + + + + + {option} + + )} + +
+ Please select News to stay informed. +
+ ); +} +``` + +### HTML forms + +The checkbox group `name` prop, paired with the checkbox `value` prop, can be used for integration with HTML forms. + + + + + +```tsx {7,11} +function HTMLFormExample() { + let formRef: HTMLFormElement | undefined; + + const onSubmit = (e: SubmitEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const formData = new FormData(formRef); + const subscriptions = Array.from(formData.getAll("subscriptions")); + + const result = { + ...Object.fromEntries(formData), + subscriptions, + }; + + alert(JSON.stringify(result, null, 2)); + }; + + return ( +
+ + What would you like to subscribe to? +
+ + {option => ( + + + + + + + + {option} + + )} + +
+
+
+ + +
+
+ ); +} +``` + +## API Reference + +### CheckboxGroup + +`CheckboxGroup` is equivalent to the `Root` import from `@kobalte/core/checkbox-group` (and deprecated `CheckboxGroup.Root`). + +| Prop | Description | +| :-------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| values | `string[]`
The controlled values of the checkboxes to check. | +| defaultValues | `string[]`
The value of the checkboxes that should be checked when initially rendered. Useful when you do not need to control the state of the checkboxes. | +| onChange | `(value: string) => void`
Event handler called when the value changes. | +| orientation | `'horizontal' \| 'vertical'`
The axis the checkbox group items should align with. | +| name | `string`
The name of the checkbox group. Submitted with its owning form as part of a name/value pair. | +| validationState | `'valid' \| 'invalid'`
Whether the checkbox group should display its "valid" or "invalid" visual styling. | +| required | `boolean`
Whether the user must check a checkbox group item before the owning form can be submitted. | +| disabled | `boolean`
Whether the checkbox group is disabled. | +| readOnly | `boolean`
Whether the checkbox group items can be selected but not changed by the user. | + +| Data attribute | Description | +| :------------- | :---------------------------------------------------------------------------------------------- | +| data-valid | Present when the checkbox group is valid according to the validation rules. | +| data-invalid | Present when the checkbox group is invalid according to the validation rules. | +| data-required | Present when the user must check a checkbox group item before the owning form can be submitted. | +| data-disabled | Present when the checkbox group is disabled. | +| data-readonly | Present when the checkbox group is read only. | + +`CheckboxGroup.Label`, `CheckboxGroup.Description` and `CheckboxGroup.ErrorMesssage` shares the same data-attributes. + +### CheckboxGroup.ErrorMessage + +| Prop | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. | + +### CheckboxGroup.Item + +| Prop | Description | +| :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| value | `string`
The value of the checkbox, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#Value). | +| disabled | `boolean`
Whether the checkbox is disabled or not. | + +| Data attribute | Description | +| :------------- | :----------------------------------------------------------------------------------- | +| data-valid | Present when the parent checkbox group is valid according to the validation rules. | +| data-invalid | Present when the parent checkbox group is invalid according to the validation rules. | +| data-checked | Present when the checkbox is checked. | +| data-disabled | Present when the checkbox is disabled. | + +`CheckboxGroup.ItemInput`, `CheckboxGroup.ItemControl`, `CheckboxGroup.ItemIndicator` and `CheckboxGroup.ItemLabel` shares the same data-attributes. + +### CheckboxGroup.ItemIndicator + +| Prop | Description | +| :--------- | :-------------------------------------------------------------------------------------------------------------------------------------- | +| forceMount | `boolean`
Used to force mounting when more control is needed. Useful when controlling animation with SolidJS animation libraries. | + +## Rendered elements + +| Component | Default rendered element | +| :------------------------------ | :----------------------- | +| `CheckboxGroup` | `div` | +| `CheckboxGroup.Label` | `span` | +| `CheckboxGroup.Description` | `div` | +| `CheckboxGroup.ErrorMessage` | `div` | +| `CheckboxGroup.Item` | `div` | +| `CheckboxGroup.ItemInput` | `input` | +| `CheckboxGroup.ItemControl` | `div` | +| `CheckboxGroup.ItemIndicator` | `div` | +| `CheckboxGroup.ItemLabel` | `label` | +| `CheckboxGroup.ItemDescription` | `div` | + +## Accessibility + +### Keyboard Interactions + +| Key | Description | +| :-------------------- | :----------------------------------------------------------------------------- | +| Tab | Moves focus to either the checked checkbox or the first checkbox in the group. | +| Space | When focus is on an unchecked checkbox, checks it. | +| ArrowDown | Moves focus and checks the next checkbox in the group. | +| ArrowRight | Moves focus and checks the next checkbox in the group. | +| ArrowUp | Moves focus and checks the previous checkbox in the group. | +| ArrowLeft | Moves focus and checks the previous checkbox in the group. | diff --git a/packages/core/dev/App.tsx b/packages/core/dev/App.tsx index bf49446a4..fda3f08a4 100644 --- a/packages/core/dev/App.tsx +++ b/packages/core/dev/App.tsx @@ -1,5 +1,3 @@ export default function App() { - return ( - <> - ); + return (<>); } diff --git a/packages/core/src/checkbox-group/checkbox-group-context.tsx b/packages/core/src/checkbox-group/checkbox-group-context.tsx new file mode 100644 index 000000000..65d1d7835 --- /dev/null +++ b/packages/core/src/checkbox-group/checkbox-group-context.tsx @@ -0,0 +1,22 @@ +import { type Accessor, createContext, useContext } from "solid-js"; + +export interface CheckboxGroupContextValue { + ariaDescribedBy: Accessor; + isValueSelected: (value: string) => boolean; + handleValue: (value: string) => void; + generateId: (part: string) => string; +} + +export const CheckboxGroupContext = createContext(); + +export function useCheckboxGroupContext() { + const context = useContext(CheckboxGroupContext); + + if (context === undefined) { + throw new Error( + "[kobalte]: `useCheckboxGroupContext` must be used within a `CheckboxGroup` component", + ); + } + + return context; +} diff --git a/packages/core/src/checkbox-group/checkbox-group-item-input.tsx b/packages/core/src/checkbox-group/checkbox-group-item-input.tsx new file mode 100644 index 000000000..3701962e5 --- /dev/null +++ b/packages/core/src/checkbox-group/checkbox-group-item-input.tsx @@ -0,0 +1,71 @@ +/* + * 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/main/packages/%40react-aria/checkbox/src/useCheckboxGroup.ts + */ + +import { type Component, type ValidComponent, splitProps } from "solid-js"; + +import { mergeRefs } from "@kobalte/utils"; +import { + Checkbox, + type CheckboxInputCommonProps, + type CheckboxInputOptions, + type CheckboxInputRenderProps, +} from "../checkbox"; +import { useFormControlContext } from "../form-control"; +import type { ElementOf, PolymorphicProps } from "../polymorphic"; +import { useCheckboxGroupContext } from "./checkbox-group-context"; + +export interface CheckboxGroupItemInputOptions extends CheckboxInputOptions {} + +export interface CheckboxGroupItemInputCommonProps< + T extends HTMLElement = HTMLInputElement, +> extends CheckboxInputCommonProps {} + +export interface CheckboxGroupItemInputRenderProps + extends CheckboxInputRenderProps, + CheckboxGroupItemInputCommonProps {} + +export type CheckboxGroupItemInputProps< + T extends ValidComponent | HTMLElement = HTMLInputElement, +> = CheckboxGroupItemInputOptions & + Partial>>; + +/** + * The native html input that is visually hidden in the checkbox. + */ +export function CheckboxGroupItemInput( + props: PolymorphicProps>, +) { + let ref: HTMLInputElement | undefined; + + const formControlContext = useFormControlContext(); + const checkboxGroupContext = useCheckboxGroupContext(); + + const [local, others] = splitProps(props, ["aria-describedby", "ref"]); + + const ariaDescribedBy = () => { + return ( + [local["aria-describedby"], checkboxGroupContext.ariaDescribedBy()] + .filter(Boolean) + .join(" ") || undefined + ); + }; + + return ( + + > + > + ref={mergeRefs((el) => (ref = el), local.ref)} + as="input" + aria-describedby={ariaDescribedBy() || undefined} + {...formControlContext.dataset()} + {...others} + /> + ); +} diff --git a/packages/core/src/checkbox-group/checkbox-group-item.tsx b/packages/core/src/checkbox-group/checkbox-group-item.tsx new file mode 100644 index 000000000..2970e0dea --- /dev/null +++ b/packages/core/src/checkbox-group/checkbox-group-item.tsx @@ -0,0 +1,123 @@ +/* + * 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/main/packages/%40react-aria/checkbox/src/useCheckboxGroup.ts + */ + +import { mergeDefaultProps, mergeRefs } from "@kobalte/utils"; +import { + type Component, + type ValidComponent, + createUniqueId, + splitProps, +} from "solid-js"; + +import { + Checkbox, + type CheckboxRootCommonProps, + type CheckboxRootOptions, + type CheckboxRootRenderProps, + type CheckboxRootState, +} from "../checkbox"; +import { + FORM_CONTROL_PROP_NAMES, + useFormControlContext, +} from "../form-control"; +import type { ElementOf, PolymorphicProps } from "../polymorphic"; + +import { useCheckboxGroupContext } from "./checkbox-group-context"; + +interface CheckboxGroupItemState extends CheckboxRootState {} + +export interface CheckboxGroupItemOptions extends CheckboxRootOptions {} + +export interface CheckboxGroupItemCommonProps< + T extends HTMLElement = HTMLElement, +> extends CheckboxRootCommonProps {} + +export interface CheckboxGroupItemRenderProps + extends CheckboxRootRenderProps, + CheckboxGroupItemCommonProps {} + +export type CheckboxGroupItemProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = CheckboxGroupItemOptions & + Partial>>; + +/** + * A control that allows the user to toggle between checked and not checked. + */ +export function CheckboxGroupItem( + props: PolymorphicProps>, +) { + let ref: HTMLElement | undefined; + + const checkboxGroupContext = useCheckboxGroupContext(); + const formControlContext = useFormControlContext(); + + const defaultId = checkboxGroupContext.generateId(`item-${createUniqueId()}`); + + const mergedProps = mergeDefaultProps( + { + id: defaultId, + }, + props as CheckboxGroupItemProps, + ); + + const [local, others] = splitProps(mergedProps, [ + "value", + "validationState", + "disabled", + "name", + "required", + "readOnly", + "ref", + ]); + + const isChecked = () => { + return checkboxGroupContext.isValueSelected(local.value!); + }; + const value = () => local.value!; + + return ( + + > + > + noRole + ref={mergeRefs((el) => (ref = el), local.ref)} + onChange={(s) => checkboxGroupContext.handleValue(local.value!)} + checked={isChecked()} + defaultChecked={isChecked()} + value={value()} + aria-invalid={ + formControlContext.validationState() === "invalid" || + local.validationState || + undefined + } + name={formControlContext.name() || local.name} + disabled={formControlContext.isDisabled() || local.disabled || undefined} + required={formControlContext.isRequired() || local.required || undefined} + readOnly={formControlContext.isReadOnly() || local.readOnly || undefined} + aria-required={ + formControlContext.isRequired() || local.required || undefined + } + aria-disabled={ + formControlContext.isDisabled() || local.disabled || undefined + } + aria-readonly={ + formControlContext.isReadOnly() || local.readOnly || undefined + } + validationState={ + formControlContext.validationState() || + local.validationState || + undefined + } + {...formControlContext.dataset()} + {...others} + /> + ); +} diff --git a/packages/core/src/checkbox-group/checkbox-group-label.tsx b/packages/core/src/checkbox-group/checkbox-group-label.tsx new file mode 100644 index 000000000..2b591774a --- /dev/null +++ b/packages/core/src/checkbox-group/checkbox-group-label.tsx @@ -0,0 +1,32 @@ +import type { Component, ValidComponent } from "solid-js"; + +import { FormControlLabel } from "../form-control"; +import type { ElementOf, PolymorphicProps } from "../polymorphic"; + +export interface CheckboxGroupLabelOptions {} + +export interface CheckboxGroupLabelCommonProps< + T extends HTMLElement = HTMLElement, +> {} + +export interface CheckboxGroupLabelRenderProps + extends CheckboxGroupLabelCommonProps {} + +export type CheckboxGroupLabelProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = CheckboxGroupLabelOptions & + Partial>>; + +/** + * The label that gives the user information on the Checkbox group. + */ +export function CheckboxGroupLabel( + props: PolymorphicProps>, +) { + return ( + > + as="span" + {...(props as CheckboxGroupLabelProps)} + /> + ); +} diff --git a/packages/core/src/checkbox-group/checkbox-group-root.tsx b/packages/core/src/checkbox-group/checkbox-group-root.tsx new file mode 100644 index 000000000..2b0e00bfa --- /dev/null +++ b/packages/core/src/checkbox-group/checkbox-group-root.tsx @@ -0,0 +1,224 @@ +/* + * 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/main/packages/%40react-aria/checkbox/src/useCheckboxGroup.ts + * https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/checkbox/src/useCheckboxGroupState.ts + */ + +import { + type Orientation, + type ValidationState, + access, + createGenerateId, + mergeDefaultProps, + mergeRefs, +} from "@kobalte/utils"; +import { type ValidComponent, createUniqueId, splitProps } from "solid-js"; + +import { + FORM_CONTROL_PROP_NAMES, + FormControlContext, + type FormControlDataSet, + createFormControl, +} from "../form-control"; +import { + type ElementOf, + Polymorphic, + type PolymorphicProps, +} from "../polymorphic"; +import { + createControllableSignal, + createFormResetListener, +} from "../primitives"; +import { + CheckboxGroupContext, + type CheckboxGroupContextValue, +} from "./checkbox-group-context"; + +export interface CheckboxGroupRootOptions { + /** The controlled values of the checkboxes to check. */ + values?: string[]; + + /** + * The value of the checkboxes that should be checked when initially rendered. + * Useful when you do not need to control the state of the Checkboxes. + */ + defaultValues?: string[]; + + /** Event handler called when the value changes. */ + onChange?: (values: string[]) => void; + + /** The axis the checkbox group items should align with. */ + orientation?: Orientation; + + /** + * A unique identifier for the component. + * The id is used to generate id attributes for nested components. + * If no id prop is provided, a generated id will be used. + */ + id?: string; + + /** + * The name of the checkbox group. + * Submitted with its owning form as part of a name/value pair. + */ + name?: string; + + /** Whether the checkbox group should display its "valid" or "invalid" visual styling. */ + validationState?: ValidationState; + + /** Whether the user must select an item before the owning form can be submitted. */ + required?: boolean; + + /** Whether the checkbox group is disabled. */ + disabled?: boolean; + + /** Whether the checkbox group is read only. */ + readOnly?: boolean; +} + +export interface CheckboxGroupRootCommonProps< + T extends HTMLElement = HTMLElement, +> { + id: string; + ref: T | ((el: T) => void); + "aria-labelledby": string | undefined; + "aria-describedby": string | undefined; + "aria-label"?: string; +} + +export interface CheckboxGroupRootRenderProps + extends CheckboxGroupRootCommonProps, + FormControlDataSet { + role: "group"; + "aria-invalid": boolean | undefined; + "aria-required": boolean | undefined; + "aria-disabled": boolean | undefined; + "aria-readonly": boolean | undefined; + "aria-orientation": Orientation | undefined; +} + +export type CheckboxGroupRootProps< + T extends ValidComponent | HTMLElement = HTMLElement, +> = CheckboxGroupRootOptions & + Partial>>; + +/** + * A set of checkboxes, where more than one of the checkbox can be checked at a time. + */ +export function CheckboxGroupRoot( + props: PolymorphicProps>, +) { + 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";