Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions packages/@react-aria/checkbox/docs/useCheckboxGroup.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<!-- Copyright 2020 Adobe. All rights reserved.
This file is licensed to you under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. You may obtain a copy
of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
OF ANY KIND, either express or implied. See the License for the specific language
governing permissions and limitations under the License. -->

import {Layout} from '@react-spectrum/docs';
export default Layout;

import docs from 'docs:@react-aria/checkbox';
import hiddenDocs from 'docs:@react-aria/visually-hidden';
import focusDocs from 'docs:@react-aria/focus';
import statelyDocs from 'docs:@react-stately/checkbox';
import {HeaderInfo, FunctionAPI, TypeContext, InterfaceType, TypeLink} from '@react-spectrum/docs';
import packageData from '@react-aria/checkbox/package.json';

```jsx import
import {useCheckboxGroup, useCheckboxGroupItem} from '@react-aria/checkbox';
```

---
category: Forms
keywords: [checkbox, input, aria]
after_version: 3.1.0
---

# useCheckboxGroup

<p>{docs.exports.useCheckboxGroup.description}</p>

<HeaderInfo
packageData={packageData}
componentNames={['useCheckboxGroup', 'useCheckboxGroupItem']}
sourceData={[
{type: 'W3C', url: 'https://www.w3.org/TR/wai-aria-practices/#checkbox'}
]} />

## API

<FunctionAPI function={docs.exports.useCheckboxGroup} links={docs.links} />
<FunctionAPI function={docs.exports.useCheckboxGroupItem} links={docs.links} />

## Features

Checkbox groups can be built in HTML with the
[&lt;fieldset&gt;](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/fieldset)
and [&lt;input&gt;](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input) elements,
however these can be difficult to style. `useCheckboxGroup` and `useCheckboxGroupItem` help achieve accessible
checkbox groups that can be styled as needed.

* Checkbox groups are exposed to assistive technology via ARIA
* Each checkbox is built with a native HTML `<input>` element, which can be optionally visually
hidden to allow custom styling
* Full support for browser features like form autofill
* Keyboard focus management and cross browser normalization
* Group and checkbox labeling support for assistive technology

## Anatomy

A checkbox group consists of a set of checkboxes, and a label. Each checkbox
includes a label and a visual selection indicator. Zero or more checkboxes
within the group can be selected at a time. Users may click or touch a checkbox
to select it, or use the <kbd>Tab</kbd> key to navigate to it
and the <kbd>Space</kbd> key to toggle it.

`useCheckboxGroup` returns props for the group and its label, which you should spread
onto the appropriate element:

<TypeContext.Provider value={docs.links}>
<InterfaceType properties={docs.links[docs.exports.useCheckboxGroup.return.id].properties} />
</TypeContext.Provider>

`useCheckboxGroupItem` returns props for an individual checkbox:

<TypeContext.Provider value={docs.links}>
<InterfaceType properties={docs.links[docs.exports.useCheckboxGroupItem.return.id].properties} />
</TypeContext.Provider>

Selection state is managed by the <TypeLink links={statelyDocs.links} type={statelyDocs.exports.useCheckboxGroupState} />
hook in `@react-stately/checkbox`. The state object should be passed as an option to `useCheckboxGroup`
and `useCheckboxGroupItem`.

Individual checkboxes must have a visual label. If the checkbox group does not have a visible label,
an `aria-label` or `aria-labelledby` prop must be passed instead to identify the element to assistive
technology.

**Note:** `useCheckboxGroupItem` should only be used when it is contained within a checkbox group. For a
standalone checkbox, use the [useCheckbox](useCheckbox.html) hook instead.

## Example

This example uses native input elements for the checkboxes, and React context to share state from the group
to each checkbox. An HTML `<label>` element wraps the native input and the text to provide an implicit label
for the radio.

```tsx example
import {useCheckboxGroupState} from '@react-stately/checkbox';

let CheckboxGroupContext = React.createContext();

function CheckboxGroup(props) {
let {children, label} = props;
let state = useCheckboxGroupState(props);
let {groupProps, labelProps} = useCheckboxGroup(props, state);

return (
<div {...groupProps}>
<span {...labelProps}>{label}</span>
<CheckboxGroupContext.Provider value={state}>
{children}
</CheckboxGroupContext.Provider>
</div>
);
}

function Checkbox(props) {
let {children} = props;
let state = React.useContext(CheckboxGroupContext);
let ref = React.useRef();
let {inputProps} = useCheckboxGroupItem(props, state, ref);

return (
<label style={{display: 'block'}}>
<input {...inputProps} />
{children}
</label>
);
}

<CheckboxGroup label="Favorite sports">
<Checkbox value="soccer">Soccer</Checkbox>
<Checkbox value="baseball">Baseball</Checkbox>
<Checkbox value="basketball">Basketball</Checkbox>
</CheckboxGroup>
```

## Styling

See the [useCheckbox](useCheckbox.html#styling) docs for details on how to customize the styling of checkbox elements.
13 changes: 9 additions & 4 deletions packages/@react-aria/checkbox/src/useCheckboxGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
*/

import {AriaCheckboxGroupProps} from '@react-types/checkbox';
import {checkboxGroupNames} from './utils';
import {CheckboxGroupState} from '@react-stately/checkbox';
import {filterDOMProps, mergeProps} from '@react-aria/utils';
import {HTMLAttributes} from 'react';
import {useLabel} from '@react-aria/label';

interface CheckboxGroupAria {
/** Props for the checkbox group wrapper element. */
checkboxGroupProps: HTMLAttributes<HTMLElement>,
groupProps: HTMLAttributes<HTMLElement>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while I understand the sentiment to match against the returned role, isn't this confusing (DX-wise)? given that useRadioGroup returns radioGroupProps?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, in that case the role is radiogroup.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the role is mainly opaque to users though - the point of React Aria is so people don't have to worry about this. Seems like one might not really know what exact role is being prefilled by React Aria in the returned objects, but they certainly know how the component that they have just used is named.

I don't care about this as much as TS will help me with this anyway but I kinda find the choice somewhat odd as the chosen name focuses on the low-level implementation detail that is hidden from the user, rather than on a high-level feature that is actually used directly by them.

/** Props for the checkbox group's visible label (if any). */
labelProps: HTMLAttributes<HTMLElement>
}
Expand All @@ -28,8 +30,8 @@ interface CheckboxGroupAria {
* @param props - Props for the checkbox group.
* @param state - State for the checkbox group, as returned by `useCheckboxGroupState`.
*/
export function useCheckboxGroup(props: AriaCheckboxGroupProps): CheckboxGroupAria {
let {isDisabled} = props;
export function useCheckboxGroup(props: AriaCheckboxGroupProps, state: CheckboxGroupState): CheckboxGroupAria {
let {isDisabled, name} = props;

let {labelProps, fieldProps} = useLabel({
...props,
Expand All @@ -40,8 +42,11 @@ export function useCheckboxGroup(props: AriaCheckboxGroupProps): CheckboxGroupAr

let domProps = filterDOMProps(props, {labelable: true});

// Pass name prop from group to all items by attaching to the state.
checkboxGroupNames.set(state, name);

return {
checkboxGroupProps: mergeProps(domProps, {
groupProps: mergeProps(domProps, {
role: 'group',
'aria-disabled': isDisabled || undefined,
...fieldProps
Expand Down
15 changes: 12 additions & 3 deletions packages/@react-aria/checkbox/src/useCheckboxGroupItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import {AriaCheckboxGroupItemProps} from '@react-types/checkbox';
import {CheckboxAria, useCheckbox} from './useCheckbox';
import {checkboxGroupNames} from './utils';
import {CheckboxGroupState} from '@react-stately/checkbox';
import {RefObject} from 'react';
import {useToggleState} from '@react-stately/toggle';
Expand All @@ -25,8 +26,8 @@ import {useToggleState} from '@react-stately/toggle';
*/
export function useCheckboxGroupItem(props: AriaCheckboxGroupItemProps, state: CheckboxGroupState, inputRef: RefObject<HTMLInputElement>): CheckboxAria {
const toggleState = useToggleState({
isReadOnly: props.isReadOnly,
isSelected: state.value.includes(props.value),
isReadOnly: props.isReadOnly || state.isReadOnly,
isSelected: state.isSelected(props.value),
onChange(isSelected) {
if (isSelected) {
state.addValue(props.value);
Expand All @@ -39,5 +40,13 @@ export function useCheckboxGroupItem(props: AriaCheckboxGroupItemProps, state: C
}
}
});
return useCheckbox({...props, name: state.name}, toggleState, inputRef);

let {inputProps} = useCheckbox({
...props,
isReadOnly: props.isReadOnly || state.isReadOnly,
isDisabled: props.isDisabled || state.isDisabled,
name: props.name || checkboxGroupNames.get(state)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't make sense for me unless I miss something. It seems important to keep the same name for all "checkbox group items" as this "groups" them when sending the containing form element.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking it might be possible to have a single "group" client side, but maybe you submit to a server using separate names for each checkbox.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from what i remember of form submission, there are some specials names that work with some servers? i think the name has to include "[]" on the end
if you don't include that then checkboxes with the same name will overwrite each other
but not everything recognizes that special name syntax, so probably better to have them be individually named and just 'look' like a group

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking it might be possible to have a single "group" client side, but maybe you submit to a server using separate names for each checkbox.

How realistic this use case is? Shouldn't this really be considered bad practice?

}, toggleState, inputRef);

return {inputProps};
}
15 changes: 15 additions & 0 deletions packages/@react-aria/checkbox/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {CheckboxGroupState} from '@react-stately/checkbox';

export const checkboxGroupNames = new WeakMap<CheckboxGroupState, string>();
59 changes: 45 additions & 14 deletions packages/@react-aria/checkbox/test/useCheckboxGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function Checkbox({checkboxGroupState, ...props}: AriaCheckboxProps & { checkbox

function CheckboxGroup({groupProps, checkboxProps}: {groupProps: AriaCheckboxGroupProps, checkboxProps: AriaCheckboxProps[]}) {
const state = useCheckboxGroupState(groupProps);
const {checkboxGroupProps, labelProps} = useCheckboxGroup(groupProps);
const {groupProps: checkboxGroupProps, labelProps} = useCheckboxGroup(groupProps, state);
return (
<div {...checkboxGroupProps}>
{groupProps.label && <span {...labelProps}>{groupProps.label}</span>}
Expand Down Expand Up @@ -55,10 +55,9 @@ describe('useCheckboxGroup', () => {
expect(checkboxGroup).toBeInTheDocument();
expect(checkboxes.length).toBe(3);

let groupName = checkboxes[0].getAttribute('name');
expect(checkboxes[0]).toHaveAttribute('name', groupName);
expect(checkboxes[1]).toHaveAttribute('name', groupName);
expect(checkboxes[2]).toHaveAttribute('name', groupName);
expect(checkboxes[0]).not.toHaveAttribute('name');
expect(checkboxes[1]).not.toHaveAttribute('name');
expect(checkboxes[2]).not.toHaveAttribute('name');

expect(checkboxes[0].value).toBe('dogs');
expect(checkboxes[1].value).toBe('cats');
Expand Down Expand Up @@ -159,8 +158,8 @@ describe('useCheckboxGroup', () => {
expect(checkboxGroup).toHaveAttribute('data-testid', 'favorite-pet');
});

it('sets aria-disabled when isDisabled is true', () => {
let {getByRole} = render(
it('sets aria-disabled and makes checkboxes disabled when isDisabled is true', () => {
let {getAllByRole, getByRole} = render(
<CheckboxGroup
groupProps={{label: 'Favorite Pet', isDisabled: true}}
checkboxProps={[
Expand All @@ -169,13 +168,18 @@ describe('useCheckboxGroup', () => {
{value: 'dragons', children: 'Dragons'}
]} />
);
let checkboxGroup = getByRole('group', {exact: true});

let checkboxGroup = getByRole('group', {exact: true});
expect(checkboxGroup).toHaveAttribute('aria-disabled', 'true');

let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
expect(checkboxes[0]).toHaveAttribute('disabled');
expect(checkboxes[0]).toHaveAttribute('disabled');
expect(checkboxes[0]).toHaveAttribute('disabled');
});

it('doesn\'t set aria-disabled by default', () => {
let {getByRole} = render(
it('doesn\'t set aria-disabled or make checkboxes disabled by default', () => {
let {getAllByRole, getByRole} = render(
<CheckboxGroup
groupProps={{label: 'Favorite Pet'}}
checkboxProps={[
Expand All @@ -184,13 +188,18 @@ describe('useCheckboxGroup', () => {
{value: 'dragons', children: 'Dragons'}
]} />
);
let checkboxGroup = getByRole('group', {exact: true});

let checkboxGroup = getByRole('group', {exact: true});
expect(checkboxGroup).not.toHaveAttribute('aria-disabled');

let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[0]).not.toHaveAttribute('disabled');
});

it('doesn\'t set aria-disabled when isDisabled is false', () => {
let {getByRole} = render(
it('doesn\'t set aria-disabled or make checkboxes disabled when isDisabled is false', () => {
let {getAllByRole, getByRole} = render(
<CheckboxGroup
groupProps={{label: 'Favorite Pet', isDisabled: false}}
checkboxProps={[
Expand All @@ -199,9 +208,31 @@ describe('useCheckboxGroup', () => {
{value: 'dragons', children: 'Dragons'}
]} />
);
let checkboxGroup = getByRole('group', {exact: true});

let checkboxGroup = getByRole('group', {exact: true});
expect(checkboxGroup).not.toHaveAttribute('aria-disabled');

let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[0]).not.toHaveAttribute('disabled');
expect(checkboxes[0]).not.toHaveAttribute('disabled');
});

it('sets readOnly on each checkbox', () => {
let {getAllByRole} = render(
<CheckboxGroup
groupProps={{label: 'Favorite Pet', isReadOnly: true}}
checkboxProps={[
{value: 'dogs', children: 'Dogs'},
{value: 'cats', children: 'Cats'},
{value: 'dragons', children: 'Dragons'}
]} />
);

let checkboxes = getAllByRole('checkbox') as HTMLInputElement[];
expect(checkboxes[0]).toHaveAttribute('readonly');
expect(checkboxes[0]).toHaveAttribute('readonly');
expect(checkboxes[0]).toHaveAttribute('readonly');
});

it('should not update state for readonly checkbox', () => {
Expand Down
Loading