utils.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import {useCallback, useMemo} from 'react';
  2. import {useFocus, usePress} from '@react-aria/interactions';
  3. import {mergeProps} from '@react-aria/utils';
  4. import {VisuallyHidden} from '@react-aria/visually-hidden';
  5. import type {ListState} from '@react-stately/list';
  6. import type {SelectionManager} from '@react-stately/selection';
  7. import type {Node, Selection} from '@react-types/shared';
  8. import {t} from 'sentry/locale';
  9. import {SectionToggleButton} from './styles';
  10. import type {
  11. SelectOption,
  12. SelectOptionOrSection,
  13. SelectOptionOrSectionWithKey,
  14. SelectOptionWithKey,
  15. SelectSection,
  16. } from './types';
  17. export function getEscapedKey<Value extends React.Key | undefined>(value: Value): string {
  18. return CSS.escape(String(value));
  19. }
  20. export function getItemsWithKeys<Value extends React.Key>(
  21. options: SelectOption<Value>[]
  22. ): SelectOptionWithKey<Value>[];
  23. export function getItemsWithKeys<Value extends React.Key>(
  24. options: SelectOptionOrSection<Value>[]
  25. ): SelectOptionOrSectionWithKey<Value>[];
  26. export function getItemsWithKeys<Value extends React.Key>(
  27. options: SelectOptionOrSection<Value>[]
  28. ): SelectOptionOrSectionWithKey<Value>[] {
  29. return options.map((item, i) => {
  30. if ('options' in item) {
  31. return {
  32. ...item,
  33. key: item.key ?? `options-${i}`,
  34. options: getItemsWithKeys(item.options),
  35. };
  36. }
  37. return {...item, key: getEscapedKey(item.value)};
  38. });
  39. }
  40. /**
  41. * Recursively finds the selected option(s) from an options array. Useful for
  42. * non-flat arrays that contain sections (groups of options).
  43. */
  44. export function getSelectedOptions<Value extends React.Key>(
  45. items: SelectOptionOrSectionWithKey<Value>[],
  46. selection: Selection
  47. ): SelectOption<Value>[] {
  48. return items.reduce<SelectOption<Value>[]>((acc, cur) => {
  49. // If this is a section
  50. if ('options' in cur) {
  51. return acc.concat(getSelectedOptions(cur.options, selection));
  52. }
  53. // If this is an option
  54. if (selection === 'all' || selection.has(getEscapedKey(cur.value))) {
  55. const {key: _key, ...opt} = cur;
  56. return acc.concat(opt);
  57. }
  58. return acc;
  59. }, []);
  60. }
  61. /**
  62. * Recursively finds the selected option(s) from an options array. Useful for non-flat
  63. * arrays that contain sections (groups of options). Returns the values of options that
  64. * were removed.
  65. */
  66. export function getDisabledOptions<Value extends React.Key>(
  67. items: SelectOptionOrSection<Value>[],
  68. isOptionDisabled?: (opt: SelectOption<Value>) => boolean
  69. ): Value[] {
  70. return items.reduce((acc: Value[], cur) => {
  71. // If this is a section
  72. if ('options' in cur) {
  73. if (cur.disabled) {
  74. // If the entire section is disabled, then mark all of its children as disabled
  75. return acc.concat(cur.options.map(opt => opt.value));
  76. }
  77. return acc.concat(getDisabledOptions(cur.options, isOptionDisabled));
  78. }
  79. // If this is an option
  80. if (isOptionDisabled?.(cur) ?? cur.disabled) {
  81. return acc.concat(cur.value);
  82. }
  83. return acc;
  84. }, []);
  85. }
  86. /**
  87. * Recursively finds the option(s) that don't match the designated search string or are
  88. * outside the list box's count limit.
  89. */
  90. export function getHiddenOptions<Value extends React.Key>(
  91. items: SelectOptionOrSection<Value>[],
  92. search: string,
  93. limit: number = Infinity
  94. ): Set<Value> {
  95. //
  96. // First, filter options using `search` value
  97. //
  98. const filterOption = (opt: SelectOption<Value>) =>
  99. `${opt.label ?? ''}${opt.textValue ?? ''}`
  100. .toLowerCase()
  101. .includes(search.toLowerCase());
  102. const hiddenOptionsSet = new Set<Value>();
  103. const remainingItems = items
  104. .flatMap<SelectOptionOrSection<Value> | null>(item => {
  105. if ('options' in item) {
  106. const filteredOptions = item.options
  107. .map(opt => {
  108. if (filterOption(opt)) {
  109. return opt;
  110. }
  111. hiddenOptionsSet.add(opt.value);
  112. return null;
  113. })
  114. .filter((opt): opt is SelectOption<Value> => !!opt);
  115. return filteredOptions.length > 0 ? {...item, options: filteredOptions} : null;
  116. }
  117. if (filterOption(item)) {
  118. return item;
  119. }
  120. hiddenOptionsSet.add(item.value);
  121. return null;
  122. })
  123. .filter((item): item is SelectOptionOrSection<Value> => !!item);
  124. //
  125. // Then, limit the number of remaining options to `limit`
  126. //
  127. let threshold = [Infinity, Infinity];
  128. let accumulator = 0;
  129. let currentIndex = 0;
  130. while (currentIndex < remainingItems.length) {
  131. const item = remainingItems[currentIndex];
  132. const delta = 'options' in item ? item.options.length : 1;
  133. if (accumulator + delta > limit) {
  134. threshold = [currentIndex, limit - accumulator];
  135. break;
  136. }
  137. accumulator += delta;
  138. currentIndex += 1;
  139. }
  140. for (let i = threshold[0]; i < remainingItems.length; i++) {
  141. const item = remainingItems[i];
  142. if ('options' in item) {
  143. const startingIndex = i === threshold[0] ? threshold[1] : 0;
  144. for (let j = startingIndex; j < item.options.length; j++) {
  145. hiddenOptionsSet.add(item.options[j].value);
  146. }
  147. } else {
  148. hiddenOptionsSet.add(item.value);
  149. }
  150. }
  151. // Return the values of options that were removed.
  152. return hiddenOptionsSet;
  153. }
  154. /**
  155. * Toggles (select/unselect) all provided options. If none/some of the options are
  156. * selected, then this function selects all of them. If all of the options are selected,
  157. * then this function unselects all of them.
  158. */
  159. export function toggleOptions<Value extends React.Key>(
  160. optionKeys: Value[],
  161. selectionManager: SelectionManager
  162. ) {
  163. const {selectedKeys} = selectionManager;
  164. const newSelectedKeys = new Set(selectedKeys);
  165. const allOptionsSelected = optionKeys.every(val => selectionManager.isSelected(val));
  166. optionKeys.forEach(val =>
  167. allOptionsSelected ? newSelectedKeys.delete(val) : newSelectedKeys.add(val)
  168. );
  169. selectionManager.setSelectedKeys(newSelectedKeys);
  170. }
  171. interface SectionToggleProps {
  172. item: Node<any>;
  173. listState: ListState<any>;
  174. listId?: string;
  175. onToggle?: (section: SelectSection<React.Key>, type: 'select' | 'unselect') => void;
  176. }
  177. /**
  178. * A visible toggle button to select/unselect all options within a given section. See
  179. * also: `HiddenSectionToggle`.
  180. */
  181. export function SectionToggle({item, listState, onToggle}: SectionToggleProps) {
  182. const allOptionsSelected = useMemo(
  183. () => [...item.childNodes].every(n => listState.selectionManager.isSelected(n.key)),
  184. [item, listState.selectionManager]
  185. );
  186. const visible = useMemo(() => {
  187. const listHasFocus = listState.selectionManager.isFocused;
  188. const sectionHasFocus = [...item.childNodes].some(
  189. n => listState.selectionManager.focusedKey === n.key
  190. );
  191. return listHasFocus && sectionHasFocus;
  192. }, [item, listState.selectionManager.focusedKey, listState.selectionManager.isFocused]);
  193. const toggleAllOptions = useCallback(() => {
  194. onToggle?.(item.value, allOptionsSelected ? 'unselect' : 'select');
  195. toggleOptions(
  196. [...item.childNodes].map(n => n.key),
  197. listState.selectionManager
  198. );
  199. }, [onToggle, allOptionsSelected, item, listState.selectionManager]);
  200. return (
  201. <SectionToggleButton
  202. data-key={item.key}
  203. visible={visible}
  204. size="zero"
  205. borderless
  206. // Remove this button from keyboard navigation and the accessibility tree, since
  207. // the outer list component implements a roving `tabindex` system that would be
  208. // messed up if there was a focusable, non-selectable button in the middle of it.
  209. // Keyboard users will still be able to toggle-select sections with hidden buttons
  210. // at the end of the list (see `HiddenSectionToggle` below)
  211. aria-hidden
  212. tabIndex={-1}
  213. onClick={toggleAllOptions}
  214. >
  215. {allOptionsSelected ? t('Unselect All') : t('Select All')}
  216. </SectionToggleButton>
  217. );
  218. }
  219. /**
  220. * A visually hidden but keyboard-focusable button to toggle (select/unselect) all
  221. * options in a given section. We need these hidden buttons because the visible toggle
  222. * buttons inside ListBox/GridList are not keyboard-focusable (due to them implementing
  223. * roving `tabindex`).
  224. */
  225. export function HiddenSectionToggle({
  226. item,
  227. listState,
  228. onToggle,
  229. listId = '',
  230. ...props
  231. }: SectionToggleProps) {
  232. // Highlight this toggle's visible counterpart (rendered inside the list box) on focus
  233. const {focusProps} = useFocus({
  234. onFocus: () => {
  235. const visibleCounterpart = document.querySelector(
  236. `#${listId} button[aria-hidden][data-key="${item.key}"]`
  237. );
  238. if (!visibleCounterpart) {
  239. return;
  240. }
  241. visibleCounterpart.classList.add('focus-visible');
  242. },
  243. onBlur: () => {
  244. const visibleCounterpart = document.querySelector(
  245. `#${listId} button[aria-hidden][data-key="${item.key}"]`
  246. );
  247. if (!visibleCounterpart) {
  248. return;
  249. }
  250. visibleCounterpart.classList.remove('focus-visible');
  251. },
  252. });
  253. /**
  254. * Whether all options in this section are currently selected
  255. */
  256. const allOptionsSelected = useMemo(
  257. () => [...item.childNodes].every(n => listState.selectionManager.isSelected(n.key)),
  258. [item, listState.selectionManager]
  259. );
  260. const {pressProps} = usePress({
  261. onPress: () => {
  262. onToggle?.(item.value, allOptionsSelected ? 'unselect' : 'select');
  263. toggleOptions(
  264. [...item.childNodes].map(n => n.key),
  265. listState.selectionManager
  266. );
  267. },
  268. });
  269. return (
  270. <VisuallyHidden role="presentation">
  271. <button
  272. {...props}
  273. {...mergeProps(focusProps, pressProps)}
  274. aria-controls={listId}
  275. id={`${listId}-section-toggle-${item.key}`}
  276. >
  277. {allOptionsSelected ? t('Unselect All in ') : t('Select All in ')}
  278. {item.textValue ?? item.rendered}
  279. </button>
  280. </VisuallyHidden>
  281. );
  282. }