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 extends Omit, 'choices' | 'multiple' | 'onChange'>, UseOverlayProps { options: Array; /** * 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['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) => 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['options'], value: Props['value'] ): Props['options'] { return opts.reduce((acc: Props['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) => { const {hasValue, selectProps} = props; const {isSearchable, menuTitle, isClearable, isLoading} = selectProps; return ( {(menuTitle || isClearable || isLoading) && ( {menuTitle} {isLoading && } {hasValue && isClearable && !isLoading && ( props.clearValue()} // set tabIndex -1 to autofocus search on open tabIndex={isSearchable ? -1 : undefined} > Clear )} )} ); }; /** * 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) { // 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(options, val)) .flat(); return ( {selectedOptions[0]?.label ?? ''} {selectedOptions.length > 1 && ( )} ); }, [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(); // 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 ( {triggerLabel ?? defaultTriggerLabel} ); } 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 ( ); } return ( {renderTrigger()} {renderMenu()} ); } 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}; `;