Combobox: Styling for dropdown (#90140)

* Add getSelectStyles

* Modify combobox styles

* Fix option with description styles

* Add highlightedIndex

* Undo estimateSize changes

* Create getComboboxStyles

* Add floating ui to Combobox

* Use elements to apply existing refs

* Delete width on styles

* Fix menu styling

* Update packages/grafana-ui/src/components/Combobox/Combobox.tsx

Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>

* Changes suggested in reviews

* Delete container styles

* Delete container styles

* Add calculated height to ul element

* Show all options in the many options story

* Replace deprecated code

* Remove console.log

* Fix ts error

* Fix ts error

* Fix val is mull error

* Fix ts error

* Add comment in the code

* Modify the comment

---------

Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>
This commit is contained in:
Laura Fernández 2024-07-25 11:17:23 +02:00 committed by GitHub
parent 0d1fbc485f
commit 944cc87f65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 148 additions and 34 deletions

View File

@ -44,6 +44,9 @@ const BasicWithState: StoryFn<typeof Combobox> = (args) => {
{...args}
value={value}
onChange={(val) => {
if (!val) {
return;
}
setValue(val.value);
action('onChange')(val);
}}
@ -74,6 +77,9 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions }) => {
options={options}
value={value}
onChange={(val) => {
if (!val) {
return;
}
setValue(val.value);
action('onChange')(val);
}}

View File

@ -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<InputProps, 'width' | 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> {
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<HTMLInputElement>(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 (
<div>
<Input suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} {...restProps} {...getInputProps()} />
<div className={styles.dropdown} {...getMenuProps({ ref: listRef })}>
<Input
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
{...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: () => {},
})}
/>
<div
className={cx(styles.menu, hasMinHeight && styles.menuHeight)}
style={{ ...floatingStyles, width: elements.reference?.getBoundingClientRect().width }}
{...getMenuProps({ ref: floatingRef })}
>
{isOpen && (
<ul style={{ height: rowVirtualizer.getTotalSize() }}>
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
return (
<li
@ -83,13 +123,21 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={styles.menuItem}
className={cx(
styles.option,
selectedItem && items[virtualRow.index].value === selectedItem.value && styles.optionSelected,
highlightedIndex === virtualRow.index && styles.optionFocused
)}
style={{
transform: `translateY(${virtualRow.start}px)`,
}}
>
<span>{items[virtualRow.index].label}</span>
{items[virtualRow.index].description && <span>{items[virtualRow.index].description}</span>}
<div className={styles.optionBody}>
<span>{items[virtualRow.index].label}</span>
{items[virtualRow.index].description && (
<span className={styles.optionDescription}>{items[virtualRow.index].description}</span>
)}
</div>
</li>
);
})}
@ -99,24 +147,3 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
</div>
);
};
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',
},
}),
});

View File

@ -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,
},
}),
};
};