import {Fragment, useCallback, useEffect, useRef, useState} from 'react'; import {components as selectComponents} from 'react-select'; import styled from '@emotion/styled'; import {useButton} from '@react-aria/button'; import {FocusScope} from '@react-aria/focus'; import {useMenuTrigger} from '@react-aria/menu'; import { AriaPositionProps, OverlayProps, useOverlay, useOverlayPosition, } from '@react-aria/overlays'; import {mergeProps, useResizeObserver} from '@react-aria/utils'; import {useMenuTriggerState} from '@react-stately/menu'; import Badge from 'sentry/components/badge'; import Button from 'sentry/components/button'; import DropdownButton, {DropdownButtonProps} from 'sentry/components/dropdownButtonV2'; import SelectControl, { ControlProps, GeneralSelectValue, } from 'sentry/components/forms/selectControl'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import space from 'sentry/styles/space'; interface TriggerRenderingProps { props: Omit; ref: React.RefObject; } interface MenuProps extends OverlayProps, Omit { children: (maxHeight: number | string) => React.ReactNode; maxMenuHeight?: number; } interface Props extends Omit, 'choices'>, Partial, Partial { 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; onChangeValueMap?: (value: OptionType[]) => ControlProps['value']; /** * Tag name for the outer wrap, defaults to `div` */ renderWrapAs?: React.ElementType; /** * 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: TriggerRenderingProps) => 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( 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 )} )} ); }; // TODO(vl): Turn this into a reusable component function Menu({ // Trigger & trigger state targetRef, onClose, // Overlay props offset = 8, crossOffset = 0, containerPadding = 8, placement = 'bottom left', shouldCloseOnBlur = true, isDismissable = true, maxMenuHeight = 400, children, }: MenuProps) { // Control the overlay's position const overlayRef = useRef(null); const {overlayProps} = useOverlay( { isOpen: true, onClose, shouldCloseOnBlur, isDismissable, shouldCloseOnInteractOutside: target => target && targetRef.current !== target && !targetRef.current?.contains(target), }, overlayRef ); const {overlayProps: positionProps} = useOverlayPosition({ targetRef, overlayRef, offset, crossOffset, placement, containerPadding, isOpen: true, }); // 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 = targetRef.current?.offsetWidth; newTriggerWidth && setTriggerWidth(newTriggerWidth); }, [targetRef]); useResizeObserver({ref: targetRef, 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]); const menuHeight = positionProps.style?.maxHeight ? Math.min(+positionProps.style?.maxHeight, maxMenuHeight) : 'none'; return ( {children(menuHeight)} ); } /** * 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({ // Select props options, onChange, defaultValue, value: valueProp, isDisabled: disabledProp, isSearchable = false, multiple, placeholder = 'Search…', onChangeValueMap, // Trigger button & wrapper props trigger, triggerLabel, triggerProps, className, renderWrapAs, closeOnSelect = true, menuTitle, ...props }: Props) { // Manage the dropdown menu's open state const isDisabled = disabledProp || options?.length === 0; const triggerRef = useRef(null); const state = useMenuTriggerState(props); const {menuTriggerProps} = useMenuTrigger( {type: 'listbox', isDisabled}, state, triggerRef ); const {buttonProps} = useButton( {onPress: () => state.toggle(), 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); // Update the button label when the value changes const getLabel = useCallback((): React.ReactNode => { 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 [label, setLabel] = useState(null); useEffect(() => { setLabel(getLabel()); }, [getLabel]); function onValueChange(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(); } } function renderTrigger() { if (trigger) { return trigger({ props: { ...triggerProps, ...buttonProps, isOpen: state.isOpen, }, ref: triggerRef, }); } return ( {triggerLabel ?? label} ); } function renderMenu() { if (!state.isOpen) { return null; } return ( {menuHeight => ( )} ); } return ( {renderTrigger()} {renderMenu()} ); } export default CompactSelect; const MenuControlWrap = styled('div')``; const ButtonLabel = styled('span')` ${p => p.theme.overflowEllipsis} `; const StyledBadge = styled(Badge)` flex-shrink: 0; top: auto; `; const Overlay = styled('div')<{minWidth?: number}>` max-width: calc(100% - ${space(2)}); border-radius: ${p => p.theme.borderRadius}; background: ${p => p.theme.backgroundElevated}; box-shadow: 0 0 0 1px ${p => p.theme.translucentBorder}, ${p => p.theme.dropShadowHeavy}; font-size: ${p => p.theme.fontSizeMedium}; overflow: hidden; /* Override z-index from useOverlayPosition */ z-index: ${p => p.theme.zIndex.dropdown} !important; ${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-right: ${space(2)}; `; 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}; `;