list.tsx 12 KB

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