list.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. import {createContext, useCallback, useContext, useEffect, useMemo} from 'react';
  2. import {useFocusManager} from '@react-aria/focus';
  3. import {AriaGridListOptions} from '@react-aria/gridlist';
  4. import {AriaListBoxOptions} from '@react-aria/listbox';
  5. import {ListProps, useListState} from '@react-stately/list';
  6. import {defined} from 'sentry/utils';
  7. import domId from 'sentry/utils/domId';
  8. import {FormSize} from 'sentry/utils/theme';
  9. import {SelectContext} from './control';
  10. import {GridList} from './gridList';
  11. import {ListBox} from './listBox';
  12. import {SelectOption, SelectOptionOrSectionWithKey, SelectSection} from './types';
  13. import {
  14. getDisabledOptions,
  15. getEscapedKey,
  16. getHiddenOptions,
  17. getSelectedOptions,
  18. HiddenSectionToggle,
  19. } from './utils';
  20. export const SelectFilterContext = createContext(new Set<React.Key>());
  21. interface BaseListProps<Value extends React.Key>
  22. extends ListProps<any>,
  23. Omit<
  24. AriaListBoxOptions<any>,
  25. | 'disabledKeys'
  26. | 'selectedKeys'
  27. | 'defaultSelectedKeys'
  28. | 'onSelectionChange'
  29. | 'autoFocus'
  30. >,
  31. Omit<
  32. AriaGridListOptions<any>,
  33. | 'disabledKeys'
  34. | 'selectedKeys'
  35. | 'defaultSelectedKeys'
  36. | 'onSelectionChange'
  37. | 'autoFocus'
  38. > {
  39. items: SelectOptionOrSectionWithKey<Value>[];
  40. /**
  41. * This list's index number inside composite select menus.
  42. */
  43. compositeIndex?: number;
  44. /**
  45. * Whether to render a grid list rather than a list box.
  46. *
  47. * Unlike list boxes, grid lists are two-dimensional. Users can press Arrow Up/Down to
  48. * move between option rows, and Arrow Left/Right to move between columns. This is
  49. * useful when the selector contains options with smaller, interactive elements
  50. * (buttons/links) inside. Grid lists allow users to focus on those child elements and
  51. * interact with them, which isn't possible with list boxes.
  52. */
  53. grid?: boolean;
  54. /**
  55. * Custom function to determine whether an option is disabled. By default, an option
  56. * is considered disabled when it has {disabled: true}.
  57. */
  58. isOptionDisabled?: (opt: SelectOption<Value>) => boolean;
  59. /**
  60. * Text label to be rendered as heading on top of grid list.
  61. */
  62. label?: React.ReactNode;
  63. /**
  64. * To be called when the user toggle-selects a whole section (applicable when sections
  65. * have `showToggleAllButton` set to true.) Note: this will be called in addition to
  66. * and before `onChange`.
  67. */
  68. onSectionToggle?: (section: SelectSection<React.Key>) => void;
  69. size?: FormSize;
  70. /**
  71. * Upper limit for the number of options to display in the menu at a time. Users can
  72. * still find overflowing options by using the search box (if `searchable` is true).
  73. * If used, make sure to hoist selected options to the top, otherwise they may be
  74. * hidden from view.
  75. */
  76. sizeLimit?: number;
  77. /**
  78. * Message to be displayed when some options are hidden due to `sizeLimit`.
  79. */
  80. sizeLimitMessage?: string;
  81. }
  82. export interface SingleListProps<Value extends React.Key> extends BaseListProps<Value> {
  83. /**
  84. * Whether to close the menu. Accepts either a boolean value or a callback function
  85. * that receives the newly selected option and returns whether to close the menu.
  86. */
  87. closeOnSelect?: boolean | ((selectedOption: SelectOption<Value>) => boolean);
  88. defaultValue?: Value;
  89. multiple?: false;
  90. onChange?: (selectedOption: SelectOption<Value>) => void;
  91. value?: Value;
  92. }
  93. export interface MultipleListProps<Value extends React.Key> extends BaseListProps<Value> {
  94. multiple: true;
  95. /**
  96. * Whether to close the menu. Accepts either a boolean value or a callback function
  97. * that receives the newly selected options and returns whether to close the menu.
  98. */
  99. closeOnSelect?: boolean | ((selectedOptions: SelectOption<Value>[]) => boolean);
  100. defaultValue?: Value[];
  101. onChange?: (selectedOptions: SelectOption<Value>[]) => void;
  102. value?: Value[];
  103. }
  104. /**
  105. * A list containing selectable options. Depending on the `grid` prop, this may be a
  106. * grid list or list box.
  107. *
  108. * In composite selectors, there may be multiple self-contained lists, each
  109. * representing a select "region".
  110. */
  111. function List<Value extends React.Key>({
  112. items,
  113. value,
  114. defaultValue,
  115. onChange,
  116. grid,
  117. multiple,
  118. disallowEmptySelection,
  119. isOptionDisabled,
  120. shouldFocusWrap = true,
  121. shouldFocusOnHover = true,
  122. compositeIndex = 0,
  123. sizeLimit,
  124. sizeLimitMessage,
  125. closeOnSelect,
  126. ...props
  127. }: SingleListProps<Value> | MultipleListProps<Value>) {
  128. const {overlayState, registerListState, saveSelectedOptions, search} =
  129. useContext(SelectContext);
  130. const hiddenOptions = useMemo(
  131. () => getHiddenOptions(items, search, sizeLimit),
  132. [items, search, sizeLimit]
  133. );
  134. /**
  135. * Props to be passed into useListState()
  136. */
  137. const listStateProps = useMemo<Partial<ListProps<any>>>(() => {
  138. const disabledKeys = [
  139. ...getDisabledOptions(items, isOptionDisabled),
  140. ...hiddenOptions,
  141. ].map(getEscapedKey);
  142. if (multiple) {
  143. return {
  144. selectionMode: 'multiple',
  145. disabledKeys,
  146. // react-aria turns all keys into strings
  147. selectedKeys: value?.map(getEscapedKey),
  148. defaultSelectedKeys: defaultValue?.map(getEscapedKey),
  149. disallowEmptySelection,
  150. allowDuplicateSelectionEvents: true,
  151. onSelectionChange: selection => {
  152. const selectedOptions = getSelectedOptions<Value>(items, selection);
  153. // Save selected options in SelectContext, to update the trigger label
  154. saveSelectedOptions(compositeIndex, selectedOptions);
  155. onChange?.(selectedOptions);
  156. // Close menu if closeOnSelect is true
  157. if (
  158. typeof closeOnSelect === 'function'
  159. ? closeOnSelect(selectedOptions)
  160. : closeOnSelect
  161. ) {
  162. overlayState?.close();
  163. }
  164. },
  165. };
  166. }
  167. return {
  168. selectionMode: 'single',
  169. disabledKeys,
  170. // react-aria turns all keys into strings
  171. selectedKeys: defined(value) ? [getEscapedKey(value)] : undefined,
  172. defaultSelectedKeys: defined(defaultValue)
  173. ? [getEscapedKey(defaultValue)]
  174. : undefined,
  175. disallowEmptySelection: disallowEmptySelection ?? true,
  176. allowDuplicateSelectionEvents: true,
  177. onSelectionChange: selection => {
  178. const selectedOption = getSelectedOptions(items, selection)[0] ?? null;
  179. // Save selected options in SelectContext, to update the trigger label
  180. saveSelectedOptions(compositeIndex, selectedOption);
  181. onChange?.(selectedOption);
  182. // Close menu if closeOnSelect is true or undefined (by default single-selection
  183. // menus will close on selection)
  184. if (
  185. !defined(closeOnSelect) ||
  186. (typeof closeOnSelect === 'function'
  187. ? closeOnSelect(selectedOption)
  188. : closeOnSelect)
  189. ) {
  190. overlayState?.close();
  191. }
  192. },
  193. };
  194. }, [
  195. value,
  196. defaultValue,
  197. onChange,
  198. items,
  199. isOptionDisabled,
  200. hiddenOptions,
  201. multiple,
  202. disallowEmptySelection,
  203. compositeIndex,
  204. saveSelectedOptions,
  205. closeOnSelect,
  206. overlayState,
  207. ]);
  208. const listState = useListState({
  209. ...props,
  210. ...listStateProps,
  211. items,
  212. });
  213. // Register the initialized list state once on mount
  214. useEffect(() => {
  215. registerListState(compositeIndex, listState);
  216. saveSelectedOptions(
  217. compositeIndex,
  218. getSelectedOptions(items, listState.selectionManager.selectedKeys)
  219. );
  220. // eslint-disable-next-line react-hooks/exhaustive-deps
  221. }, [listState.collection]);
  222. // In composite selects, focus should seamlessly move from one region (list) to
  223. // another when the ArrowUp/Down key is pressed
  224. const focusManager = useFocusManager();
  225. const firstFocusableKey = useMemo(() => {
  226. let firstKey = listState.collection.getFirstKey();
  227. while (
  228. firstKey &&
  229. (listState.collection.getItem(firstKey)?.type === 'section' ||
  230. listState.selectionManager.isDisabled(firstKey))
  231. ) {
  232. firstKey = listState.collection.getKeyAfter(firstKey);
  233. }
  234. return firstKey;
  235. }, [listState.collection, listState.selectionManager]);
  236. const lastFocusableKey = useMemo(() => {
  237. let lastKey = listState.collection.getLastKey();
  238. while (
  239. lastKey &&
  240. (listState.collection.getItem(lastKey)?.type === 'section' ||
  241. listState.selectionManager.isDisabled(lastKey))
  242. ) {
  243. lastKey = listState.collection.getKeyBefore(lastKey);
  244. }
  245. return lastKey;
  246. }, [listState.collection, listState.selectionManager]);
  247. /**
  248. * Keyboard event handler to seamlessly move focus from one composite list to another
  249. * when an arrow key is pressed. Returns a boolean indicating whether the keyboard
  250. * event was intercepted. If yes, then no further callback function should be run.
  251. */
  252. const keyDownHandler = useCallback(
  253. (e: React.KeyboardEvent<HTMLUListElement>) => {
  254. // Don't handle ArrowDown/Up key presses if focus already wraps
  255. if (shouldFocusWrap && !grid) {
  256. return true;
  257. }
  258. // Move focus to next region when ArrowDown is pressed and the last item in this
  259. // list is currently focused
  260. if (
  261. e.key === 'ArrowDown' &&
  262. listState.selectionManager.focusedKey === lastFocusableKey
  263. ) {
  264. focusManager?.focusNext({
  265. wrap: true,
  266. accept: element =>
  267. (element.getAttribute('role') === 'option' ||
  268. element.getAttribute('role') === 'row') &&
  269. element.getAttribute('aria-disabled') !== 'true',
  270. });
  271. return false; // event intercepted, don't run any further callbacks
  272. }
  273. // Move focus to previous region when ArrowUp is pressed and the first item in this
  274. // list is currently focused
  275. if (
  276. e.key === 'ArrowUp' &&
  277. listState.selectionManager.focusedKey === firstFocusableKey
  278. ) {
  279. focusManager?.focusPrevious({
  280. wrap: true,
  281. accept: element =>
  282. (element.getAttribute('role') === 'option' ||
  283. element.getAttribute('role') === 'row') &&
  284. element.getAttribute('aria-disabled') !== 'true',
  285. });
  286. return false; // event intercepted, don't run any further callbacks
  287. }
  288. return true;
  289. },
  290. [
  291. focusManager,
  292. firstFocusableKey,
  293. lastFocusableKey,
  294. listState.selectionManager.focusedKey,
  295. shouldFocusWrap,
  296. grid,
  297. ]
  298. );
  299. const listId = useMemo(() => domId('select-list-'), []);
  300. const sections = useMemo(
  301. () =>
  302. [...listState.collection].filter(
  303. item =>
  304. // This is a section
  305. item.type === 'section' &&
  306. // Options inside the section haven't been all filtered out
  307. ![...item.childNodes].every(child => hiddenOptions.has(child.props.value))
  308. ),
  309. [listState.collection, hiddenOptions]
  310. );
  311. return (
  312. <SelectFilterContext.Provider value={hiddenOptions}>
  313. {grid ? (
  314. <GridList
  315. {...props}
  316. id={listId}
  317. listState={listState}
  318. sizeLimitMessage={sizeLimitMessage}
  319. keyDownHandler={keyDownHandler}
  320. />
  321. ) : (
  322. <ListBox
  323. {...props}
  324. id={listId}
  325. listState={listState}
  326. shouldFocusWrap={shouldFocusWrap}
  327. shouldFocusOnHover={shouldFocusOnHover}
  328. sizeLimitMessage={sizeLimitMessage}
  329. keyDownHandler={keyDownHandler}
  330. />
  331. )}
  332. {multiple &&
  333. sections.map(
  334. section =>
  335. section.value.showToggleAllButton && (
  336. <HiddenSectionToggle
  337. key={section.key}
  338. item={section}
  339. listState={listState}
  340. listId={listId}
  341. onToggle={props.onSectionToggle}
  342. />
  343. )
  344. )}
  345. </SelectFilterContext.Provider>
  346. );
  347. }
  348. export {List};