123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- import {useCallback} from 'react';
- import styled from '@emotion/styled';
- import memoize from 'lodash/memoize';
- import AutoComplete from 'sentry/components/autoComplete';
- import DropdownBubble from 'sentry/components/dropdownBubble';
- import Input from 'sentry/components/input';
- import LoadingIndicator from 'sentry/components/loadingIndicator';
- import {t} from 'sentry/locale';
- import space from 'sentry/styles/space';
- import defaultAutoCompleteFilter from './autoCompleteFilter';
- import List from './list';
- import {Item, ItemsBeforeFilter} from './types';
- type AutoCompleteChildrenArgs = Parameters<AutoComplete<Item>['props']['children']>[0];
- type Actions = AutoCompleteChildrenArgs['actions'];
- export type MenuFooterChildProps = {
- actions: Actions;
- };
- type ListProps = React.ComponentProps<typeof List>;
- type Props = {
- children: (
- args: Pick<
- AutoCompleteChildrenArgs,
- 'getInputProps' | 'getActorProps' | 'actions' | 'isOpen' | 'selectedItem'
- >
- ) => React.ReactNode;
- /** null items indicates loading */
- items: ItemsBeforeFilter | null;
- /**
- * Dropdown menu alignment.
- */
- alignMenu?: 'left' | 'right';
- /**
- * Optionally provide a custom implementation for filtering result items
- * Useful if you want to show items that don't strictly match the input value
- */
- autoCompleteFilter?: typeof defaultAutoCompleteFilter;
- /**
- * Should menu visually lock to a direction (so we don't display a rounded corner)
- */
- blendCorner?: boolean;
- /**
- * Show loading indicator next to input and "Searching..." text in the list
- */
- busy?: boolean;
- /**
- * Show loading indicator next to input but don't hide list items
- */
- busyItemsStillVisible?: boolean;
- /**
- * for passing styles to the DropdownBubble
- */
- className?: string;
- /**
- * AutoComplete prop
- */
- closeOnSelect?: boolean;
- css?: any;
- 'data-test-id'?: string;
- /**
- * If true, the menu will be visually detached from actor.
- */
- detached?: boolean;
- /**
- * Disables padding for the label.
- */
- disableLabelPadding?: boolean;
- /**
- * passed down to the AutoComplete Component
- */
- disabled?: boolean;
- /**
- * Hide's the input when there are no items. Avoid using this when querying
- * results in an async fashion.
- */
- emptyHidesInput?: boolean;
- /**
- * Message to display when there are no items initially
- */
- emptyMessage?: React.ReactNode;
- /**
- * If this is undefined, autocomplete filter will use this value instead of the
- * current value in the filter input element.
- *
- * This is useful if you need to strip characters out of the search
- */
- filterValue?: string;
- /**
- * Hides the default filter input
- */
- hideInput?: boolean;
- /**
- * renderProp for the end (right side) of the search input
- */
- inputActions?: React.ReactElement;
- /**
- * Props to pass to input/filter component
- */
- inputProps?: {style: React.CSSProperties};
- /**
- * Used to control the input value (optional)
- */
- inputValue?: string;
- /**
- * Used to control dropdown state (optional)
- */
- isOpen?: boolean;
- /**
- * Max height of dropdown menu. Units are assumed as `px`
- */
- maxHeight?: ListProps['maxHeight'];
- menuFooter?:
- | React.ReactElement
- | ((props: MenuFooterChildProps) => React.ReactElement | null);
- menuHeader?: React.ReactElement;
- /**
- * Props to pass to menu component
- */
- menuProps?: Parameters<AutoCompleteChildrenArgs['getMenuProps']>[0];
- /**
- * Minimum menu width, defaults to 250
- */
- minWidth?: number;
- /**
- * Message to display when there are no items that match the search
- */
- noResultsMessage?: React.ReactNode;
- /**
- * When AutoComplete input changes
- */
- onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
- /**
- * Callback for when dropdown menu closes
- */
- onClose?: () => void;
- /**
- * Callback for when the input value changes
- */
- onInputValueChange?: (value: string) => void;
- /**
- * Callback for when dropdown menu opens
- */
- onOpen?: (event?: React.MouseEvent) => void;
- /**
- * When an item is selected (via clicking dropdown, or keyboard navigation)
- */
- onSelect?: (
- item: Item,
- state?: AutoComplete<Item>['state'],
- e?: React.MouseEvent | React.KeyboardEvent
- ) => void;
- /**
- * for passing simple styles to the root container
- */
- rootClassName?: string;
- /**
- * Search input's placeholder text
- */
- searchPlaceholder?: string;
- /**
- * the styles are forward to the Autocomplete's getMenuProps func
- */
- style?: React.CSSProperties;
- /**
- * Optional element to be rendered on the right side of the dropdown menu
- */
- subPanel?: React.ReactNode;
- } & Pick<
- ListProps,
- 'virtualizedHeight' | 'virtualizedLabelHeight' | 'itemSize' | 'onScroll'
- >;
- function Menu({
- autoCompleteFilter = defaultAutoCompleteFilter,
- maxHeight = 300,
- emptyMessage = t('No items'),
- searchPlaceholder = t('Filter search'),
- blendCorner = true,
- detached = false,
- alignMenu = 'left',
- minWidth = 250,
- hideInput = false,
- disableLabelPadding = false,
- busy = false,
- busyItemsStillVisible = false,
- disabled = false,
- subPanel = null,
- itemSize,
- virtualizedHeight,
- virtualizedLabelHeight,
- menuProps,
- noResultsMessage,
- inputProps,
- children,
- rootClassName,
- className,
- emptyHidesInput,
- menuHeader,
- filterValue,
- items,
- menuFooter,
- style,
- onScroll,
- inputActions,
- onChange,
- onSelect,
- onOpen,
- onClose,
- css,
- closeOnSelect,
- 'data-test-id': dataTestId,
- ...props
- }: Props) {
- // Can't search if there are no items
- const hasItems = !!items?.length;
- // Items are loading if null
- const itemsLoading = items === null;
- // Hide the input when we have no items to filter, only if
- // emptyHidesInput is set to true.
- const showInput = !hideInput && (hasItems || !emptyHidesInput);
- // Only redefine the autocomplete function if our items list has changed.
- // This avoids producing a new array on every call.
- const stableItemFilter = useCallback(
- (filterValueOrInput: string) => autoCompleteFilter(items, filterValueOrInput),
- [autoCompleteFilter, items]
- );
- // Memoize the filterValueOrInput to the stableItemFilter so that we get the
- // same list every time when the filter value doesn't change.
- const getFilteredItems = memoize(stableItemFilter);
- return (
- <AutoComplete
- onSelect={onSelect}
- inputIsActor={false}
- onOpen={onOpen}
- onClose={onClose}
- disabled={disabled}
- closeOnSelect={closeOnSelect}
- resetInputOnClose
- {...props}
- >
- {({
- getActorProps,
- getRootProps,
- getInputProps,
- getMenuProps,
- getItemProps,
- registerItemCount,
- registerVisibleItem,
- inputValue,
- selectedItem,
- highlightedIndex,
- isOpen,
- actions,
- }) => {
- // This is the value to use to filter (default to value in filter input)
- const filterValueOrInput = filterValue ?? inputValue;
- // Only filter results if menu is open and there are items. Uses
- // `getFilteredItems` to ensure we get a stable items list back.
- const autoCompleteResults =
- isOpen && hasItems ? getFilteredItems(filterValueOrInput) : [];
- // Has filtered results
- const hasResults = !!autoCompleteResults.length;
- // No items to display
- const showNoItems = !busy && !filterValueOrInput && !hasItems;
- // Results mean there was an attempt to search
- const showNoResultsMessage =
- !busy && !busyItemsStillVisible && filterValueOrInput && !hasResults;
- // When virtualization is turned on, we need to pass in the number of
- // selectable items for arrow-key limits
- const itemCount = virtualizedHeight
- ? autoCompleteResults.filter(i => !i.groupLabel).length
- : undefined;
- const renderedFooter =
- typeof menuFooter === 'function' ? menuFooter({actions}) : menuFooter;
- // XXX(epurkhiser): Would be better if this happened in a useEffect,
- // but hooks do not work inside render-prop callbacks.
- registerItemCount(itemCount);
- return (
- <AutoCompleteRoot
- {...getRootProps()}
- className={rootClassName}
- disabled={disabled}
- data-is-open={isOpen}
- data-test-id={dataTestId}
- >
- {children({
- getInputProps,
- getActorProps,
- actions,
- isOpen,
- selectedItem,
- })}
- {isOpen && (
- <StyledDropdownBubble
- className={className}
- {...getMenuProps(menuProps)}
- {...{style, css, blendCorner, detached, alignMenu, minWidth}}
- >
- <DropdownMainContent minWidth={minWidth}>
- {itemsLoading && <LoadingIndicator mini />}
- {showInput && (
- <InputWrapper>
- <StyledInput
- autoFocus
- placeholder={searchPlaceholder}
- {...getInputProps({...inputProps, onChange})}
- />
- <InputLoadingWrapper>
- {(busy || busyItemsStillVisible) && (
- <LoadingIndicator size={16} mini />
- )}
- </InputLoadingWrapper>
- {inputActions}
- </InputWrapper>
- )}
- <div>
- {menuHeader && (
- <LabelWithPadding disableLabelPadding={disableLabelPadding}>
- {menuHeader}
- </LabelWithPadding>
- )}
- <ItemList data-test-id="autocomplete-list" maxHeight={maxHeight}>
- {showNoItems && <EmptyMessage>{emptyMessage}</EmptyMessage>}
- {showNoResultsMessage && (
- <EmptyMessage>
- {noResultsMessage ?? `${emptyMessage} ${t('found')}`}
- </EmptyMessage>
- )}
- {busy && (
- <BusyMessage>
- <EmptyMessage>{t('Searching\u2026')}</EmptyMessage>
- </BusyMessage>
- )}
- {!busy && (
- <List
- items={autoCompleteResults}
- {...{
- maxHeight,
- highlightedIndex,
- inputValue,
- onScroll,
- getItemProps,
- registerVisibleItem,
- virtualizedLabelHeight,
- virtualizedHeight,
- itemSize,
- }}
- />
- )}
- </ItemList>
- {renderedFooter && (
- <LabelWithPadding disableLabelPadding={disableLabelPadding}>
- {renderedFooter}
- </LabelWithPadding>
- )}
- </div>
- </DropdownMainContent>
- {subPanel}
- </StyledDropdownBubble>
- )}
- </AutoCompleteRoot>
- );
- }}
- </AutoComplete>
- );
- }
- export default Menu;
- const StyledInput = styled(Input)`
- flex: 1;
- border: 1px solid transparent;
- &,
- &:focus,
- &:active,
- &:hover {
- border: 0;
- box-shadow: none;
- font-size: 13px;
- padding: ${space(1)};
- font-weight: normal;
- color: ${p => p.theme.gray300};
- }
- `;
- const InputLoadingWrapper = styled('div')`
- display: flex;
- background: ${p => p.theme.background};
- align-items: center;
- flex-shrink: 0;
- width: 30px;
- .loading.mini {
- height: 16px;
- margin: 0;
- }
- `;
- const EmptyMessage = styled('div')`
- color: ${p => p.theme.gray200};
- padding: ${space(2)};
- text-align: center;
- text-transform: none;
- `;
- export const AutoCompleteRoot = styled('div')<{disabled?: boolean}>`
- position: relative;
- display: inline-block;
- ${p => p.disabled && 'pointer-events: none;'}
- `;
- const StyledDropdownBubble = styled(DropdownBubble)<{minWidth: number}>`
- display: flex;
- min-width: ${p => p.minWidth}px;
- ${p => p.detached && p.alignMenu === 'left' && 'right: auto;'}
- ${p => p.detached && p.alignMenu === 'right' && 'left: auto;'}
- `;
- const DropdownMainContent = styled('div')<{minWidth: number}>`
- width: 100%;
- min-width: ${p => p.minWidth}px;
- `;
- const InputWrapper = styled('div')`
- display: flex;
- border-bottom: 1px solid ${p => p.theme.innerBorder};
- border-radius: ${p => `${p.theme.borderRadius} ${p.theme.borderRadius} 0 0`};
- align-items: center;
- `;
- const LabelWithPadding = styled('div')<{disableLabelPadding: boolean}>`
- background-color: ${p => p.theme.backgroundSecondary};
- border-bottom: 1px solid ${p => p.theme.innerBorder};
- border-width: 1px 0;
- color: ${p => p.theme.subText};
- font-size: ${p => p.theme.fontSizeMedium};
- &:first-child {
- border-top: none;
- }
- &:last-child {
- border-bottom: none;
- }
- padding: ${p => !p.disableLabelPadding && `${space(0.25)} ${space(1)}`};
- `;
- const ItemList = styled('div')<{maxHeight: NonNullable<Props['maxHeight']>}>`
- max-height: ${p => `${p.maxHeight}px`};
- overflow-y: auto;
- `;
- const BusyMessage = styled('div')`
- display: flex;
- justify-content: center;
- padding: ${space(1)};
- `;
|