|
@@ -1,26 +1,27 @@
|
|
-import {Fragment, useContext, useEffect, useMemo, useRef, useState} from 'react';
|
|
|
|
-import styled from '@emotion/styled';
|
|
|
|
|
|
+import {useCallback, useContext, useEffect, useMemo} from 'react';
|
|
import {useFocusManager} from '@react-aria/focus';
|
|
import {useFocusManager} from '@react-aria/focus';
|
|
-import {useKeyboard} from '@react-aria/interactions';
|
|
|
|
-import {AriaListBoxOptions, useListBox} from '@react-aria/listbox';
|
|
|
|
-import {mergeProps} from '@react-aria/utils';
|
|
|
|
|
|
+import {AriaGridListOptions} from '@react-aria/gridlist';
|
|
|
|
+import {AriaListBoxOptions} from '@react-aria/listbox';
|
|
import {ListProps, useListState} from '@react-stately/list';
|
|
import {ListProps, useListState} from '@react-stately/list';
|
|
import {Selection} from '@react-types/shared';
|
|
import {Selection} from '@react-types/shared';
|
|
|
|
|
|
-import {space} from 'sentry/styles/space';
|
|
|
|
import {defined} from 'sentry/utils';
|
|
import {defined} from 'sentry/utils';
|
|
import {FormSize} from 'sentry/utils/theme';
|
|
import {FormSize} from 'sentry/utils/theme';
|
|
|
|
|
|
import {SelectContext} from './control';
|
|
import {SelectContext} from './control';
|
|
-import {Option} from './option';
|
|
|
|
-import {Section} from './section';
|
|
|
|
|
|
+import {GridList} from './gridList';
|
|
|
|
+import {ListBox} from './listBox';
|
|
import {SelectOption, SelectOptionOrSection, SelectOptionOrSectionWithKey} from './types';
|
|
import {SelectOption, SelectOptionOrSection, SelectOptionOrSectionWithKey} from './types';
|
|
|
|
|
|
-interface BaseListBoxProps<Value extends React.Key>
|
|
|
|
|
|
+interface BaseListProps<Value extends React.Key>
|
|
extends ListProps<any>,
|
|
extends ListProps<any>,
|
|
Omit<
|
|
Omit<
|
|
AriaListBoxOptions<any>,
|
|
AriaListBoxOptions<any>,
|
|
'disabledKeys' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'
|
|
'disabledKeys' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'
|
|
|
|
+ >,
|
|
|
|
+ Omit<
|
|
|
|
+ AriaGridListOptions<any>,
|
|
|
|
+ 'disabledKeys' | 'selectedKeys' | 'defaultSelectedKeys' | 'onSelectionChange'
|
|
> {
|
|
> {
|
|
items: SelectOptionOrSectionWithKey<Value>[];
|
|
items: SelectOptionOrSectionWithKey<Value>[];
|
|
/**
|
|
/**
|
|
@@ -29,29 +30,39 @@ interface BaseListBoxProps<Value extends React.Key>
|
|
*/
|
|
*/
|
|
closeOnSelect?: boolean;
|
|
closeOnSelect?: boolean;
|
|
/**
|
|
/**
|
|
- * The index number of this list box inside composite select menus, which contain
|
|
|
|
- * multiple list boxes (each corresponding to a select region).
|
|
|
|
|
|
+ * This list's index number inside composite select menus.
|
|
*/
|
|
*/
|
|
compositeIndex?: number;
|
|
compositeIndex?: number;
|
|
|
|
+ /**
|
|
|
|
+ * Whether to render a grid list rather than a list box.
|
|
|
|
+ *
|
|
|
|
+ * Unlike list boxes, grid lists are two-dimensional. Users can press Arrow Up/Down to
|
|
|
|
+ * move between option rows, and Arrow Left/Right to move between columns. This is
|
|
|
|
+ * useful when the selector contains options with smaller, interactive elements
|
|
|
|
+ * (buttons/links) inside. Grid lists allow users to focus on those child elements and
|
|
|
|
+ * interact with them, which isn't possible with list boxes.
|
|
|
|
+ */
|
|
|
|
+ grid?: boolean;
|
|
/**
|
|
/**
|
|
* Custom function to determine whether an option is disabled. By default, an option
|
|
* Custom function to determine whether an option is disabled. By default, an option
|
|
* is considered disabled when it has {disabled: true}.
|
|
* is considered disabled when it has {disabled: true}.
|
|
*/
|
|
*/
|
|
isOptionDisabled?: (opt: SelectOption<Value>) => boolean;
|
|
isOptionDisabled?: (opt: SelectOption<Value>) => boolean;
|
|
|
|
+ /**
|
|
|
|
+ * Text label to be rendered as heading on top of grid list.
|
|
|
|
+ */
|
|
label?: React.ReactNode;
|
|
label?: React.ReactNode;
|
|
size?: FormSize;
|
|
size?: FormSize;
|
|
}
|
|
}
|
|
|
|
|
|
-export interface SingleListBoxProps<Value extends React.Key>
|
|
|
|
- extends BaseListBoxProps<Value> {
|
|
|
|
|
|
+export interface SingleListProps<Value extends React.Key> extends BaseListProps<Value> {
|
|
defaultValue?: Value;
|
|
defaultValue?: Value;
|
|
multiple?: false;
|
|
multiple?: false;
|
|
onChange?: (selectedOption: SelectOption<Value>) => void;
|
|
onChange?: (selectedOption: SelectOption<Value>) => void;
|
|
value?: Value;
|
|
value?: Value;
|
|
}
|
|
}
|
|
|
|
|
|
-export interface MultipleListBoxProps<Value extends React.Key>
|
|
|
|
- extends BaseListBoxProps<Value> {
|
|
|
|
|
|
+export interface MultipleListProps<Value extends React.Key> extends BaseListProps<Value> {
|
|
multiple: true;
|
|
multiple: true;
|
|
defaultValue?: Value[];
|
|
defaultValue?: Value[];
|
|
onChange?: (selectedOptions: SelectOption<Value>[]) => void;
|
|
onChange?: (selectedOptions: SelectOption<Value>[]) => void;
|
|
@@ -59,34 +70,29 @@ export interface MultipleListBoxProps<Value extends React.Key>
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
/**
|
|
- * A list box wrapper with accessibile behaviors & attributes. In composite selectors,
|
|
|
|
- * there may be multiple self-contained list boxes, each representing a select "region".
|
|
|
|
- * https://react-spectrum.adobe.com/react-aria/useListBox.html
|
|
|
|
|
|
+ * A list containing selectable options. Depending on the `grid` prop, this may be a
|
|
|
|
+ * grid list or list box.
|
|
|
|
+ *
|
|
|
|
+ * In composite selectors, there may be multiple self-contained lists, each
|
|
|
|
+ * representing a select "region".
|
|
*/
|
|
*/
|
|
-function ListBox<Value extends React.Key>({
|
|
|
|
|
|
+function List<Value extends React.Key>({
|
|
items,
|
|
items,
|
|
value,
|
|
value,
|
|
defaultValue,
|
|
defaultValue,
|
|
onChange,
|
|
onChange,
|
|
|
|
+ grid,
|
|
multiple,
|
|
multiple,
|
|
disallowEmptySelection,
|
|
disallowEmptySelection,
|
|
isOptionDisabled,
|
|
isOptionDisabled,
|
|
- size = 'md',
|
|
|
|
shouldFocusWrap = true,
|
|
shouldFocusWrap = true,
|
|
shouldFocusOnHover = true,
|
|
shouldFocusOnHover = true,
|
|
compositeIndex = 0,
|
|
compositeIndex = 0,
|
|
closeOnSelect,
|
|
closeOnSelect,
|
|
- label,
|
|
|
|
...props
|
|
...props
|
|
-}: SingleListBoxProps<Value> | MultipleListBoxProps<Value>) {
|
|
|
|
- const ref = useRef<HTMLUListElement>(null);
|
|
|
|
- const {
|
|
|
|
- overlayState,
|
|
|
|
- overlayIsOpen,
|
|
|
|
- registerListState,
|
|
|
|
- saveSelectedOptions,
|
|
|
|
- filterOption,
|
|
|
|
- } = useContext(SelectContext);
|
|
|
|
|
|
+}: SingleListProps<Value> | MultipleListProps<Value>) {
|
|
|
|
+ const {overlayState, registerListState, saveSelectedOptions, filterOption} =
|
|
|
|
+ useContext(SelectContext);
|
|
|
|
|
|
/**
|
|
/**
|
|
* Props to be passed into useListState()
|
|
* Props to be passed into useListState()
|
|
@@ -164,20 +170,6 @@ function ListBox<Value extends React.Key>({
|
|
items,
|
|
items,
|
|
});
|
|
});
|
|
|
|
|
|
- const [hasFocus, setHasFocus] = useState(false);
|
|
|
|
- const {listBoxProps, labelProps} = useListBox(
|
|
|
|
- {
|
|
|
|
- ...props,
|
|
|
|
- label,
|
|
|
|
- onFocusChange: setHasFocus,
|
|
|
|
- shouldFocusWrap,
|
|
|
|
- shouldFocusOnHover,
|
|
|
|
- shouldSelectOnPressUp: true,
|
|
|
|
- },
|
|
|
|
- listState,
|
|
|
|
- ref
|
|
|
|
- );
|
|
|
|
-
|
|
|
|
// Register the initialized list state once on mount
|
|
// Register the initialized list state once on mount
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
registerListState(compositeIndex, listState);
|
|
registerListState(compositeIndex, listState);
|
|
@@ -201,35 +193,50 @@ function ListBox<Value extends React.Key>({
|
|
});
|
|
});
|
|
}, [listState.collection, filterOption]);
|
|
}, [listState.collection, filterOption]);
|
|
|
|
|
|
- // In composite selects, focus should seamlessly move from one region (listbox) to
|
|
|
|
|
|
+ // In composite selects, focus should seamlessly move from one region (list) to
|
|
// another when the ArrowUp/Down key is pressed
|
|
// another when the ArrowUp/Down key is pressed
|
|
const focusManager = useFocusManager();
|
|
const focusManager = useFocusManager();
|
|
const firstFocusableKey = useMemo(() => {
|
|
const firstFocusableKey = useMemo(() => {
|
|
let firstKey = listState.collection.getFirstKey();
|
|
let firstKey = listState.collection.getFirstKey();
|
|
- while (firstKey && listState.selectionManager.isDisabled(firstKey)) {
|
|
|
|
|
|
+
|
|
|
|
+ while (
|
|
|
|
+ firstKey &&
|
|
|
|
+ (listState.collection.getItem(firstKey).type === 'section' ||
|
|
|
|
+ listState.selectionManager.isDisabled(firstKey))
|
|
|
|
+ ) {
|
|
firstKey = listState.collection.getKeyAfter(firstKey);
|
|
firstKey = listState.collection.getKeyAfter(firstKey);
|
|
}
|
|
}
|
|
|
|
+
|
|
return firstKey;
|
|
return firstKey;
|
|
}, [listState.collection, listState.selectionManager]);
|
|
}, [listState.collection, listState.selectionManager]);
|
|
const lastFocusableKey = useMemo(() => {
|
|
const lastFocusableKey = useMemo(() => {
|
|
let lastKey = listState.collection.getLastKey();
|
|
let lastKey = listState.collection.getLastKey();
|
|
- while (lastKey && listState.selectionManager.isDisabled(lastKey)) {
|
|
|
|
|
|
+
|
|
|
|
+ while (
|
|
|
|
+ lastKey &&
|
|
|
|
+ (listState.collection.getItem(lastKey).type === 'section' ||
|
|
|
|
+ listState.selectionManager.isDisabled(lastKey))
|
|
|
|
+ ) {
|
|
lastKey = listState.collection.getKeyBefore(lastKey);
|
|
lastKey = listState.collection.getKeyBefore(lastKey);
|
|
}
|
|
}
|
|
|
|
+
|
|
return lastKey;
|
|
return lastKey;
|
|
}, [listState.collection, listState.selectionManager]);
|
|
}, [listState.collection, listState.selectionManager]);
|
|
- const {keyboardProps} = useKeyboard({
|
|
|
|
- onKeyDown: e => {
|
|
|
|
- // Continue propagation, otherwise the overlay won't close on Esc key press
|
|
|
|
- e.continuePropagation();
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
+ * Keyboard event handler to seamlessly move focus from one composite list to another
|
|
|
|
+ * when an arrow key is pressed. Returns a boolean indicating whether the keyboard
|
|
|
|
+ * event was intercepted. If yes, then no further callback function should be run.
|
|
|
|
+ */
|
|
|
|
+ const keyDownHandler = useCallback(
|
|
|
|
+ (e: React.KeyboardEvent<HTMLUListElement>) => {
|
|
// Don't handle ArrowDown/Up key presses if focus already wraps
|
|
// Don't handle ArrowDown/Up key presses if focus already wraps
|
|
- if (shouldFocusWrap) {
|
|
|
|
- return;
|
|
|
|
|
|
+ if (shouldFocusWrap && !grid) {
|
|
|
|
+ return true;
|
|
}
|
|
}
|
|
|
|
|
|
// Move focus to next region when ArrowDown is pressed and the last item in this
|
|
// Move focus to next region when ArrowDown is pressed and the last item in this
|
|
- // list box is currently focused
|
|
|
|
|
|
+ // list is currently focused
|
|
if (
|
|
if (
|
|
e.key === 'ArrowDown' &&
|
|
e.key === 'ArrowDown' &&
|
|
listState.selectionManager.focusedKey === lastFocusableKey
|
|
listState.selectionManager.focusedKey === lastFocusableKey
|
|
@@ -237,13 +244,16 @@ function ListBox<Value extends React.Key>({
|
|
focusManager.focusNext({
|
|
focusManager.focusNext({
|
|
wrap: true,
|
|
wrap: true,
|
|
accept: element =>
|
|
accept: element =>
|
|
- element.getAttribute('role') === 'option' &&
|
|
|
|
|
|
+ (element.getAttribute('role') === 'option' ||
|
|
|
|
+ element.getAttribute('role') === 'row') &&
|
|
element.getAttribute('aria-disabled') !== 'true',
|
|
element.getAttribute('aria-disabled') !== 'true',
|
|
});
|
|
});
|
|
|
|
+
|
|
|
|
+ return false; // event intercepted, don't run any further callbacks
|
|
}
|
|
}
|
|
|
|
|
|
// Move focus to previous region when ArrowUp is pressed and the first item in this
|
|
// Move focus to previous region when ArrowUp is pressed and the first item in this
|
|
- // list box is currently focused
|
|
|
|
|
|
+ // list is currently focused
|
|
if (
|
|
if (
|
|
e.key === 'ArrowUp' &&
|
|
e.key === 'ArrowUp' &&
|
|
listState.selectionManager.focusedKey === firstFocusableKey
|
|
listState.selectionManager.focusedKey === firstFocusableKey
|
|
@@ -251,48 +261,50 @@ function ListBox<Value extends React.Key>({
|
|
focusManager.focusPrevious({
|
|
focusManager.focusPrevious({
|
|
wrap: true,
|
|
wrap: true,
|
|
accept: element =>
|
|
accept: element =>
|
|
- element.getAttribute('role') === 'option' &&
|
|
|
|
|
|
+ (element.getAttribute('role') === 'option' ||
|
|
|
|
+ element.getAttribute('role') === 'row') &&
|
|
element.getAttribute('aria-disabled') !== 'true',
|
|
element.getAttribute('aria-disabled') !== 'true',
|
|
});
|
|
});
|
|
|
|
+
|
|
|
|
+ return false; // event intercepted, don't run any further callbacks
|
|
}
|
|
}
|
|
|
|
+
|
|
|
|
+ return true;
|
|
},
|
|
},
|
|
- });
|
|
|
|
|
|
+ [
|
|
|
|
+ focusManager,
|
|
|
|
+ firstFocusableKey,
|
|
|
|
+ lastFocusableKey,
|
|
|
|
+ listState.selectionManager.focusedKey,
|
|
|
|
+ shouldFocusWrap,
|
|
|
|
+ grid,
|
|
|
|
+ ]
|
|
|
|
+ );
|
|
|
|
+
|
|
|
|
+ if (grid) {
|
|
|
|
+ return (
|
|
|
|
+ <GridList
|
|
|
|
+ {...props}
|
|
|
|
+ listItems={filteredItems}
|
|
|
|
+ listState={listState}
|
|
|
|
+ keyDownHandler={keyDownHandler}
|
|
|
|
+ />
|
|
|
|
+ );
|
|
|
|
+ }
|
|
|
|
|
|
return (
|
|
return (
|
|
- <Fragment>
|
|
|
|
- {filteredItems.length !== 0 && <Separator role="separator" />}
|
|
|
|
- {filteredItems.length !== 0 && label && <Label {...labelProps}>{label}</Label>}
|
|
|
|
- <SelectListBoxWrap {...mergeProps(listBoxProps, keyboardProps)} ref={ref}>
|
|
|
|
- {overlayIsOpen &&
|
|
|
|
- filteredItems.map(item => {
|
|
|
|
- if (item.type === 'section') {
|
|
|
|
- return (
|
|
|
|
- <Section
|
|
|
|
- key={item.key}
|
|
|
|
- item={item}
|
|
|
|
- listState={listState}
|
|
|
|
- listBoxHasFocus={hasFocus}
|
|
|
|
- size={size}
|
|
|
|
- />
|
|
|
|
- );
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- return (
|
|
|
|
- <Option
|
|
|
|
- key={item.key}
|
|
|
|
- item={item}
|
|
|
|
- listState={listState}
|
|
|
|
- listBoxHasFocus={hasFocus}
|
|
|
|
- size={size}
|
|
|
|
- />
|
|
|
|
- );
|
|
|
|
- })}
|
|
|
|
- </SelectListBoxWrap>
|
|
|
|
- </Fragment>
|
|
|
|
|
|
+ <ListBox
|
|
|
|
+ {...props}
|
|
|
|
+ listItems={filteredItems}
|
|
|
|
+ listState={listState}
|
|
|
|
+ shouldFocusWrap={shouldFocusWrap}
|
|
|
|
+ shouldFocusOnHover={shouldFocusOnHover}
|
|
|
|
+ keyDownHandler={keyDownHandler}
|
|
|
|
+ />
|
|
);
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
-export {ListBox};
|
|
|
|
|
|
+export {List};
|
|
|
|
|
|
/**
|
|
/**
|
|
* Recursively finds the selected option(s) from an options array. Useful for
|
|
* Recursively finds the selected option(s) from an options array. Useful for
|
|
@@ -342,56 +354,3 @@ function getDisabledOptions<Value extends React.Key>(
|
|
return acc;
|
|
return acc;
|
|
}, []);
|
|
}, []);
|
|
}
|
|
}
|
|
-
|
|
|
|
-const SelectListBoxWrap = styled('ul')`
|
|
|
|
- margin: 0;
|
|
|
|
- padding: ${space(0.5)} 0;
|
|
|
|
-
|
|
|
|
- /* Add 1px to top padding if preceded by menu header, to account for the header's
|
|
|
|
- shadow border */
|
|
|
|
- div[data-header] ~ &:first-of-type,
|
|
|
|
- div[data-header] ~ div > &:first-of-type {
|
|
|
|
- padding-top: calc(${space(0.5)} + 1px);
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- /* Remove top padding if preceded by search input, since search input already has
|
|
|
|
- vertical padding */
|
|
|
|
- input ~ &&:first-of-type,
|
|
|
|
- input ~ div > &&:first-of-type {
|
|
|
|
- padding-top: 0;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- /* Should scroll if it's in a non-composite select */
|
|
|
|
- :only-of-type {
|
|
|
|
- min-height: 0;
|
|
|
|
- overflow: auto;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- :focus-visible {
|
|
|
|
- outline: none;
|
|
|
|
- }
|
|
|
|
-`;
|
|
|
|
-
|
|
|
|
-const Label = styled('p')`
|
|
|
|
- display: inline-block;
|
|
|
|
- font-weight: 600;
|
|
|
|
- font-size: ${p => p.theme.fontSizeExtraSmall};
|
|
|
|
- color: ${p => p.theme.subText};
|
|
|
|
- text-transform: uppercase;
|
|
|
|
- white-space: nowrap;
|
|
|
|
- margin: ${space(0.5)} ${space(1.5)};
|
|
|
|
- padding-right: ${space(1)};
|
|
|
|
-`;
|
|
|
|
-
|
|
|
|
-const Separator = styled('div')`
|
|
|
|
- border-top: solid 1px ${p => p.theme.innerBorder};
|
|
|
|
- margin: ${space(0.5)} ${space(1.5)};
|
|
|
|
-
|
|
|
|
- :first-child {
|
|
|
|
- display: none;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- ul:empty + & {
|
|
|
|
- display: none;
|
|
|
|
- }
|
|
|
|
-`;
|
|
|