-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(Listbox): add Listbox component
- Loading branch information
Showing
8 changed files
with
634 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
} | ||
]} /> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.