Skip to content

Commit

Permalink
feat(react): add multiselect to Listbox component (#1763)
Browse files Browse the repository at this point in the history
  • Loading branch information
scurker authored Dec 18, 2024
1 parent b64aa4e commit afa8fb4
Show file tree
Hide file tree
Showing 6 changed files with 355 additions and 42 deletions.
26 changes: 25 additions & 1 deletion docs/pages/components/Listbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ function ControlledListboxExample() {
<Listbox
aria-labelledby="listbox-controlled-example"
value={value}
onSelect={handleSelect}
onSelectionChange={handleSelect}
>
<ListboxOption>One</ListboxOption>
<ListboxOption>Two</ListboxOption>
Expand All @@ -128,6 +128,25 @@ Uncontrolled listboxes will automatically set `aria-selected="true"` for the sel
</>
```

### Multiselect

Listboxes can also support multiple selection of listbox options.

```jsx example
<>
<div id="listbox-multiselect-example">Multiselect Listbox</div>
<Listbox aria-labelledby="listbox-multiselect-example" multiselect>
<ListboxOption>One</ListboxOption>
<ListboxOption>Two</ListboxOption>
<ListboxOption>Three</ListboxOption>
</Listbox>
</>
```

<Note>
Multiselect Listbox components will pass in array values for the selected options in `onSelectionChange` and expect an array of values for `value` and `defaultValue` props.
</Note>

## Props

### Listbox
Expand Down Expand Up @@ -180,6 +199,11 @@ Uncontrolled listboxes will automatically set `aria-selected="true"` for the sel
type: 'boolean',
description: 'When set, sets the listbox option as "aria-disabled="true" and removes the element from key navigation.'
},
{
name: 'selected',
type: 'boolean',
description: 'When set, sets the listbox option as "aria-selected="true".'
},
{
name: 'activeClass',
type: 'string',
Expand Down
7 changes: 5 additions & 2 deletions packages/react/src/components/Combobox/ComboboxOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ const ComboboxOption = forwardRef<HTMLLIElement, ComboboxOptionProps>(
});
const isActive =
!!active?.element && active.element === comboboxOptionRef.current;
const isSelected =
!!selected?.element && selected.element === comboboxOptionRef.current;
const isSelected = !!(
selected &&
!!selected[0]?.element &&
selected[0].element === comboboxOptionRef.current
);
const isMatching =
(typeof matches === 'boolean' && matches) ||
(typeof matches === 'function' && matches(children));
Expand Down
137 changes: 105 additions & 32 deletions packages/react/src/components/Listbox/Listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,34 @@ import useSharedRef from '../../utils/useSharedRef';

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

interface ListboxProps
interface BaseListboxProps
extends PolymorphicProps<
Omit<React.HTMLAttributes<HTMLElement>, 'onSelect'>
Omit<React.HTMLAttributes<HTMLElement>, 'onSelect' | 'defaultValue'>
> {
value?: ListboxValue;
navigation?: 'cycle' | 'bound';
onSelectionChange?: <T extends HTMLElement = HTMLElement>({
value
}: {
onActiveChange?: (option: ListboxOption) => void;
}

interface SingleSelectListboxProps extends BaseListboxProps {
multiselect?: false;
value?: ListboxValue;
defaultValue?: ListboxValue;
onSelectionChange?: <T extends HTMLElement = HTMLElement>(props: {
target: T;
previousValue: ListboxValue;
value: ListboxValue;
}) => void;
onActiveChange?: (option: ListboxOption) => void;
}

interface MultiSelectListboxProps extends BaseListboxProps {
multiselect: true;
value?: ListboxValue[];
defaultValue?: ListboxValue[];
onSelectionChange?: <T extends HTMLElement = HTMLElement>(props: {
target: T;
previousValue: ListboxValue[];
value: ListboxValue[];
}) => void;
}

// id for listbox options should always be defined since it should
Expand All @@ -45,14 +59,18 @@ const optionMatchesValue = (option: ListboxOption, value: unknown): boolean =>
typeof option.value !== 'undefined' &&
option.value === value;

const Listbox = forwardRef<HTMLElement, ListboxProps>(
const Listbox = forwardRef<
HTMLElement,
SingleSelectListboxProps | MultiSelectListboxProps
>(
(
{
as: Component = 'ul',
children,
defaultValue,
value,
navigation = 'bound',
multiselect = false,
onKeyDown,
onFocus,
onSelectionChange,
Expand All @@ -65,25 +83,36 @@ const Listbox = forwardRef<HTMLElement, ListboxProps>(
const [activeOption, setActiveOption] = useState<ListboxOption | null>(
null
);
const [selectedOption, setSelectedOption] = useState<ListboxOption | null>(
null
);
const [selectedOptions, setSelectedOptions] = useState<ListboxOption[]>([]);
const listboxRef = useSharedRef<HTMLElement>(ref);
const isControlled = typeof value !== 'undefined';

useLayoutEffect(() => {
if (!isControlled && selectedOption) {
if (!isControlled && selectedOptions.length > 0) {
return;
}

const listboxValue = isControlled ? value : defaultValue;
const matchingOption = options.find((option) =>
optionMatchesValue(option, listboxValue)
);
if (!listboxValue) {
return;
}

setSelectedOption(matchingOption || null);
setActiveOption(matchingOption || null);
}, [isControlled, options, value]);
if (multiselect) {
const matchingOptions = options.filter((option) =>
(listboxValue as ListboxValue[]).find((value) =>
optionMatchesValue(option, value)
)
);
setSelectedOptions(matchingOptions);
setActiveOption(matchingOptions[0] || null);
} else {
const matchingOption = options.find((option) =>
optionMatchesValue(option, listboxValue)
);
setSelectedOptions(matchingOption ? [matchingOption] : []);
setActiveOption(matchingOption || null);
}
}, [isControlled, options, value, defaultValue]);

useEffect(() => {
if (activeOption) {
Expand All @@ -94,17 +123,56 @@ const Listbox = forwardRef<HTMLElement, ListboxProps>(
const handleSelect = useCallback(
(option: ListboxOption) => {
setActiveOption(option);
const optionIsSelected = selectedOptions.some(
(selected) => selected.element === option.element
);
const previousValues = selectedOptions.map(
(selected) => selected.value
);

// istanbul ignore else
if (!isControlled) {
setSelectedOption(option);
if (!multiselect) {
setSelectedOptions([option]);
} else {
setSelectedOptions(
optionIsSelected
? [
...selectedOptions.filter(
(selected) => selected.element !== option.element
)
]
: [...selectedOptions, option]
);
}
}

if (multiselect) {
(onSelectionChange as MultiSelectListboxProps['onSelectionChange'])?.(
{
target: option.element,
value: optionIsSelected
? selectedOptions
.filter(
(selectedOption) =>
selectedOption.element !== option.element
)
.map((selectedOption) => selectedOption.value)
: [...previousValues, option.value],
previousValue: previousValues
}
);
} else {
(
onSelectionChange as SingleSelectListboxProps['onSelectionChange']
)?.({
target: option.element,
value: option.value,
previousValue: selectedOptions[0]?.value
});
}
onSelectionChange?.({
target: option.element,
value: option.value,
previousValue: selectedOption?.value
});
},
[isControlled, selectedOption]
[isControlled, selectedOptions, multiselect, onSelectionChange]
);

const handleKeyDown = useCallback(
Expand Down Expand Up @@ -170,12 +238,12 @@ const Listbox = forwardRef<HTMLElement, ListboxProps>(
break;
}
},
[options, activeOption, navigation]
[options, activeOption, navigation, handleSelect]
);

const handleFocus = useCallback(
(event: React.FocusEvent<HTMLElement>) => {
if (!activeOption && !selectedOption) {
if (!activeOption) {
const firstOption = options.find(
(option) => !isDisabledOption(option)
);
Expand All @@ -184,13 +252,16 @@ const Listbox = forwardRef<HTMLElement, ListboxProps>(
setActiveOption(firstOption);
}
// istanbul ignore else
} else if (event.target === listboxRef.current) {
setActiveOption(selectedOption);
} else if (
selectedOptions.length &&
event.target === listboxRef.current
) {
setActiveOption(selectedOptions[selectedOptions.length - 1]);
}

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

return (
Expand All @@ -200,6 +271,7 @@ const Listbox = forwardRef<HTMLElement, ListboxProps>(
tabIndex="0"
onKeyDown={handleKeyDown}
onFocus={handleFocus}
aria-multiselectable={multiselect ? true : undefined}
aria-activedescendant={
activeOption ? getOptionId(activeOption) : undefined
}
Expand All @@ -208,7 +280,8 @@ const Listbox = forwardRef<HTMLElement, ListboxProps>(
<ListboxProvider
options={options}
active={activeOption}
selected={selectedOption}
multiselect={multiselect}
selected={selectedOptions}
setOptions={setOptions}
onSelect={handleSelect}
>
Expand All @@ -217,7 +290,7 @@ const Listbox = forwardRef<HTMLElement, ListboxProps>(
</Component>
);
}
) as PolymorphicComponent<ListboxProps>;
) as PolymorphicComponent<SingleSelectListboxProps | MultiSelectListboxProps>;

Listbox.displayName = 'Listbox';

Expand Down
8 changes: 6 additions & 2 deletions packages/react/src/components/Listbox/ListboxContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ type ListboxOption<Element = HTMLElement, Value = string | number> = {
type ListboxContext<T extends ListboxOption> = {
options: T[];
active: T | null;
selected: T | null;
selected: T[] | null;
multiselect: boolean;
setOptions: React.Dispatch<React.SetStateAction<T[]>>;
onSelect: (option: T) => void;
};
Expand All @@ -24,6 +25,7 @@ const ListboxContext = createContext({
options: [],
active: null,
selected: null,
multiselect: false,
setOptions: () => null,
onSelect: () => null
});
Expand All @@ -32,6 +34,7 @@ function ListboxProvider<T extends ListboxOption>({
options,
active,
selected,
multiselect,
setOptions,
onSelect,
children
Expand All @@ -44,10 +47,11 @@ function ListboxProvider<T extends ListboxOption>({
options,
active,
selected,
multiselect,
setOptions,
onSelect
}),
[options, active, selected, setOptions]
[options, active, selected, multiselect, setOptions]
);

return <Provider value={value}>{children}</Provider>;
Expand Down
14 changes: 10 additions & 4 deletions packages/react/src/components/Listbox/ListboxOption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface ListboxOptionProps
extends PolymorphicProps<React.HTMLAttributes<HTMLElement>> {
value?: ListboxValue;
disabled?: boolean;
selected?: boolean;
activeClass?: string;
}

Expand All @@ -30,6 +31,7 @@ const ListboxOption = forwardRef<HTMLElement, ListboxOptionProps>(
children,
value,
disabled,
selected: selectedProp,
activeClass = 'ListboxOption--active',
onClick,
...props
Expand All @@ -39,10 +41,14 @@ const ListboxOption = forwardRef<HTMLElement, ListboxOptionProps>(
const { active, selected, setOptions, onSelect } = useListboxContext();
const listboxOptionRef = useSharedRef<HTMLElement>(ref);
const [id] = propId ? [propId] : useId(1, 'listbox-option');
const isActive =
active !== null && active.element === listboxOptionRef.current;
const isActive = active?.element === listboxOptionRef.current;
const isSelected =
selected !== null && selected.element === listboxOptionRef.current;
typeof selectedProp === 'boolean'
? selectedProp
: selected !== null &&
!!selected.find(
(option) => option.element === listboxOptionRef.current
);
const optionValue =
typeof value !== 'undefined'
? value
Expand Down Expand Up @@ -98,7 +104,7 @@ const ListboxOption = forwardRef<HTMLElement, ListboxOptionProps>(
onSelect({ element: listboxOptionRef.current, value: optionValue });
onClick?.(event);
},
[optionValue]
[optionValue, onSelect, onClick, disabled]
);

return (
Expand Down
Loading

0 comments on commit afa8fb4

Please sign in to comment.