123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318 |
- import {useCallback, useMemo} from 'react';
- import {useFocus, usePress} from '@react-aria/interactions';
- import {mergeProps} from '@react-aria/utils';
- import {VisuallyHidden} from '@react-aria/visually-hidden';
- import type {ListState} from '@react-stately/list';
- import type {SelectionManager} from '@react-stately/selection';
- import type {Node, Selection} from '@react-types/shared';
- import {t} from 'sentry/locale';
- import {SectionToggleButton} from './styles';
- import type {
- SelectOption,
- SelectOptionOrSection,
- SelectOptionOrSectionWithKey,
- SelectOptionWithKey,
- SelectSection,
- } from './types';
- export function getEscapedKey<Value extends React.Key | undefined>(value: Value): string {
- return CSS.escape(String(value));
- }
- export function getItemsWithKeys<Value extends React.Key>(
- options: SelectOption<Value>[]
- ): SelectOptionWithKey<Value>[];
- export function getItemsWithKeys<Value extends React.Key>(
- options: SelectOptionOrSection<Value>[]
- ): SelectOptionOrSectionWithKey<Value>[];
- export function getItemsWithKeys<Value extends React.Key>(
- options: SelectOptionOrSection<Value>[]
- ): SelectOptionOrSectionWithKey<Value>[] {
- return options.map((item, i) => {
- if ('options' in item) {
- return {
- ...item,
- key: item.key ?? `options-${i}`,
- options: getItemsWithKeys(item.options),
- };
- }
- return {...item, key: getEscapedKey(item.value)};
- });
- }
- /**
- * Recursively finds the selected option(s) from an options array. Useful for
- * non-flat arrays that contain sections (groups of options).
- */
- export function getSelectedOptions<Value extends React.Key>(
- items: SelectOptionOrSectionWithKey<Value>[],
- selection: Selection
- ): SelectOption<Value>[] {
- return items.reduce<SelectOption<Value>[]>((acc, cur) => {
- // If this is a section
- if ('options' in cur) {
- return acc.concat(getSelectedOptions(cur.options, selection));
- }
- // If this is an option
- if (selection === 'all' || selection.has(getEscapedKey(cur.value))) {
- const {key: _key, ...opt} = cur;
- return acc.concat(opt);
- }
- return acc;
- }, []);
- }
- /**
- * Recursively finds the selected option(s) from an options array. Useful for non-flat
- * arrays that contain sections (groups of options). Returns the values of options that
- * were removed.
- */
- export function getDisabledOptions<Value extends React.Key>(
- items: SelectOptionOrSection<Value>[],
- isOptionDisabled?: (opt: SelectOption<Value>) => boolean
- ): Value[] {
- return items.reduce((acc: Value[], cur) => {
- // If this is a section
- if ('options' in cur) {
- if (cur.disabled) {
- // If the entire section is disabled, then mark all of its children as disabled
- return acc.concat(cur.options.map(opt => opt.value));
- }
- return acc.concat(getDisabledOptions(cur.options, isOptionDisabled));
- }
- // If this is an option
- if (isOptionDisabled?.(cur) ?? cur.disabled) {
- return acc.concat(cur.value);
- }
- return acc;
- }, []);
- }
- /**
- * Recursively finds the option(s) that don't match the designated search string or are
- * outside the list box's count limit.
- */
- export function getHiddenOptions<Value extends React.Key>(
- items: SelectOptionOrSection<Value>[],
- search: string,
- limit: number = Infinity
- ): Set<Value> {
- //
- // First, filter options using `search` value
- //
- const filterOption = (opt: SelectOption<Value>) =>
- `${opt.label ?? ''}${opt.textValue ?? ''}`
- .toLowerCase()
- .includes(search.toLowerCase());
- const hiddenOptionsSet = new Set<Value>();
- const remainingItems = items
- .flatMap<SelectOptionOrSection<Value> | null>(item => {
- if ('options' in item) {
- const filteredOptions = item.options
- .map(opt => {
- if (filterOption(opt)) {
- return opt;
- }
- hiddenOptionsSet.add(opt.value);
- return null;
- })
- .filter((opt): opt is SelectOption<Value> => !!opt);
- return filteredOptions.length > 0 ? {...item, options: filteredOptions} : null;
- }
- if (filterOption(item)) {
- return item;
- }
- hiddenOptionsSet.add(item.value);
- return null;
- })
- .filter((item): item is SelectOptionOrSection<Value> => !!item);
- //
- // Then, limit the number of remaining options to `limit`
- //
- let threshold = [Infinity, Infinity];
- let accumulator = 0;
- let currentIndex = 0;
- while (currentIndex < remainingItems.length) {
- const item = remainingItems[currentIndex];
- const delta = 'options' in item ? item.options.length : 1;
- if (accumulator + delta > limit) {
- threshold = [currentIndex, limit - accumulator];
- break;
- }
- accumulator += delta;
- currentIndex += 1;
- }
- for (let i = threshold[0]; i < remainingItems.length; i++) {
- const item = remainingItems[i];
- if ('options' in item) {
- const startingIndex = i === threshold[0] ? threshold[1] : 0;
- for (let j = startingIndex; j < item.options.length; j++) {
- hiddenOptionsSet.add(item.options[j].value);
- }
- } else {
- hiddenOptionsSet.add(item.value);
- }
- }
- // Return the values of options that were removed.
- return hiddenOptionsSet;
- }
- /**
- * Toggles (select/unselect) all provided options. If none/some of the options are
- * selected, then this function selects all of them. If all of the options are selected,
- * then this function unselects all of them.
- */
- export function toggleOptions<Value extends React.Key>(
- optionKeys: Value[],
- selectionManager: SelectionManager
- ) {
- const {selectedKeys} = selectionManager;
- const newSelectedKeys = new Set(selectedKeys);
- const allOptionsSelected = optionKeys.every(val => selectionManager.isSelected(val));
- optionKeys.forEach(val =>
- allOptionsSelected ? newSelectedKeys.delete(val) : newSelectedKeys.add(val)
- );
- selectionManager.setSelectedKeys(newSelectedKeys);
- }
- interface SectionToggleProps {
- item: Node<any>;
- listState: ListState<any>;
- listId?: string;
- onToggle?: (section: SelectSection<React.Key>, type: 'select' | 'unselect') => void;
- }
- /**
- * A visible toggle button to select/unselect all options within a given section. See
- * also: `HiddenSectionToggle`.
- */
- export function SectionToggle({item, listState, onToggle}: SectionToggleProps) {
- const allOptionsSelected = useMemo(
- () => [...item.childNodes].every(n => listState.selectionManager.isSelected(n.key)),
- [item, listState.selectionManager]
- );
- const visible = useMemo(() => {
- const listHasFocus = listState.selectionManager.isFocused;
- const sectionHasFocus = [...item.childNodes].some(
- n => listState.selectionManager.focusedKey === n.key
- );
- return listHasFocus && sectionHasFocus;
- }, [item, listState.selectionManager.focusedKey, listState.selectionManager.isFocused]);
- const toggleAllOptions = useCallback(() => {
- onToggle?.(item.value, allOptionsSelected ? 'unselect' : 'select');
- toggleOptions(
- [...item.childNodes].map(n => n.key),
- listState.selectionManager
- );
- }, [onToggle, allOptionsSelected, item, listState.selectionManager]);
- return (
- <SectionToggleButton
- data-key={item.key}
- visible={visible}
- size="zero"
- borderless
- // Remove this button from keyboard navigation and the accessibility tree, since
- // the outer list component implements a roving `tabindex` system that would be
- // messed up if there was a focusable, non-selectable button in the middle of it.
- // Keyboard users will still be able to toggle-select sections with hidden buttons
- // at the end of the list (see `HiddenSectionToggle` below)
- aria-hidden
- tabIndex={-1}
- onClick={toggleAllOptions}
- >
- {allOptionsSelected ? t('Unselect All') : t('Select All')}
- </SectionToggleButton>
- );
- }
- /**
- * A visually hidden but keyboard-focusable button to toggle (select/unselect) all
- * options in a given section. We need these hidden buttons because the visible toggle
- * buttons inside ListBox/GridList are not keyboard-focusable (due to them implementing
- * roving `tabindex`).
- */
- export function HiddenSectionToggle({
- item,
- listState,
- onToggle,
- listId = '',
- ...props
- }: SectionToggleProps) {
- // Highlight this toggle's visible counterpart (rendered inside the list box) on focus
- const {focusProps} = useFocus({
- onFocus: () => {
- const visibleCounterpart = document.querySelector(
- `#${listId} button[aria-hidden][data-key="${item.key}"]`
- );
- if (!visibleCounterpart) {
- return;
- }
- visibleCounterpart.classList.add('focus-visible');
- },
- onBlur: () => {
- const visibleCounterpart = document.querySelector(
- `#${listId} button[aria-hidden][data-key="${item.key}"]`
- );
- if (!visibleCounterpart) {
- return;
- }
- visibleCounterpart.classList.remove('focus-visible');
- },
- });
- /**
- * Whether all options in this section are currently selected
- */
- const allOptionsSelected = useMemo(
- () => [...item.childNodes].every(n => listState.selectionManager.isSelected(n.key)),
- [item, listState.selectionManager]
- );
- const {pressProps} = usePress({
- onPress: () => {
- onToggle?.(item.value, allOptionsSelected ? 'unselect' : 'select');
- toggleOptions(
- [...item.childNodes].map(n => n.key),
- listState.selectionManager
- );
- },
- });
- return (
- <VisuallyHidden role="presentation">
- <button
- {...props}
- {...mergeProps(focusProps, pressProps)}
- aria-controls={listId}
- id={`${listId}-section-toggle-${item.key}`}
- >
- {allOptionsSelected ? t('Unselect All in ') : t('Select All in ')}
- {item.textValue ?? item.rendered}
- </button>
- </VisuallyHidden>
- );
- }
|