123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- import {createContext, useCallback, useContext, useEffect, useMemo} from 'react';
- import {useFocusManager} from '@react-aria/focus';
- import {AriaGridListOptions} from '@react-aria/gridlist';
- import {AriaListBoxOptions} from '@react-aria/listbox';
- import {ListProps, useListState} from '@react-stately/list';
- import {defined} from 'sentry/utils';
- import domId from 'sentry/utils/domId';
- import {FormSize} from 'sentry/utils/theme';
- import {SelectContext} from './control';
- import {GridList} from './gridList';
- import {ListBox} from './listBox';
- import {SelectOption, SelectOptionOrSectionWithKey, SelectSection} from './types';
- import {
- getDisabledOptions,
- getHiddenOptions,
- getSelectedOptions,
- HiddenSectionToggle,
- } from './utils';
- export const SelectFilterContext = createContext(new Set<React.Key>());
- interface BaseListProps<Value extends React.Key>
- extends ListProps<any>,
- Omit<
- AriaListBoxOptions<any>,
- 'disabledKeys' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'
- >,
- Omit<
- AriaGridListOptions<any>,
- 'disabledKeys' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'
- > {
- items: SelectOptionOrSectionWithKey<Value>[];
- /**
- * This list's index number inside composite select menus.
- */
- compositeIndex?: number;
- /**
- * Whether to render a grid list rather than a list box.
- *
- * Unlike list boxes, grid lists are two-dimensional. Users can press Arrow Up/Down to
- * move between option rows, and Arrow Left/Right to move between columns. This is
- * useful when the selector contains options with smaller, interactive elements
- * (buttons/links) inside. Grid lists allow users to focus on those child elements and
- * interact with them, which isn't possible with list boxes.
- */
- grid?: boolean;
- /**
- * Custom function to determine whether an option is disabled. By default, an option
- * is considered disabled when it has {disabled: true}.
- */
- isOptionDisabled?: (opt: SelectOption<Value>) => boolean;
- /**
- * Text label to be rendered as heading on top of grid list.
- */
- label?: React.ReactNode;
- /**
- * To be called when the user toggle-selects a whole section (applicable when sections
- * have `showToggleAllButton` set to true.) Note: this will be called in addition to
- * and before `onChange`.
- */
- onSectionToggle?: (section: SelectSection<React.Key>) => void;
- size?: FormSize;
- /**
- * Upper limit for the number of options to display in the menu at a time. Users can
- * still find overflowing options by using the search box (if `searchable` is true).
- * If used, make sure to hoist selected options to the top, otherwise they may be
- * hidden from view.
- */
- sizeLimit?: number;
- /**
- * Message to be displayed when some options are hidden due to `sizeLimit`.
- */
- sizeLimitMessage?: string;
- }
- export interface SingleListProps<Value extends React.Key> extends BaseListProps<Value> {
- /**
- * Whether to close the menu. Accepts either a boolean value or a callback function
- * that receives the newly selected option and returns whether to close the menu.
- */
- closeOnSelect?: boolean | ((selectedOption: SelectOption<Value>) => boolean);
- defaultValue?: Value;
- multiple?: false;
- onChange?: (selectedOption: SelectOption<Value>) => void;
- value?: Value;
- }
- export interface MultipleListProps<Value extends React.Key> extends BaseListProps<Value> {
- multiple: true;
- /**
- * Whether to close the menu. Accepts either a boolean value or a callback function
- * that receives the newly selected options and returns whether to close the menu.
- */
- closeOnSelect?: boolean | ((selectedOptions: SelectOption<Value>[]) => boolean);
- defaultValue?: Value[];
- onChange?: (selectedOptions: SelectOption<Value>[]) => void;
- value?: Value[];
- }
- /**
- * A list containing selectable options. Depending on the `grid` prop, this may be a
- * grid list or list box.
- *
- * In composite selectors, there may be multiple self-contained lists, each
- * representing a select "region".
- */
- function List<Value extends React.Key>({
- items,
- value,
- defaultValue,
- onChange,
- grid,
- multiple,
- disallowEmptySelection,
- isOptionDisabled,
- shouldFocusWrap = true,
- shouldFocusOnHover = true,
- compositeIndex = 0,
- sizeLimit,
- sizeLimitMessage,
- closeOnSelect,
- ...props
- }: SingleListProps<Value> | MultipleListProps<Value>) {
- const {overlayState, registerListState, saveSelectedOptions, search} =
- useContext(SelectContext);
- const hiddenOptions = useMemo(
- () => getHiddenOptions(items, search, sizeLimit),
- [items, search, sizeLimit]
- );
- /**
- * Props to be passed into useListState()
- */
- const listStateProps = useMemo<Partial<ListProps<any>>>(() => {
- const disabledKeys = [
- ...getDisabledOptions(items, isOptionDisabled),
- ...hiddenOptions,
- ].map(String);
- if (multiple) {
- return {
- selectionMode: 'multiple',
- disabledKeys,
- // react-aria turns all keys into strings
- selectedKeys: value?.map(String),
- defaultSelectedKeys: defaultValue?.map(String),
- disallowEmptySelection,
- allowDuplicateSelectionEvents: true,
- onSelectionChange: selection => {
- const selectedOptions = getSelectedOptions<Value>(items, selection);
- // Save selected options in SelectContext, to update the trigger label
- saveSelectedOptions(compositeIndex, selectedOptions);
- onChange?.(selectedOptions);
- // Close menu if closeOnSelect is true
- if (
- typeof closeOnSelect === 'function'
- ? closeOnSelect(selectedOptions)
- : closeOnSelect
- ) {
- overlayState?.close();
- }
- },
- };
- }
- return {
- selectionMode: 'single',
- disabledKeys,
- // react-aria turns all keys into strings
- selectedKeys: defined(value) ? [String(value)] : undefined,
- defaultSelectedKeys: defined(defaultValue) ? [String(defaultValue)] : undefined,
- disallowEmptySelection: disallowEmptySelection ?? true,
- allowDuplicateSelectionEvents: true,
- onSelectionChange: selection => {
- const selectedOption = getSelectedOptions(items, selection)[0] ?? null;
- // Save selected options in SelectContext, to update the trigger label
- saveSelectedOptions(compositeIndex, selectedOption);
- onChange?.(selectedOption);
- // Close menu if closeOnSelect is true or undefined (by default single-selection
- // menus will close on selection)
- if (
- !defined(closeOnSelect) ||
- (typeof closeOnSelect === 'function'
- ? closeOnSelect(selectedOption)
- : closeOnSelect)
- ) {
- overlayState?.close();
- }
- },
- };
- }, [
- value,
- defaultValue,
- onChange,
- items,
- isOptionDisabled,
- hiddenOptions,
- multiple,
- disallowEmptySelection,
- compositeIndex,
- saveSelectedOptions,
- closeOnSelect,
- overlayState,
- ]);
- const listState = useListState({
- ...props,
- ...listStateProps,
- items,
- });
- // Register the initialized list state once on mount
- useEffect(() => {
- registerListState(compositeIndex, listState);
- saveSelectedOptions(
- compositeIndex,
- getSelectedOptions(items, listState.selectionManager.selectedKeys)
- );
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [listState.collection]);
- // In composite selects, focus should seamlessly move from one region (list) to
- // another when the ArrowUp/Down key is pressed
- const focusManager = useFocusManager();
- const firstFocusableKey = useMemo(() => {
- let firstKey = listState.collection.getFirstKey();
- while (
- firstKey &&
- (listState.collection.getItem(firstKey)?.type === 'section' ||
- listState.selectionManager.isDisabled(firstKey))
- ) {
- firstKey = listState.collection.getKeyAfter(firstKey);
- }
- return firstKey;
- }, [listState.collection, listState.selectionManager]);
- const lastFocusableKey = useMemo(() => {
- let lastKey = listState.collection.getLastKey();
- while (
- lastKey &&
- (listState.collection.getItem(lastKey)?.type === 'section' ||
- listState.selectionManager.isDisabled(lastKey))
- ) {
- lastKey = listState.collection.getKeyBefore(lastKey);
- }
- return lastKey;
- }, [listState.collection, listState.selectionManager]);
- /**
- * Keyboard event handler to seamlessly move focus from one composite list to another
- * when an arrow key is pressed. Returns a boolean indicating whether the keyboard
- * event was intercepted. If yes, then no further callback function should be run.
- */
- const keyDownHandler = useCallback(
- (e: React.KeyboardEvent<HTMLUListElement>) => {
- // Don't handle ArrowDown/Up key presses if focus already wraps
- if (shouldFocusWrap && !grid) {
- return true;
- }
- // Move focus to next region when ArrowDown is pressed and the last item in this
- // list is currently focused
- if (
- e.key === 'ArrowDown' &&
- listState.selectionManager.focusedKey === lastFocusableKey
- ) {
- focusManager.focusNext({
- wrap: true,
- accept: element =>
- (element.getAttribute('role') === 'option' ||
- element.getAttribute('role') === 'row') &&
- element.getAttribute('aria-disabled') !== 'true',
- });
- return false; // event intercepted, don't run any further callbacks
- }
- // Move focus to previous region when ArrowUp is pressed and the first item in this
- // list is currently focused
- if (
- e.key === 'ArrowUp' &&
- listState.selectionManager.focusedKey === firstFocusableKey
- ) {
- focusManager.focusPrevious({
- wrap: true,
- accept: element =>
- (element.getAttribute('role') === 'option' ||
- element.getAttribute('role') === 'row') &&
- element.getAttribute('aria-disabled') !== 'true',
- });
- return false; // event intercepted, don't run any further callbacks
- }
- return true;
- },
- [
- focusManager,
- firstFocusableKey,
- lastFocusableKey,
- listState.selectionManager.focusedKey,
- shouldFocusWrap,
- grid,
- ]
- );
- const listId = useMemo(() => domId('select-list-'), []);
- const sections = useMemo(
- () =>
- [...listState.collection].filter(
- item =>
- // This is a section
- item.type === 'section' &&
- // Options inside the section haven't been all filtered out
- ![...item.childNodes].every(child => hiddenOptions.has(child.props.value))
- ),
- [listState.collection, hiddenOptions]
- );
- return (
- <SelectFilterContext.Provider value={hiddenOptions}>
- {grid ? (
- <GridList
- {...props}
- id={listId}
- listState={listState}
- sizeLimitMessage={sizeLimitMessage}
- keyDownHandler={keyDownHandler}
- />
- ) : (
- <ListBox
- {...props}
- id={listId}
- listState={listState}
- shouldFocusWrap={shouldFocusWrap}
- shouldFocusOnHover={shouldFocusOnHover}
- sizeLimitMessage={sizeLimitMessage}
- keyDownHandler={keyDownHandler}
- />
- )}
- {multiple &&
- sections.map(
- section =>
- section.value.showToggleAllButton && (
- <HiddenSectionToggle
- key={section.key}
- item={section}
- listState={listState}
- listId={listId}
- onToggle={props.onSectionToggle}
- />
- )
- )}
- </SelectFilterContext.Provider>
- );
- }
- export {List};
|