123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431 |
- 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/dropdownButton';
- import SelectControl, {
- ControlProps,
- GeneralSelectValue,
- } from 'sentry/components/forms/selectControl';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import space from 'sentry/styles/space';
- import {FormSize} from 'sentry/utils/theme';
- interface TriggerRenderingProps {
- props: Omit<DropdownButtonProps, 'children'>;
- ref: React.RefObject<HTMLButtonElement>;
- }
- interface MenuProps extends OverlayProps, Omit<AriaPositionProps, 'overlayRef'> {
- children: (maxHeight: number | string) => React.ReactNode;
- maxMenuHeight?: number;
- minMenuWidth?: number;
- }
- interface Props<OptionType>
- extends Omit<ControlProps<OptionType>, 'choices'>,
- Partial<OverlayProps>,
- Partial<AriaPositionProps> {
- 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;
- 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: 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<OptionType extends GeneralSelectValue = GeneralSelectValue>(
- opts: Props<OptionType>['options'],
- value: Props<OptionType>['value']
- ): Props<OptionType>['options'] {
- return opts.reduce((acc: Props<OptionType>['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>
- );
- };
- // 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,
- minMenuWidth,
- children,
- }: MenuProps) {
- // Control the overlay's position
- const overlayRef = useRef<HTMLDivElement>(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,
- });
- const menuHeight = positionProps.style?.maxHeight
- ? Math.min(+positionProps.style?.maxHeight, maxMenuHeight)
- : 'none';
- return (
- <Overlay
- ref={overlayRef}
- minWidth={minMenuWidth}
- {...mergeProps(overlayProps, positionProps)}
- >
- <FocusScope restoreFocus autoFocus>
- {children(menuHeight)}
- </FocusScope>
- </Overlay>
- );
- }
- /**
- * 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>({
- // Select props
- options,
- onChange,
- defaultValue,
- value: valueProp,
- isDisabled: disabledProp,
- isSearchable = false,
- multiple,
- placeholder = 'Search…',
- onChangeValueMap,
- // Trigger button & wrapper props
- trigger,
- triggerLabel,
- triggerProps,
- size = 'md',
- className,
- renderWrapAs,
- closeOnSelect = true,
- menuTitle,
- onClose,
- ...props
- }: Props<OptionType>) {
- // Manage the dropdown menu's open state
- const isDisabled = disabledProp || options?.length === 0;
- const triggerRef = useRef<HTMLButtonElement>(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<OptionType>(options, val))
- .flat();
- return (
- <Fragment>
- <ButtonLabel>{selectedOptions[0]?.label ?? ''}</ButtonLabel>
- {selectedOptions.length > 1 && (
- <StyledBadge text={`+${selectedOptions.length - 1}`} />
- )}
- </Fragment>
- );
- }, [options, valueProp, internalValue]);
- const [label, setLabel] = useState<React.ReactNode>(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();
- }
- }
- // 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({
- props: {
- size,
- isOpen: state.isOpen,
- ...triggerProps,
- ...buttonProps,
- },
- ref: triggerRef,
- });
- }
- return (
- <DropdownButton
- ref={triggerRef}
- size={size}
- isOpen={state.isOpen}
- {...triggerProps}
- {...buttonProps}
- >
- {triggerLabel ?? label}
- </DropdownButton>
- );
- }
- function onMenuClose() {
- onClose?.();
- state.close();
- }
- function renderMenu() {
- if (!state.isOpen) {
- return null;
- }
- return (
- <Menu
- targetRef={triggerRef}
- onClose={onMenuClose}
- minMenuWidth={triggerWidth}
- {...props}
- >
- {menuHeight => (
- <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
- />
- )}
- </Menu>
- );
- }
- 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 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.75)} ${space(1)} ${space(0.75)} ${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};
- `;
|