list.tsx 12 KB

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