123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381 |
- import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
- import {components as selectComponents, OptionTypeBase} from 'react-select';
- import isPropValid from '@emotion/is-prop-valid';
- import {useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import {useButton} from '@react-aria/button';
- import {FocusScope} from '@react-aria/focus';
- import {useMenuTrigger} from '@react-aria/menu';
- import {useResizeObserver} from '@react-aria/utils';
- import Badge from 'sentry/components/badge';
- import Button from 'sentry/components/button';
- import DropdownButton, {DropdownButtonProps} from 'sentry/components/dropdownButton';
- import SelectControl, {
- ControlProps,
- GeneralSelectValue,
- } from 'sentry/components/forms/controls/selectControl';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import {Overlay, PositionWrapper} from 'sentry/components/overlay';
- import space from 'sentry/styles/space';
- import {FormSize} from 'sentry/utils/theme';
- import useOverlay, {UseOverlayProps} from 'sentry/utils/useOverlay';
- interface Props<OptionType extends OptionTypeBase, MultipleType extends boolean>
- extends Omit<ControlProps<OptionType>, 'choices' | 'multiple' | 'onChange'>,
- UseOverlayProps {
- options: Array<OptionType & {options?: OptionType[]}>;
- /**
- * Pass class name to the outer wrap
- */
- className?: string;
- /**
- * Whether new options are being loaded. When true, CompactSelect will
- * display a loading indicator in the header.
- */
- isLoading?: boolean;
- multiple?: MultipleType;
- onChange?: MultipleType extends true
- ? (values: OptionType[]) => void
- : (value: OptionType) => void;
- onChangeValueMap?: (value: OptionType[]) => ControlProps<OptionType>['value'];
- /**
- * Tag name for the outer wrap, defaults to `div`
- */
- renderWrapAs?: React.ElementType;
- /**
- * Affects the size of the trigger button and menu items.
- */
- size?: FormSize;
- /**
- * Optionally replace the trigger button with a different component. Note
- * that the replacement must have the `props` and `ref` (supplied in
- * TriggerProps) forwarded its outer wrap, otherwise the accessibility
- * features won't work correctly.
- */
- trigger?: (props: Omit<DropdownButtonProps, 'children'>) => React.ReactNode;
- /**
- * By default, the menu trigger will be rendered as a button, with
- * triggerLabel as the button label.
- */
- triggerLabel?: React.ReactNode;
- /**
- * If using the default button trigger (i.e. the custom `trigger` prop has
- * not been provided), then `triggerProps` will be passed on to the button
- * component.
- */
- triggerProps?: DropdownButtonProps;
- }
- /**
- * Recursively finds the selected option(s) from an options array. Useful for
- * non-flat arrays that contain sections (groups of options).
- */
- function getSelectedOptions<
- OptionType extends GeneralSelectValue,
- MultipleType extends boolean
- >(
- opts: Props<OptionType, MultipleType>['options'],
- value: Props<OptionType, MultipleType>['value']
- ): Props<OptionType, MultipleType>['options'] {
- return opts.reduce((acc: Props<OptionType, MultipleType>['options'], cur) => {
- if (cur.options) {
- return acc.concat(getSelectedOptions(cur.options, value));
- }
- if (cur.value === value) {
- return acc.concat(cur);
- }
- return acc;
- }, []);
- }
- // Exported so we can further customize this component with react-select's
- // components prop elsewhere
- export const CompactSelectControl = ({
- innerProps,
- ...props
- }: React.ComponentProps<typeof selectComponents.Control>) => {
- const {hasValue, selectProps} = props;
- const {isSearchable, menuTitle, isClearable, isLoading} = selectProps;
- return (
- <Fragment>
- {(menuTitle || isClearable || isLoading) && (
- <MenuHeader>
- <MenuTitle>
- <span>{menuTitle}</span>
- </MenuTitle>
- {isLoading && <StyledLoadingIndicator size={12} mini />}
- {hasValue && isClearable && !isLoading && (
- <ClearButton
- type="button"
- size="zero"
- borderless
- onClick={() => props.clearValue()}
- // set tabIndex -1 to autofocus search on open
- tabIndex={isSearchable ? -1 : undefined}
- >
- Clear
- </ClearButton>
- )}
- </MenuHeader>
- )}
- <selectComponents.Control
- {...props}
- innerProps={{...innerProps, ...(!isSearchable && {'aria-hidden': true})}}
- />
- </Fragment>
- );
- };
- /**
- * A select component with a more compact trigger button. Accepts the same
- * props as SelectControl, plus some more for the trigger button & overlay.
- */
- function CompactSelect<
- OptionType extends GeneralSelectValue = GeneralSelectValue,
- MultipleType extends boolean = false
- >({
- // Select props
- options,
- onChange,
- defaultValue,
- value: valueProp,
- isDisabled: disabledProp,
- isSearchable = false,
- multiple,
- placeholder = 'Search…',
- onChangeValueMap,
- // Trigger button & wrapper props
- trigger,
- triggerLabel,
- triggerProps,
- isOpen: isOpenProp,
- size = 'md',
- className,
- renderWrapAs,
- closeOnSelect = true,
- menuTitle,
- onClose,
- // Overlay props
- offset = 8,
- position = 'bottom-start',
- shouldCloseOnBlur = true,
- isDismissable = true,
- maxMenuHeight = 400,
- ...props
- }: Props<OptionType, MultipleType>) {
- // Manage the dropdown menu's open state
- const isDisabled = disabledProp || options?.length === 0;
- const {
- isOpen,
- state,
- triggerProps: overlayTriggerProps,
- triggerRef,
- overlayProps,
- } = useOverlay({
- isOpen: isOpenProp,
- onClose,
- offset,
- position,
- isDismissable,
- shouldCloseOnBlur,
- shouldCloseOnInteractOutside: target =>
- target && triggerRef.current !== target && !triggerRef.current?.contains(target),
- });
- const {menuTriggerProps} = useMenuTrigger(
- {type: 'listbox', isDisabled},
- {...state, focusStrategy: 'first'},
- triggerRef
- );
- const {buttonProps} = useButton({isDisabled, ...menuTriggerProps}, triggerRef);
- // Keep an internal copy of the current select value and update the control
- // button's label when the value changes
- const [internalValue, setInternalValue] = useState(valueProp ?? defaultValue);
- // Keep track of the default trigger label when the value changes
- const defaultTriggerLabel = useMemo(() => {
- const newValue = valueProp ?? internalValue;
- const valueSet = Array.isArray(newValue) ? newValue : [newValue];
- const selectedOptions = valueSet
- .map(val => getSelectedOptions<OptionType, MultipleType>(options, val))
- .flat();
- return (
- <Fragment>
- <ButtonLabel>{selectedOptions[0]?.label ?? ''}</ButtonLabel>
- {selectedOptions.length > 1 && (
- <StyledBadge text={`+${selectedOptions.length - 1}`} />
- )}
- </Fragment>
- );
- }, [options, valueProp, internalValue]);
- const onValueChange = useCallback(
- option => {
- const valueMap = onChangeValueMap ?? (opts => opts.map(opt => opt.value));
- const newValue = Array.isArray(option) ? valueMap(option) : option?.value;
- setInternalValue(newValue);
- onChange?.(option);
- if (closeOnSelect && !multiple) {
- state.close();
- }
- },
- [state, closeOnSelect, multiple, onChange, onChangeValueMap]
- );
- // Calculate the current trigger element's width. This will be used as
- // the min width for the menu.
- const [triggerWidth, setTriggerWidth] = useState<number>();
- // Update triggerWidth when its size changes using useResizeObserver
- const updateTriggerWidth = useCallback(async () => {
- // Wait until the trigger element finishes rendering, otherwise
- // ResizeObserver might throw an infinite loop error.
- await new Promise(resolve => window.setTimeout(resolve));
- const newTriggerWidth = triggerRef.current?.offsetWidth;
- newTriggerWidth && setTriggerWidth(newTriggerWidth);
- }, [triggerRef]);
- useResizeObserver({ref: triggerRef, onResize: updateTriggerWidth});
- // If ResizeObserver is not available, manually update the width
- // when any of [trigger, triggerLabel, triggerProps] changes.
- useEffect(() => {
- if (typeof window.ResizeObserver !== 'undefined') {
- return;
- }
- updateTriggerWidth();
- }, [updateTriggerWidth]);
- function renderTrigger() {
- if (trigger) {
- return trigger({
- size,
- isOpen,
- ...triggerProps,
- ...overlayTriggerProps,
- ...buttonProps,
- });
- }
- return (
- <DropdownButton
- size={size}
- isOpen={isOpen}
- {...triggerProps}
- {...overlayTriggerProps}
- {...buttonProps}
- >
- {triggerLabel ?? defaultTriggerLabel}
- </DropdownButton>
- );
- }
- const theme = useTheme();
- const menuHeight = useMemo(
- () =>
- overlayProps.style?.maxHeight
- ? Math.min(+overlayProps.style?.maxHeight, maxMenuHeight)
- : maxMenuHeight,
- [overlayProps, maxMenuHeight]
- );
- function renderMenu() {
- if (!isOpen) {
- return null;
- }
- return (
- <FocusScope restoreFocus autoFocus>
- <PositionWrapper zIndex={theme.zIndex.dropdown} {...overlayProps}>
- <StyledOverlay minWidth={triggerWidth}>
- <SelectControl
- components={{Control: CompactSelectControl, ClearIndicator: null}}
- {...props}
- options={options}
- value={valueProp ?? internalValue}
- multiple={multiple}
- onChange={onValueChange}
- size={size}
- menuTitle={menuTitle}
- placeholder={placeholder}
- isSearchable={isSearchable}
- menuHeight={menuHeight}
- menuPlacement="bottom"
- menuIsOpen
- isCompact
- controlShouldRenderValue={false}
- hideSelectedOptions={false}
- menuShouldScrollIntoView={false}
- blurInputOnSelect={false}
- closeMenuOnSelect={false}
- closeMenuOnScroll={false}
- openMenuOnFocus
- />
- </StyledOverlay>
- </PositionWrapper>
- </FocusScope>
- );
- }
- return (
- <MenuControlWrap className={className} as={renderWrapAs} role="presentation">
- {renderTrigger()}
- {renderMenu()}
- </MenuControlWrap>
- );
- }
- export default CompactSelect;
- const MenuControlWrap = styled('div')``;
- const ButtonLabel = styled('span')`
- ${p => p.theme.overflowEllipsis}
- text-align: left;
- `;
- const StyledBadge = styled(Badge)`
- flex-shrink: 0;
- top: auto;
- `;
- const StyledOverlay = styled(Overlay, {
- shouldForwardProp: prop => typeof prop === 'string' && isPropValid(prop),
- })<{minWidth?: number}>`
- max-width: calc(100vw - ${space(2)} * 2);
- overflow: hidden;
- ${p => p.minWidth && `min-width: ${p.minWidth}px;`}
- `;
- const MenuHeader = styled('div')`
- position: relative;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: ${space(0.25)} ${space(1)} ${space(0.25)} ${space(1.5)};
- box-shadow: 0 1px 0 ${p => p.theme.translucentInnerBorder};
- z-index: 1;
- `;
- const MenuTitle = styled('span')`
- font-weight: 600;
- font-size: ${p => p.theme.fontSizeSmall};
- color: ${p => p.theme.headingColor};
- white-space: nowrap;
- margin: ${space(0.5)} ${space(2)} ${space(0.5)} 0;
- `;
- const StyledLoadingIndicator = styled(LoadingIndicator)`
- && {
- margin: ${space(0.5)} ${space(0.5)} ${space(0.5)} ${space(1)};
- height: ${space(1.5)};
- width: ${space(1.5)};
- }
- `;
- const ClearButton = styled(Button)`
- font-size: ${p => p.theme.fontSizeSmall};
- color: ${p => p.theme.subText};
- `;
|