Files
2024-02-15 17:01:50 +00:00

696 lines
21 KiB
TypeScript

import {
createContext,
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import isPropValid from '@emotion/is-prop-valid';
import {useTheme} from '@emotion/react';
import styled from '@emotion/styled';
import {FocusScope} from '@react-aria/focus';
import {useKeyboard} from '@react-aria/interactions';
import {mergeProps} from '@react-aria/utils';
import type {ListState} from '@react-stately/list';
import type {OverlayTriggerState} from '@react-stately/overlays';
import Badge from 'sentry/components/badge';
import {Button} from 'sentry/components/button';
import type {DropdownButtonProps} from 'sentry/components/dropdownButton';
import DropdownButton from 'sentry/components/dropdownButton';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {Overlay, PositionWrapper} from 'sentry/components/overlay';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {defined} from 'sentry/utils';
import type {FormSize} from 'sentry/utils/theme';
import type {UseOverlayProps} from 'sentry/utils/useOverlay';
import useOverlay from 'sentry/utils/useOverlay';
import usePrevious from 'sentry/utils/usePrevious';
import type {SingleListProps} from './list';
import type {SelectOption} from './types';
// autoFocus react attribute is sync called on render, this causes
// layout thrashing and is bad for performance. This thin wrapper function
// will defer the focus call until the next frame, after the browser and react
// have had a chance to update the DOM, splitting the perf cost across frames.
function nextFrameCallback(cb: () => void) {
if ('requestAnimationFrame' in window) {
window.requestAnimationFrame(() => cb());
} else {
setTimeout(() => {
cb();
}, 1);
}
}
export interface SelectContextValue {
overlayIsOpen: boolean;
/**
* Function to be called once when a list is initialized, to register its state in
* SelectContext. In composite selectors, where there can be multiple lists, the
* `index` parameter is the list's index number (the order in which it appears). In
* non-composite selectors, where there's only one list, that list's index is 0.
*/
registerListState: (index: number, listState: ListState<any>) => void;
/**
* Function to be called when a list's selection state changes. We need a complete
* list of all selected options to label the trigger button. The `index` parameter
* indentifies the list, in the same way as in `registerListState`.
*/
saveSelectedOptions: (
index: number,
newSelectedOptions: SelectOption<React.Key> | SelectOption<React.Key>[]
) => void;
/**
* Search string to determine whether an option should be rendered in the select list.
*/
search: string;
/**
* The control's overlay state. Useful for opening/closing the menu from inside the
* selector.
*/
overlayState?: OverlayTriggerState;
}
export const SelectContext = createContext<SelectContextValue>({
registerListState: () => {},
saveSelectedOptions: () => {},
overlayIsOpen: false,
search: '',
});
export interface ControlProps
extends Omit<
React.BaseHTMLAttributes<HTMLDivElement>,
// omit keys from SingleListProps because those will be passed to <List /> instead
keyof Omit<
SingleListProps<React.Key>,
'children' | 'items' | 'grid' | 'compositeIndex' | 'label'
>
>,
Pick<
UseOverlayProps,
| 'isOpen'
| 'onClose'
| 'offset'
| 'position'
| 'isDismissable'
| 'shouldCloseOnBlur'
| 'shouldCloseOnInteractOutside'
| 'onInteractOutside'
| 'preventOverflowOptions'
| 'flipOptions'
> {
children?: React.ReactNode;
className?: string;
/**
* If true, there will be a "Clear" button in the menu header.
*/
clearable?: boolean;
/**
* Whether to disable the search input's filter function (applicable only when
* `searchable` is true). This is useful for implementing custom search behaviors,
* like fetching new options on search (via the onSearch() prop).
*/
disableSearchFilter?: boolean;
disabled?: boolean;
/**
* Message to be displayed when all options have been filtered out (via search).
*/
emptyMessage?: string;
/**
* 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 rows (options), and Arrow Left/Right to move between "columns". This
* is useful when the select options have smaller, interactive elements
* (buttons/links) inside. Grid lists allow users to focus on those child elements
* using the Arrow Left/Right keys and interact with them, which isn't possible with
* list boxes.
*/
grid?: boolean;
/**
* If true, all select options will be hidden. This should only be used on a temporary
* basis in conjunction with `menuBody` to display special views/states (e.g. a
* secondary date range selector).
*/
hideOptions?: boolean;
/**
* If true, there will be a loading indicator in the menu header.
*/
loading?: boolean;
maxMenuHeight?: number | string;
maxMenuWidth?: number | string;
/**
* Optional content to display below the menu's header and above the options.
*/
menuBody?: React.ReactNode | ((actions: {closeOverlay: () => void}) => React.ReactNode);
/**
* Footer to be rendered at the bottom of the menu.
*/
menuFooter?:
| React.ReactNode
| ((actions: {closeOverlay: () => void}) => React.ReactNode);
/**
* Items to be displayed in the trailing (right) side of the menu's header.
*/
menuHeaderTrailingItems?:
| React.ReactNode
| ((actions: {closeOverlay: () => void}) => React.ReactNode);
/**
* Title to display in the menu's header. Keep the title as short as possible.
*/
menuTitle?: React.ReactNode;
menuWidth?: number | string;
/**
* Called when the clear button is clicked (applicable only when `clearable` is
* true).
*/
onClear?: () => void;
/**
* Called when the search input's value changes (applicable only when `searchable`
* is true).
*/
onSearch?: (value: string) => void;
/**
* The search input's placeholder text (applicable only when `searchable` is true).
*/
searchPlaceholder?: string;
/**
* If true, there will be a search box on top of the menu, useful for quickly finding
* menu items.
*/
searchable?: boolean;
size?: FormSize;
/**
* Optional replacement for the default trigger button. Note that the replacement must
* forward `props` and `ref` its outer wrap, otherwise many accessibility features
* won't work correctly.
*/
trigger?: (
props: Omit<React.HTMLAttributes<HTMLElement>, 'children'>,
isOpen: boolean
) => React.ReactNode;
/**
* Label text inside the default trigger button. This is optional — by default the
* selected option's label will be used.
*/
triggerLabel?: React.ReactNode;
/**
* Props to be passed to the default trigger button.
*/
triggerProps?: DropdownButtonProps;
}
/**
* Controls Select's open state and exposes SelectContext to all chidlren.
*/
export function Control({
// Control props
trigger,
triggerLabel: triggerLabelProp,
triggerProps,
isOpen,
onClose,
isDismissable,
onInteractOutside,
shouldCloseOnInteractOutside,
shouldCloseOnBlur,
preventOverflowOptions,
flipOptions,
disabled,
position = 'bottom-start',
offset,
hideOptions,
menuTitle,
maxMenuHeight = '32rem',
maxMenuWidth,
menuWidth,
menuHeaderTrailingItems,
menuBody,
menuFooter,
// Select props
size = 'md',
searchable = false,
searchPlaceholder = 'Search…',
disableSearchFilter = false,
onSearch,
clearable = false,
onClear,
loading = false,
grid = false,
children,
...wrapperProps
}: ControlProps) {
// Set up list states (in composite selects, each region has its own state, that way
// selection values are contained within each region).
const [listStates, setListStates] = useState<ListState<any>[]>([]);
const registerListState = useCallback<SelectContextValue['registerListState']>(
(index, listState) => {
setListStates(current => [
...current.slice(0, index),
listState,
...current.slice(index + 1),
]);
},
[]
);
/**
* Search/filter value, used to filter out the list of displayed elements
*/
const [search, setSearch] = useState('');
const [searchInputValue, setSearchInputValue] = useState(search);
const searchRef = useRef<HTMLInputElement>(null);
const updateSearch = useCallback(
(newValue: string) => {
onSearch?.(newValue);
setSearchInputValue(newValue);
if (!disableSearchFilter) {
setSearch(newValue);
return;
}
},
[onSearch, disableSearchFilter]
);
const {keyboardProps: searchKeyboardProps} = useKeyboard({
onKeyDown: e => {
// When the search input is focused, and the user presses Arrow Down,
// we should move the focus to the menu items list.
if (e.key === 'ArrowDown') {
e.preventDefault(); // Prevent scroll action
overlayRef.current
?.querySelector<HTMLLIElement>(`li[role="${grid ? 'row' : 'option'}"]`)
?.focus();
}
// Continue propagation, otherwise the overlay won't close on Esc key press
e.continuePropagation();
},
});
/**
* Clears selection values across all list states
*/
const clearSelection = useCallback(() => {
listStates.forEach(listState => listState.selectionManager.clearSelection());
onClear?.();
}, [onClear, listStates]);
// Manage overlay position
const {
isOpen: overlayIsOpen,
state: overlayState,
update: updateOverlay,
triggerRef,
triggerProps: overlayTriggerProps,
overlayRef,
overlayProps,
} = useOverlay({
disableTrigger: disabled,
type: grid ? 'menu' : 'listbox',
position,
offset,
isOpen,
isDismissable,
onInteractOutside,
shouldCloseOnInteractOutside,
shouldCloseOnBlur,
preventOverflowOptions,
flipOptions,
onOpenChange: open => {
nextFrameCallback(() => {
if (open) {
// Focus on search box if present
if (searchable) {
searchRef.current?.focus();
return;
}
const firstSelectedOption = overlayRef.current?.querySelector<HTMLLIElement>(
`li[role="${grid ? 'row' : 'option'}"][aria-selected="true"]`
);
// Focus on first selected item
if (firstSelectedOption) {
firstSelectedOption.focus();
return;
}
// If no item is selected, focus on first item instead
overlayRef.current
?.querySelector<HTMLLIElement>(`li[role="${grid ? 'row' : 'option'}"]`)
?.focus();
return;
}
// On close
onClose?.();
// Clear search string
setSearchInputValue('');
setSearch('');
triggerRef.current?.focus();
});
},
});
// Recalculate overlay position when its main content changes
const prevMenuBody = usePrevious(menuBody);
const prevHideOptions = usePrevious(hideOptions);
useEffect(() => {
if (
// Don't update when the content inside `menuBody` changes. We should only update
// when `menuBody` itself appears/disappears.
((!prevMenuBody && !menuBody) || (!!prevMenuBody && !!menuBody)) &&
prevHideOptions === hideOptions
) {
return;
}
updateOverlay?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [menuBody, hideOptions]);
/**
* The menu's full width, before any option has been filtered out. Used to maintain a
* constant width while the user types into the search box.
*/
const [menuFullWidth, setMenuFullWidth] = useState<number>();
// When search box is focused, read the menu's width and lock it at that value to
// prevent visual jumps during search
const onSearchFocus = useCallback(
() => setMenuFullWidth(overlayRef.current?.offsetWidth),
[overlayRef]
);
// When search box is blurred, release the lock the menu's width
const onSearchBlur = useCallback(
() => !search && setMenuFullWidth(undefined),
[search]
);
/**
* A list of selected options across all select regions, to be used to generate the
* trigger label.
*/
const [selectedOptions, setSelectedOptions] = useState<
Array<SelectOption<React.Key> | SelectOption<React.Key>[]>
>([]);
const saveSelectedOptions = useCallback<SelectContextValue['saveSelectedOptions']>(
(index, newSelectedOptions) => {
setSelectedOptions(current => [
...current.slice(0, index),
newSelectedOptions,
...current.slice(index + 1),
]);
},
[]
);
/**
* Trigger label, generated from current selection values. If more than one option is
* selected, then a count badge will appear.
*/
const triggerLabel: React.ReactNode = useMemo(() => {
if (defined(triggerLabelProp)) {
return triggerLabelProp;
}
const options = selectedOptions.flat().filter(Boolean);
if (options.length === 0) {
return <TriggerLabel>{t('None')}</TriggerLabel>;
}
return (
<Fragment>
<TriggerLabel>{options[0]?.label}</TriggerLabel>
{options.length > 1 && <StyledBadge text={`+${options.length - 1}`} />}
</Fragment>
);
}, [triggerLabelProp, selectedOptions]);
const {keyboardProps: triggerKeyboardProps} = useKeyboard({
onKeyDown: e => {
// Open the select menu when user presses Arrow Up/Down.
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault(); // Prevent scroll
overlayState.open();
}
},
});
const showClearButton = useMemo(
() => selectedOptions.flat().length > 0,
[selectedOptions]
);
const contextValue = useMemo(
() => ({
registerListState,
saveSelectedOptions,
overlayState,
overlayIsOpen,
search,
}),
[registerListState, saveSelectedOptions, overlayState, overlayIsOpen, search]
);
const theme = useTheme();
return (
<SelectContext.Provider value={contextValue}>
<ControlWrap {...wrapperProps}>
{trigger ? (
trigger(mergeProps(triggerKeyboardProps, overlayTriggerProps), overlayIsOpen)
) : (
<DropdownButton
size={size}
{...mergeProps(triggerProps, triggerKeyboardProps, overlayTriggerProps)}
isOpen={overlayIsOpen}
disabled={disabled}
>
{triggerLabel}
</DropdownButton>
)}
<StyledPositionWrapper
zIndex={theme.zIndex?.tooltip}
visible={overlayIsOpen}
{...overlayProps}
>
<StyledOverlay
width={menuWidth ?? menuFullWidth}
minWidth={overlayProps.style.minWidth}
maxWidth={maxMenuWidth}
maxHeight={overlayProps.style.maxHeight}
maxHeightProp={maxMenuHeight}
data-menu-has-header={!!menuTitle || clearable}
data-menu-has-search={searchable}
data-menu-has-footer={!!menuFooter}
>
<FocusScope contain={overlayIsOpen}>
{(menuTitle ||
menuHeaderTrailingItems ||
(clearable && showClearButton)) && (
<MenuHeader size={size}>
<MenuTitle>{menuTitle}</MenuTitle>
<MenuHeaderTrailingItems>
{loading && <StyledLoadingIndicator size={12} mini />}
{typeof menuHeaderTrailingItems === 'function'
? menuHeaderTrailingItems({closeOverlay: overlayState.close})
: menuHeaderTrailingItems}
{clearable && showClearButton && (
<ClearButton onClick={clearSelection} size="zero" borderless>
{t('Clear')}
</ClearButton>
)}
</MenuHeaderTrailingItems>
</MenuHeader>
)}
{searchable && (
<SearchInput
ref={searchRef}
placeholder={searchPlaceholder}
value={searchInputValue}
onFocus={onSearchFocus}
onBlur={onSearchBlur}
onChange={e => updateSearch(e.target.value)}
visualSize={size}
{...searchKeyboardProps}
/>
)}
{typeof menuBody === 'function'
? menuBody({closeOverlay: overlayState.close})
: menuBody}
{!hideOptions && <OptionsWrap>{children}</OptionsWrap>}
{menuFooter && (
<MenuFooter>
{typeof menuFooter === 'function'
? menuFooter({closeOverlay: overlayState.close})
: menuFooter}
</MenuFooter>
)}
</FocusScope>
</StyledOverlay>
</StyledPositionWrapper>
</ControlWrap>
</SelectContext.Provider>
);
}
const ControlWrap = styled('div')`
position: relative;
width: max-content;
`;
const TriggerLabel = styled('span')`
${p => p.theme.overflowEllipsis}
text-align: left;
line-height: normal;
`;
const StyledBadge = styled(Badge)`
flex-shrink: 0;
top: auto;
`;
const headerVerticalPadding: Record<FormSize, string> = {
xs: space(0.25),
sm: space(0.5),
md: space(0.75),
};
const MenuHeader = styled('div')<{size: FormSize}>`
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: ${p => headerVerticalPadding[p.size]} ${space(1.5)};
box-shadow: 0 1px 0 ${p => p.theme.translucentInnerBorder};
[data-menu-has-search='true'] > & {
padding-bottom: 0;
box-shadow: none;
}
line-height: ${p => p.theme.text.lineHeightBody};
z-index: 2;
font-size: ${p =>
p.size !== 'xs' ? p.theme.fontSizeSmall : p.theme.fontSizeExtraSmall};
color: ${p => p.theme.headingColor};
`;
const MenuHeaderTrailingItems = styled('div')`
display: grid;
grid-auto-flow: column;
gap: ${space(0.5)};
`;
const MenuTitle = styled('span')`
font-size: inherit; /* Inherit font size from MenuHeader */
font-weight: 600;
white-space: nowrap;
margin-right: ${space(2)};
`;
const StyledLoadingIndicator = styled(LoadingIndicator)`
&& {
margin: 0 ${space(0.5)} 0 ${space(1)};
height: 12px;
width: 12px;
}
`;
const ClearButton = styled(Button)`
font-size: inherit; /* Inherit font size from MenuHeader */
font-weight: 400;
color: ${p => p.theme.subText};
padding: 0 ${space(0.5)};
margin: -${space(0.25)} -${space(0.5)};
`;
const searchVerticalPadding: Record<FormSize, string> = {
xs: space(0.25),
sm: space(0.5),
md: space(0.5),
};
const SearchInput = styled('input')<{visualSize: FormSize}>`
appearance: none;
width: calc(100% - ${space(0.5)} * 2);
border: solid 1px ${p => p.theme.innerBorder};
border-radius: ${p => p.theme.borderRadius};
background: ${p => p.theme.backgroundSecondary};
font-size: ${p =>
p.visualSize !== 'xs' ? p.theme.fontSizeMedium : p.theme.fontSizeSmall};
/* Subtract 1px to account for border width */
padding: ${p => searchVerticalPadding[p.visualSize]} calc(${space(1)} - 1px);
margin: ${space(0.5)} ${space(0.5)};
/* Add 1px to top margin if immediately preceded by menu header, to account for the
header's shadow border */
[data-menu-has-header='true'] > & {
margin-top: calc(${space(0.5)} + 1px);
}
&:focus,
&:focus-visible {
outline: none;
border-color: ${p => p.theme.focusBorder};
box-shadow: ${p => p.theme.focusBorder} 0 0 0 1px;
background: transparent;
}
`;
const withUnits = value => (typeof value === 'string' ? value : `${value}px`);
const StyledOverlay = styled(Overlay, {
shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop),
})<{
maxHeightProp: string | number;
maxHeight?: string | number;
maxWidth?: string | number;
minWidth?: string | number;
width?: string | number;
}>`
/* Should be a flex container so that when maxHeight is set (to avoid page overflow),
ListBoxWrap/GridListWrap will also shrink to fit */
display: flex;
flex-direction: column;
overflow: hidden;
${p => p.width && `width: ${withUnits(p.width)};`}
${p => p.minWidth && `min-width: ${withUnits(p.minWidth)};`}
max-width: ${p => (p.maxWidth ? `min(${withUnits(p.maxWidth)}, 100%)` : `100%`)};
max-height: ${p =>
p.maxHeight
? `min(${withUnits(p.maxHeight)}, ${withUnits(p.maxHeightProp)})`
: withUnits(p.maxHeightProp)};
`;
const StyledPositionWrapper = styled(PositionWrapper, {
shouldForwardProp: prop => isPropValid(prop),
})<{visible?: boolean}>`
min-width: 100%;
display: ${p => (p.visible ? 'block' : 'none')};
`;
const OptionsWrap = styled('div')`
display: flex;
flex-direction: column;
min-height: 0;
`;
const MenuFooter = styled('div')`
box-shadow: 0 -1px 0 ${p => p.theme.translucentInnerBorder};
padding: ${space(1)} ${space(1.5)};
z-index: 2;
`;