diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx index 120e333e76d..329362149ae 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx @@ -44,6 +44,9 @@ const BasicWithState: StoryFn = (args) => { {...args} value={value} onChange={(val) => { + if (!val) { + return; + } setValue(val.value); action('onChange')(val); }} @@ -74,6 +77,9 @@ const ManyOptionsStory: StoryFn = ({ numberOfOptions }) => { options={options} value={value} onChange={(val) => { + if (!val) { + return; + } setValue(val.value); action('onChange')(val); }} diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index af247f7bae1..a045d938ade 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -1,4 +1,5 @@ -import { css } from '@emotion/css'; +import { cx } from '@emotion/css'; +import { autoUpdate, flip, useFloating } from '@floating-ui/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useCombobox } from 'downshift'; import { useMemo, useRef, useState } from 'react'; @@ -7,6 +8,8 @@ import { useStyles2 } from '../../themes'; import { Icon } from '../Icon/Icon'; import { Input, Props as InputProps } from '../Input/Input'; +import { getComboboxStyles } from './getComboboxStyles'; + export type Value = string | number; export type Option = { label: string; @@ -16,7 +19,7 @@ export type Option = { interface ComboboxProps extends Omit { - onChange: (val: Option) => void; + onChange: (val: Option | null) => void; value: Value; options: Option[]; } @@ -42,20 +45,21 @@ function estimateSize() { } export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => { + const MIN_WIDTH = 400; const [items, setItems] = useState(options); const selectedItem = useMemo(() => options.find((option) => option.value === value) || null, [options, value]); - const listRef = useRef(null); - - const styles = useStyles2(getStyles); + const inputRef = useRef(null); + const floatingRef = useRef(null); + const styles = useStyles2(getComboboxStyles); const rowVirtualizer = useVirtualizer({ count: items.length, - getScrollElement: () => listRef.current, + getScrollElement: () => floatingRef.current, estimateSize, overscan: 2, }); - const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({ + const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex } = useCombobox({ items, itemToString, selectedItem, @@ -70,12 +74,48 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro } }, }); + + // the order of middleware is important! + const middleware = [ + flip({ + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + fallbackPlacements: ['top'], + }), + ]; + const elements = { reference: inputRef.current, floating: floatingRef.current }; + const { floatingStyles } = useFloating({ + open: isOpen, + placement: 'bottom', + middleware, + elements, + whileElementsMounted: autoUpdate, + }); + + const hasMinHeight = isOpen && rowVirtualizer.getTotalSize() >= MIN_WIDTH; + return (
- } {...restProps} {...getInputProps()} /> -
+ } + {...restProps} + {...getInputProps({ + ref: inputRef, + /* Empty onCall to avoid TS error + * See issue here: https://github.com/downshift-js/downshift/issues/718 + * Downshift repo: https://github.com/downshift-js/downshift/tree/master + */ + onChange: () => {}, + })} + /> +
{isOpen && ( -
    +
      {rowVirtualizer.getVirtualItems().map((virtualRow) => { return (
    • - {items[virtualRow.index].label} - {items[virtualRow.index].description && {items[virtualRow.index].description}} +
      + {items[virtualRow.index].label} + {items[virtualRow.index].description && ( + {items[virtualRow.index].description} + )} +
    • ); })} @@ -99,24 +147,3 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
); }; - -const getStyles = () => ({ - dropdown: css({ - position: 'absolute', - height: 400, - width: 600, - overflowY: 'scroll', - contain: 'strict', - }), - menuItem: css({ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - display: 'flex', - flexDirection: 'column', - '&:first-child': { - fontWeight: 'bold', - }, - }), -}); diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts new file mode 100644 index 00000000000..2deec9dde09 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts @@ -0,0 +1,81 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getComboboxStyles = (theme: GrafanaTheme2) => { + return { + menu: css({ + label: 'grafana-select-menu', + background: theme.components.dropdown.background, + boxShadow: theme.shadows.z3, + position: 'relative', + zIndex: 1, + }), + menuHeight: css({ + height: 400, + overflowY: 'scroll', + position: 'relative', + }), + menuUlContainer: css({ + label: 'grafana-select-menu-ul-container', + listStyle: 'none', + }), + option: css({ + label: 'grafana-select-option', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + whiteSpace: 'nowrap', + cursor: 'pointer', + borderLeft: '2px solid transparent', + padding: theme.spacing.x1, + boxSizing: 'border-box', + height: 'auto', + '&:hover': { + background: theme.colors.action.hover, + '@media (forced-colors: active), (prefers-contrast: more)': { + border: `1px solid ${theme.colors.primary.border}`, + }, + }, + }), + optionBody: css({ + label: 'grafana-select-option-body', + display: 'flex', + fontWeight: theme.typography.fontWeightMedium, + flexDirection: 'column', + flexGrow: 1, + }), + optionDescription: css({ + label: 'grafana-select-option-description', + fontWeight: 'normal', + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + whiteSpace: 'normal', + lineHeight: theme.typography.body.lineHeight, + }), + optionFocused: css({ + label: 'grafana-select-option-focused', + top: 0, + background: theme.colors.action.focus, + '@media (forced-colors: active), (prefers-contrast: more)': { + border: `1px solid ${theme.colors.primary.border}`, + }, + }), + optionSelected: css({ + background: theme.colors.action.selected, + '&::before': { + backgroundImage: theme.colors.gradients.brandVertical, + borderRadius: theme.shape.radius.default, + content: '" "', + display: 'block', + height: '100%', + position: 'absolute', + transform: 'translateX(-50%)', + width: theme.spacing(0.5), + left: 0, + top: 0, + }, + }), + }; +};