mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
0d1fbc485f
commit
944cc87f65
@ -44,6 +44,9 @@ const BasicWithState: StoryFn<typeof Combobox> = (args) => {
|
|||||||
{...args}
|
{...args}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
|
if (!val) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setValue(val.value);
|
setValue(val.value);
|
||||||
action('onChange')(val);
|
action('onChange')(val);
|
||||||
}}
|
}}
|
||||||
@ -74,6 +77,9 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions }) => {
|
|||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
|
if (!val) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setValue(val.value);
|
setValue(val.value);
|
||||||
action('onChange')(val);
|
action('onChange')(val);
|
||||||
}}
|
}}
|
||||||
|
@ -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 { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useCombobox } from 'downshift';
|
import { useCombobox } from 'downshift';
|
||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
@ -7,6 +8,8 @@ import { useStyles2 } from '../../themes';
|
|||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
import { Input, Props as InputProps } from '../Input/Input';
|
import { Input, Props as InputProps } from '../Input/Input';
|
||||||
|
|
||||||
|
import { getComboboxStyles } from './getComboboxStyles';
|
||||||
|
|
||||||
export type Value = string | number;
|
export type Value = string | number;
|
||||||
export type Option = {
|
export type Option = {
|
||||||
label: string;
|
label: string;
|
||||||
@ -16,7 +19,7 @@ export type Option = {
|
|||||||
|
|
||||||
interface ComboboxProps
|
interface ComboboxProps
|
||||||
extends Omit<InputProps, 'width' | 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> {
|
extends Omit<InputProps, 'width' | 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> {
|
||||||
onChange: (val: Option) => void;
|
onChange: (val: Option | null) => void;
|
||||||
value: Value;
|
value: Value;
|
||||||
options: Option[];
|
options: Option[];
|
||||||
}
|
}
|
||||||
@ -42,20 +45,21 @@ function estimateSize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => {
|
export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => {
|
||||||
|
const MIN_WIDTH = 400;
|
||||||
const [items, setItems] = useState(options);
|
const [items, setItems] = useState(options);
|
||||||
const selectedItem = useMemo(() => options.find((option) => option.value === value) || null, [options, value]);
|
const selectedItem = useMemo(() => options.find((option) => option.value === value) || null, [options, value]);
|
||||||
const listRef = useRef(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const floatingRef = useRef(null);
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getComboboxStyles);
|
||||||
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
const rowVirtualizer = useVirtualizer({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
getScrollElement: () => listRef.current,
|
getScrollElement: () => floatingRef.current,
|
||||||
estimateSize,
|
estimateSize,
|
||||||
overscan: 2,
|
overscan: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({
|
const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex } = useCombobox({
|
||||||
items,
|
items,
|
||||||
itemToString,
|
itemToString,
|
||||||
selectedItem,
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Input suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} {...restProps} {...getInputProps()} />
|
<Input
|
||||||
<div className={styles.dropdown} {...getMenuProps({ ref: listRef })}>
|
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 && (
|
{isOpen && (
|
||||||
<ul style={{ height: rowVirtualizer.getTotalSize() }}>
|
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
@ -83,13 +123,21 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
|
|||||||
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
|
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
|
||||||
data-index={virtualRow.index}
|
data-index={virtualRow.index}
|
||||||
ref={rowVirtualizer.measureElement}
|
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={{
|
style={{
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<div className={styles.optionBody}>
|
||||||
<span>{items[virtualRow.index].label}</span>
|
<span>{items[virtualRow.index].label}</span>
|
||||||
{items[virtualRow.index].description && <span>{items[virtualRow.index].description}</span>}
|
{items[virtualRow.index].description && (
|
||||||
|
<span className={styles.optionDescription}>{items[virtualRow.index].description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -99,24 +147,3 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
|
|||||||
</div>
|
</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',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user