Skip to content

Commit 944cc87

Browse files
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 <[email protected]> * 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 <[email protected]>
1 parent 0d1fbc4 commit 944cc87

File tree

3 files changed

+148
-34
lines changed

3 files changed

+148
-34
lines changed

packages/grafana-ui/src/components/Combobox/Combobox.internal.story.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const BasicWithState: StoryFn<typeof Combobox> = (args) => {
4444
{...args}
4545
value={value}
4646
onChange={(val) => {
47+
if (!val) {
48+
return;
49+
}
4750
setValue(val.value);
4851
action('onChange')(val);
4952
}}
@@ -74,6 +77,9 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions }) => {
7477
options={options}
7578
value={value}
7679
onChange={(val) => {
80+
if (!val) {
81+
return;
82+
}
7783
setValue(val.value);
7884
action('onChange')(val);
7985
}}

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

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { css } from '@emotion/css';
1+
import { cx } from '@emotion/css';
2+
import { autoUpdate, flip, useFloating } from '@floating-ui/react';
23
import { useVirtualizer } from '@tanstack/react-virtual';
34
import { useCombobox } from 'downshift';
45
import { useMemo, useRef, useState } from 'react';
@@ -7,6 +8,8 @@ import { useStyles2 } from '../../themes';
78
import { Icon } from '../Icon/Icon';
89
import { Input, Props as InputProps } from '../Input/Input';
910

11+
import { getComboboxStyles } from './getComboboxStyles';
12+
1013
export type Value = string | number;
1114
export type Option = {
1215
label: string;
@@ -16,7 +19,7 @@ export type Option = {
1619

1720
interface ComboboxProps
1821
extends Omit<InputProps, 'width' | 'prefix' | 'suffix' | 'value' | 'addonBefore' | 'addonAfter' | 'onChange'> {
19-
onChange: (val: Option) => void;
22+
onChange: (val: Option | null) => void;
2023
value: Value;
2124
options: Option[];
2225
}
@@ -42,20 +45,21 @@ function estimateSize() {
4245
}
4346

4447
export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxProps) => {
48+
const MIN_WIDTH = 400;
4549
const [items, setItems] = useState(options);
4650
const selectedItem = useMemo(() => options.find((option) => option.value === value) || null, [options, value]);
47-
const listRef = useRef(null);
48-
49-
const styles = useStyles2(getStyles);
51+
const inputRef = useRef<HTMLInputElement>(null);
52+
const floatingRef = useRef(null);
53+
const styles = useStyles2(getComboboxStyles);
5054

5155
const rowVirtualizer = useVirtualizer({
5256
count: items.length,
53-
getScrollElement: () => listRef.current,
57+
getScrollElement: () => floatingRef.current,
5458
estimateSize,
5559
overscan: 2,
5660
});
5761

58-
const { getInputProps, getMenuProps, getItemProps, isOpen } = useCombobox({
62+
const { getInputProps, getMenuProps, getItemProps, isOpen, highlightedIndex } = useCombobox({
5963
items,
6064
itemToString,
6165
selectedItem,
@@ -70,26 +74,70 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
7074
}
7175
},
7276
});
77+
78+
// the order of middleware is important!
79+
const middleware = [
80+
flip({
81+
// see https://floating-ui.com/docs/flip#combining-with-shift
82+
crossAxis: false,
83+
boundary: document.body,
84+
fallbackPlacements: ['top'],
85+
}),
86+
];
87+
const elements = { reference: inputRef.current, floating: floatingRef.current };
88+
const { floatingStyles } = useFloating({
89+
open: isOpen,
90+
placement: 'bottom',
91+
middleware,
92+
elements,
93+
whileElementsMounted: autoUpdate,
94+
});
95+
96+
const hasMinHeight = isOpen && rowVirtualizer.getTotalSize() >= MIN_WIDTH;
97+
7398
return (
7499
<div>
75-
<Input suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />} {...restProps} {...getInputProps()} />
76-
<div className={styles.dropdown} {...getMenuProps({ ref: listRef })}>
100+
<Input
101+
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
102+
{...restProps}
103+
{...getInputProps({
104+
ref: inputRef,
105+
/* Empty onCall to avoid TS error
106+
* See issue here: https://github.com/downshift-js/downshift/issues/718
107+
* Downshift repo: https://github.com/downshift-js/downshift/tree/master
108+
*/
109+
onChange: () => {},
110+
})}
111+
/>
112+
<div
113+
className={cx(styles.menu, hasMinHeight && styles.menuHeight)}
114+
style={{ ...floatingStyles, width: elements.reference?.getBoundingClientRect().width }}
115+
{...getMenuProps({ ref: floatingRef })}
116+
>
77117
{isOpen && (
78-
<ul style={{ height: rowVirtualizer.getTotalSize() }}>
118+
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
79119
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
80120
return (
81121
<li
82122
key={items[virtualRow.index].value}
83123
{...getItemProps({ item: items[virtualRow.index], index: virtualRow.index })}
84124
data-index={virtualRow.index}
85125
ref={rowVirtualizer.measureElement}
86-
className={styles.menuItem}
126+
className={cx(
127+
styles.option,
128+
selectedItem && items[virtualRow.index].value === selectedItem.value && styles.optionSelected,
129+
highlightedIndex === virtualRow.index && styles.optionFocused
130+
)}
87131
style={{
88132
transform: `translateY(${virtualRow.start}px)`,
89133
}}
90134
>
91-
<span>{items[virtualRow.index].label}</span>
92-
{items[virtualRow.index].description && <span>{items[virtualRow.index].description}</span>}
135+
<div className={styles.optionBody}>
136+
<span>{items[virtualRow.index].label}</span>
137+
{items[virtualRow.index].description && (
138+
<span className={styles.optionDescription}>{items[virtualRow.index].description}</span>
139+
)}
140+
</div>
93141
</li>
94142
);
95143
})}
@@ -99,24 +147,3 @@ export const Combobox = ({ options, onChange, value, ...restProps }: ComboboxPro
99147
</div>
100148
);
101149
};
102-
103-
const getStyles = () => ({
104-
dropdown: css({
105-
position: 'absolute',
106-
height: 400,
107-
width: 600,
108-
overflowY: 'scroll',
109-
contain: 'strict',
110-
}),
111-
menuItem: css({
112-
position: 'absolute',
113-
top: 0,
114-
left: 0,
115-
width: '100%',
116-
display: 'flex',
117-
flexDirection: 'column',
118-
'&:first-child': {
119-
fontWeight: 'bold',
120-
},
121-
}),
122-
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { css } from '@emotion/css';
2+
3+
import { GrafanaTheme2 } from '@grafana/data';
4+
5+
export const getComboboxStyles = (theme: GrafanaTheme2) => {
6+
return {
7+
menu: css({
8+
label: 'grafana-select-menu',
9+
background: theme.components.dropdown.background,
10+
boxShadow: theme.shadows.z3,
11+
position: 'relative',
12+
zIndex: 1,
13+
}),
14+
menuHeight: css({
15+
height: 400,
16+
overflowY: 'scroll',
17+
position: 'relative',
18+
}),
19+
menuUlContainer: css({
20+
label: 'grafana-select-menu-ul-container',
21+
listStyle: 'none',
22+
}),
23+
option: css({
24+
label: 'grafana-select-option',
25+
position: 'absolute',
26+
top: 0,
27+
left: 0,
28+
width: '100%',
29+
whiteSpace: 'nowrap',
30+
cursor: 'pointer',
31+
borderLeft: '2px solid transparent',
32+
padding: theme.spacing.x1,
33+
boxSizing: 'border-box',
34+
height: 'auto',
35+
'&:hover': {
36+
background: theme.colors.action.hover,
37+
'@media (forced-colors: active), (prefers-contrast: more)': {
38+
border: `1px solid ${theme.colors.primary.border}`,
39+
},
40+
},
41+
}),
42+
optionBody: css({
43+
label: 'grafana-select-option-body',
44+
display: 'flex',
45+
fontWeight: theme.typography.fontWeightMedium,
46+
flexDirection: 'column',
47+
flexGrow: 1,
48+
}),
49+
optionDescription: css({
50+
label: 'grafana-select-option-description',
51+
fontWeight: 'normal',
52+
fontSize: theme.typography.bodySmall.fontSize,
53+
color: theme.colors.text.secondary,
54+
whiteSpace: 'normal',
55+
lineHeight: theme.typography.body.lineHeight,
56+
}),
57+
optionFocused: css({
58+
label: 'grafana-select-option-focused',
59+
top: 0,
60+
background: theme.colors.action.focus,
61+
'@media (forced-colors: active), (prefers-contrast: more)': {
62+
border: `1px solid ${theme.colors.primary.border}`,
63+
},
64+
}),
65+
optionSelected: css({
66+
background: theme.colors.action.selected,
67+
'&::before': {
68+
backgroundImage: theme.colors.gradients.brandVertical,
69+
borderRadius: theme.shape.radius.default,
70+
content: '" "',
71+
display: 'block',
72+
height: '100%',
73+
position: 'absolute',
74+
transform: 'translateX(-50%)',
75+
width: theme.spacing(0.5),
76+
left: 0,
77+
top: 0,
78+
},
79+
}),
80+
};
81+
};

0 commit comments

Comments
 (0)