Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
scurker committed Aug 17, 2023
1 parent ac0947b commit 99df32d
Show file tree
Hide file tree
Showing 15 changed files with 972 additions and 9 deletions.
75 changes: 75 additions & 0 deletions docs/pages/components/Combobox.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
title: Combobox
description: An input component to display a list of suggested options matching the current text input.
source: https://github.com/dequelabs/cauldron/tree/develop/packages/react/src/components/Combobox/Combobox.tsx
---

import { Combobox, ComboboxItem, ComboboxGroup } from '@deque/cauldron-react'

```js
import {
Combobox,
ComboboxItem,
ComboboxGroup
} from '@deque/cauldron-react'
```

## Examples

## With Items

```jsx example
function ComboboxWithItemPropExample() {
const items = [
{ label: 'Red', value: 'Red' },
{ label: 'Orange', value: 'Orange' },
{ label: 'Yellow', value: 'Yellow' },
{ label: 'Green', value: 'Green' },
{ label: 'Blue', value: 'Blue' }
]
return (
<Combobox label="Favorite Color" items={items} />
)
}
```

## With Children

```jsx example
<Combobox label="Fruit" defaultValue="Peach">
<ComboboxItem value="Apple">🍎 Apple</ComboboxItem>
<ComboboxItem value="Banana">🍌 Banana</ComboboxItem>
<ComboboxItem value="Cucumber">🥒 Cucumber</ComboboxItem>
<ComboboxItem value="Orange">🍊 Orange</ComboboxItem>
<ComboboxItem value="Peach">🍑 Peach</ComboboxItem>
<ComboboxItem value="Pear">🍐 Pear</ComboboxItem>
</Combobox>
```

## Grouping

```jsx example
<Combobox label="Countries">
<ComboboxGroup label="North America">
<ComboboxItem>Canada</ComboboxItem>
<ComboboxItem>Mexico</ComboboxItem>
<ComboboxItem>United States</ComboboxItem>
</ComboboxGroup>
<ComboboxGroup label="South America">
<ComboboxItem>Argentina</ComboboxItem>
<ComboboxItem>Bolivia</ComboboxItem>
<ComboboxItem>Brazil</ComboboxItem>
<ComboboxItem>Chile</ComboboxItem>
<ComboboxItem>Columbia</ComboboxItem>
<ComboboxItem>Ecuador</ComboboxItem>
<ComboboxItem>Falkland Islands</ComboboxItem>
<ComboboxItem>French Guiana</ComboboxItem>
<ComboboxItem>Guyana</ComboboxItem>
<ComboboxItem>Paraguay</ComboboxItem>
<ComboboxItem>Peru</ComboboxItem>
<ComboboxItem>Suriname</ComboboxItem>
<ComboboxItem>Uruguay</ComboboxItem>
<ComboboxItem>Venezuela</ComboboxItem>
</ComboboxGroup>
</Combobox>
```
170 changes: 170 additions & 0 deletions docs/pages/components/Listbox.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
---
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](./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

```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

```jsx example
<>
<div id="listbox-uncontrolled-example">Uncontrolled Listbox</div>
<Listbox navigation="cycle" aria-labelledby="listbox-uncontrolled-example" value="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'
}
]} />
132 changes: 132 additions & 0 deletions packages/react/src/components/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import React, {
forwardRef,
useState,
useEffect,
useRef,
useCallback
} from 'react';
import classnames from 'classnames';
import { useId } from 'react-id-generator';
import { ContentNode } from '../../types';
import Listbox from '../Listbox';
import ComboboxItem from './ComboboxItem';

interface ComboboxItem<Label = string, Value = string> {
key?: string;
label: Label;
value?: Value;
}

type Props = {
label: ContentNode;
items?: ComboboxItem[];
value?: ComboboxItem | string | number;
defaultValue?: ComboboxItem | string | number;
} & React.HTMLAttributes<Omit<HTMLInputElement, 'value' | 'defaultValue'>>;

function isComboboxItem(value: unknown): value is ComboboxItem {
return value !== null && typeof value === 'object' && 'label' in value;
}

const Combobox = forwardRef<HTMLInputElement, Props>(
(
{
id: propId,
className,
label,
children,
items = [],
value: propValue,
defaultValue,
...props
},
ref
): JSX.Element => {
const [value, setValue] = useState<Props['value']>(
defaultValue || propValue
);
const [open, setOpen] = useState(false);
const [id] = propId ? [propId] : useId(1, 'combobox');
const inputRef = useRef<HTMLInputElement>(null);
const isControlled = typeof value !== 'undefined';

useEffect(() => {
setValue(propValue);
}, [propValue]);

useEffect(() => {
const focusHandler = () => {
setOpen(true);
};
const blurHandler = () => setOpen(false);

inputRef.current?.addEventListener('focus', focusHandler);
inputRef.current?.addEventListener('blur', blurHandler);

return () => {
inputRef.current?.removeEventListener('focus', focusHandler);
inputRef.current?.removeEventListener('blur', blurHandler);
};
}, []);

const comboboxItems =
children ||
items.map((item, index) => (
<ComboboxItem key={item.key || index} id={`${id}-option-${index + 1}`}>
{item.label}
</ComboboxItem>
));

const inputValue = isComboboxItem(value) ? value.label : value;

const handleSelect = useCallback(
({ value: listboxValue }: { value: string | number }) => {
if (!isControlled) {
setValue(listboxValue);
}
},
[isControlled]
);

return (
<div className={classnames('Combobox', className)} ref={ref}>
<label
className="Field__label"
id={`${id}-label`}
htmlFor={`${id}-input`}
>
{label}
</label>
<div className="Combobox__input">
<input
type="text"
id={`${id}-input`}
ref={inputRef}
value={inputValue}
{...props}
role="combobox"
aria-autocomplete="list"
aria-controls={`${id}-listbox`}
aria-expanded={open}
aria-haspopup="listbox"
/>
<span className="Combobox__arrow" />
</div>
<Listbox
className="Combobox__listbox"
role="listbox"
aria-labelledby={`${id}-label`}
id={`${id}-listbox`}
value={inputValue}
onSelect={handleSelect}
>
{comboboxItems}
</Listbox>
</div>
);
}
);

Combobox.displayName = 'Combobox';

export default Combobox;
Loading

0 comments on commit 99df32d

Please sign in to comment.