Skip to content

Commit

Permalink
feat(Listbox): add Listbox component
Browse files Browse the repository at this point in the history
  • Loading branch information
scurker committed Aug 17, 2023
1 parent 37a98eb commit 3368530
Show file tree
Hide file tree
Showing 8 changed files with 634 additions and 24 deletions.
191 changes: 191 additions & 0 deletions docs/pages/components/Listbox.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
---
title: Listbox
description: An unstyled component to provide a keyboard navigable list of options.
source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/Listbox/Listbox.tsx
---

import { Listbox, ListboxOption, ListboxGroup } from '@deque/cauldron-react';

```js
import {
Listbox,
ListboxOption,
ListboxGroup
} from '@deque/cauldron-react';
```

## Examples

<Note>
Listbox follows [aria practices](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) for _Listbox_ but is not currently intended to be used by itself. This component's intended usage is to be composed with components that have keyboard navigable items like Combobox and [OptionsMenu](./OptionsMenu).
</Note>

### Listbox Options

A listbox can contain a list of options with optional values.

```jsx example
<>
<div id="listbox-options-example">Numbers</div>
<Listbox aria-labelledby="listbox-options-example">
<ListboxOption>One</ListboxOption>
<ListboxOption value="dos">Two</ListboxOption>
<ListboxOption>Three</ListboxOption>
</Listbox>
</>
```

### Disabled Options

A listbox option can be optionally disabled.

```jsx example
<>
<div id="listbox-disabled-options-example">Numbers, but two is disabled</div>
<Listbox aria-labelledby="listbox-disabled-options-example">
<ListboxOption>One</ListboxOption>
<ListboxOption disabled>Two</ListboxOption>
<ListboxOption>Three</ListboxOption>
</Listbox>
</>
```

### Grouped Options

Listbox options can also be grouped into categories.

```jsx example
<>
<div id="listbox-grouped-example">Colors and Numbers</div>
<Listbox as="div" aria-labelledby="listbox-grouped-example">
<ListboxGroup label="Colors">
<ListboxOption>Red</ListboxOption>
<ListboxOption>Green</ListboxOption>
<ListboxOption>Blue</ListboxOption>
</ListboxGroup>
<ListboxGroup label="Numbers">
<ListboxOption>One</ListboxOption>
<ListboxOption>Two</ListboxOption>
<ListboxOption>Three</ListboxOption>
</ListboxGroup>
</Listbox>
</>
```

### Keyboard Navigation

By default, keyboard navigation will stop at the first or last option of the list. To wrap around focus to the beginning or the end of the list, `navigation="cycle"` can be set to enable this behavior.

```jsx example
<>
<div id="listbox-cycle-example">Numbers that Cycle</div>
<Listbox navigation="cycle" aria-labelledby="listbox-cycle-example">
<ListboxOption>One</ListboxOption>
<ListboxOption>Two</ListboxOption>
<ListboxOption>Three</ListboxOption>
</Listbox>
</>
```

### Controlled

Controlled listboxes require updating the value to reflect the new selected state. Useful if some form of validation needs to be performed before setting the selected value.

```jsx example
<>
<div id="listbox-controlled-example">Controlled Listbox</div>
<Listbox navigation="cycle" aria-labelledby="listbox-controlled-example" value="Two">
<ListboxOption>One</ListboxOption>
<ListboxOption>Two</ListboxOption>
<ListboxOption>Three</ListboxOption>
</Listbox>
</>
```

### Uncontrolled

Uncontrolled listboxes will automatically set `aria-selected="true"` for the selected option upon selection. The initial value can be set via `defaultValue`.

```jsx example
<>
<div id="listbox-uncontrolled-example">Uncontrolled Listbox</div>
<Listbox navigation="cycle" aria-labelledby="listbox-uncontrolled-example" defaultValue="Two">
<ListboxOption>One</ListboxOption>
<ListboxOption>Two</ListboxOption>
<ListboxOption>Three</ListboxOption>
</Listbox>
</>
```

## Props

### Listbox

<ComponentProps className={true} refType="HTMLUListElement" props={[
{
name: 'navigation',
type: ['bound', 'cycle'],
defaultValue: 'bound',
description: 'How keyboard navigation is handled when reaching the start/end of the list.'
},
{
name: 'value',
type: ['string', 'number'],
description: 'Initial value to be applied to the listbox. Optionally used for "controlled" listboxes.'
},
{
name: 'defaultValue',
type: ['string', 'number'],
description: 'Value to be applied to the listbox. Optionally used for "controlled" listboxes.'
},
{
name: 'as',
type: ['React.ElementType', 'string'],
description: 'A component to render the Listbox as.',
defaultValue: 'ul'
}
]} />

### ListboxOption

<ComponentProps className={true} refType="HTMLLIElement" props={[
{
name: 'value',
type: ['string', 'number'],
description: 'Value to be applied to the listbox option. When omitted, value will default to the text content of the option.'
},
{
name: 'disabled',
type: 'boolean',
description: 'When set, sets the listbox option as "aria-disabled="true" and removes the element from key navigation.'
},
{
name: 'activeClass',
type: 'string',
defaultValue: 'ListboxOption--active',
description: 'When the listbox option becomes active, this class will be applied to the element.'
},
{
name: 'as',
type: ['React.ElementType', 'string'],
description: 'A component to render the ListboxOption as.',
defaultValue: 'li'
}
]} />

### ListboxGroup

<ComponentProps className={true} refType="HTMLUListElement" props={[
{
name: 'label',
required: true,
type: ['string', 'number'],
description: 'Value to be applied to the listbox option. When omitted, value will default to the text content of the option.'
},
{
name: 'as',
type: ['React.ElementType', 'string'],
description: 'A component to render the ListboxGroup as.',
defaultValue: 'ul'
}
]} />
194 changes: 194 additions & 0 deletions packages/react/src/components/Listbox/Listbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import React, {
forwardRef,
useCallback,
useState,
useLayoutEffect,
} from 'react';
import { ListboxProvider, type ListboxOption } from './ListboxContext';
import type { ListboxValue } from './ListboxOption';
import useSharedRef from '../../utils/useSharedRef';

const keys = ['ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', ' '];

interface ListboxProps
extends Omit<React.HTMLAttributes<HTMLElement>, 'onSelect'> {
as?: React.ElementType | string;
value?: ListboxValue;
navigation?: 'cycle' | 'bound';
onSelect?: <T extends HTMLElement = HTMLElement>({
target,
value,
}: {
target: T;
value: ListboxValue;
}) => void;
onSelectionChange?: ({
value,
}: {
previousValue: ListboxValue;
value: ListboxValue;
}) => void;
}

const getOptionId = (option: ListboxOption): string | null =>
option.element.getAttribute('id');

const isDisabledOption = (option: ListboxOption): boolean =>
option.element.getAttribute('aria-disabled') === 'true';

const optionMatchesValue = (option: ListboxOption, value: unknown): boolean =>
typeof option.value !== null &&
typeof option.value !== 'undefined' &&
option.value === value;

const Listbox = forwardRef<HTMLElement, ListboxProps>(
(
{
as: Component = 'ul',
children,
defaultValue,
value,
navigation = 'bound',
onKeyDown,
onFocus,
onSelect,
onSelectionChange,
...props
},
ref
): JSX.Element => {
const [options, setOptions] = useState<ListboxOption[]>([]);
const [activeOption, setActiveOption] = useState<ListboxOption | null>(
null
);
const [selectedOption, setSelectedOption] = useState<ListboxOption | null>(
null
);
const listboxRef = useSharedRef<HTMLElement>(ref);
const isControlled = typeof value !== 'undefined';

useLayoutEffect(() => {
const listboxValue = isControlled ? value : defaultValue;
const selectedOption = options.find((option) =>
optionMatchesValue(option, listboxValue)
);
if (selectedOption) {
setSelectedOption(selectedOption);
}
}, [options]);

const handleSelect = useCallback(
(option: ListboxOption) => {
setActiveOption(option);
onSelect?.({ target: option.element, value: option.value as string });
if (!isControlled) {
setSelectedOption(option);
onSelectionChange?.({
value: option.value,
previousValue: selectedOption?.value,
});
}
},
[isControlled]
);

const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => {
onKeyDown?.(event);

if (!keys.includes(event.key)) return;
event.preventDefault();
const enabledOptions = options.filter(
(option) => !isDisabledOption(option)
);

if (!enabledOptions.length) return;

const [up, down, home, end, enter, space] = keys;
const firstOption = enabledOptions[0];
const lastOption = enabledOptions[options.length - 1];
const currentOption = activeOption || firstOption;
const currentIndex = enabledOptions.indexOf(currentOption);
const allowCyclicalNavigation = navigation === 'cycle';

switch (event.key) {
case up:
const previousOption =
currentIndex === 0 && allowCyclicalNavigation
? lastOption
: enabledOptions[Math.max(currentIndex - 1, 0)];
setActiveOption(previousOption);
break;
case down:
const nextOption =
currentIndex === enabledOptions.length - 1 &&
allowCyclicalNavigation
? firstOption
: enabledOptions[
Math.min(currentIndex + 1, enabledOptions.length - 1)
];
setActiveOption(nextOption);
break;
case home:
setActiveOption(firstOption);
break;
case end:
setActiveOption(lastOption);
break;
case enter:
case space:
activeOption && handleSelect(activeOption);
break;
}
},
[options, activeOption, navigation]
);

const handleFocus = useCallback(
(event: React.FocusEvent<HTMLElement>) => {
if (!activeOption) {
const firstOption = options.find(
(option) => !isDisabledOption(option)
);
if (firstOption) {
setActiveOption(firstOption);
firstOption.element.focus();
}
} else if (event.target === listboxRef.current) {
activeOption.element.focus();
}

onFocus?.(event);
},
[options, activeOption]
);

return (
<Component
role="listbox"
ref={listboxRef}
tabIndex="0"
onKeyDown={handleKeyDown}
onFocus={handleFocus}
aria-activedescendant={
activeOption ? getOptionId(activeOption) : undefined
}
{...props}
>
<ListboxProvider
options={options}
active={activeOption}
selected={selectedOption}
setOptions={setOptions}
onSelect={handleSelect}
>
{children}
</ListboxProvider>
</Component>
);
}
);

Listbox.displayName = 'Listbox';

export default Listbox;
Loading

0 comments on commit 3368530

Please sign in to comment.