Combobox: Measure text of longest label for dropdown width (#93937)

* Combobox: Measure text of longest label for dropdown width

* remove commented out code

* add story to compare to Select

* move magic numbers to constants to reference, and calculate the scrollbar width

* look at first 100,000 items
This commit is contained in:
Josh Hunt 2024-10-11 15:10:44 +01:00 committed by GitHub
parent 4d08f44667
commit 64bb94cc62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 172 additions and 42 deletions

View File

@ -3,8 +3,11 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react';
import { Chance } from 'chance';
import React, { ComponentProps, useEffect, useState } from 'react';
import { useTheme2 } from '../../themes/ThemeContext';
import { Alert } from '../Alert/Alert';
import { Divider } from '../Divider/Divider';
import { Field } from '../Forms/Field';
import { Select } from '../Select/Select';
import { Combobox, ComboboxOption } from './Combobox';
@ -73,7 +76,6 @@ async function generateOptions(amount: number): Promise<ComboboxOption[]> {
return Array.from({ length: amount }, (_, index) => ({
label: chance.sentence({ words: index % 5 }),
value: chance.guid(),
//description: chance.sentence(),
}));
}
@ -88,7 +90,6 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...arg
setIsLoading(false);
setOptions(options);
setValue(options[5].value);
console.log("I've set stuff");
});
}, 1000);
}, [numberOfOptions]);
@ -107,6 +108,123 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...arg
);
};
const SelectComparisonStory: StoryFn<typeof Combobox> = (args) => {
const [comboboxValue, setComboboxValue] = useState(args.value);
const theme = useTheme2();
return (
<div style={{ border: '1px solid ' + theme.colors.border.weak, padding: 16 }}>
<Field label="Combobox with default size">
<Combobox
id="combobox-default-size"
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Select with default size">
<Select
id="select-default-size"
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Divider />
<Field label="Combobox with explicit size (25)">
<Combobox
id="combobox-explicit-size"
width={25}
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Select with explicit size (25)">
<Select
id="select-explicit-size"
width={25}
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Divider />
<Field label="Combobox with auto width, minWidth 15">
<Combobox
id="combobox-auto-size"
width="auto"
minWidth={15}
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Select with auto width">
<Select
id="select-auto-size"
width="auto"
value={comboboxValue}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Combobox with auto width, minWidth 15, empty value">
<Combobox
id="combobox-auto-size-empty"
width="auto"
minWidth={15}
value={null}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
<Field label="Select with auto width, empty value">
<Select
id="select-auto-size-empty"
width="auto"
value={null}
options={args.options}
onChange={(val) => {
setComboboxValue(val?.value || null);
action('onChange')(val);
}}
/>
</Field>
</div>
);
};
export const AutoSize: StoryObj<PropsAndCustomArgs> = {
args: {
width: 'auto',
@ -130,6 +248,13 @@ export const CustomValue: StoryObj<PropsAndCustomArgs> = {
},
};
export const ComparisonToSelect: StoryObj<PropsAndCustomArgs> = {
args: {
numberOfOptions: 100,
},
render: SelectComparisonStory,
};
export default meta;
function InDevDecorator(Story: React.ElementType) {

View File

@ -4,6 +4,12 @@ import { GrafanaTheme2 } from '@grafana/data';
const MAX_HEIGHT = 400;
// We need a px font size to accurately measure the width of items.
// This should be in sync with the body font size in the theme.
export const MENU_ITEM_FONT_SIZE = 14;
export const MENU_ITEM_FONT_WEIGHT = 500;
export const MENU_ITEM_PADDING_X = 8;
export const getComboboxStyles = (theme: GrafanaTheme2) => {
return {
menuClosed: css({
@ -24,7 +30,7 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
}),
option: css({
label: 'grafana-select-option',
padding: '8px',
padding: MENU_ITEM_PADDING_X,
position: 'absolute',
display: 'flex',
alignItems: 'center',
@ -53,6 +59,9 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
label: 'grafana-select-option-label',
textOverflow: 'ellipsis',
overflow: 'hidden',
fontSize: MENU_ITEM_FONT_SIZE,
fontWeight: MENU_ITEM_FONT_WEIGHT,
letterSpacing: 0, // pr todo: text in grafana has a slightly different letter spacing, which causes measureText() to be ~5% off
}),
optionDescription: css({
label: 'grafana-select-option-description',

View File

@ -1,12 +1,13 @@
import { autoUpdate, flip, size, useFloating } from '@floating-ui/react';
import { useEffect, useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { measureText } from '../../utils';
import { ComboboxOption } from './Combobox';
import { MENU_ITEM_FONT_SIZE, MENU_ITEM_FONT_WEIGHT, MENU_ITEM_PADDING_X } from './getComboboxStyles';
// On every 100th index we will recalculate the width of the popover.
const INDEX_WIDTH_CALCULATION = 100;
// A multiplier guesstimate times the amount of characters. If any padding or image support etc. is added this will need to be updated.
const WIDTH_MULTIPLIER = 7.3;
// Only consider the first n items when calculating the width of the popover.
const WIDTH_CALCULATION_LIMIT_ITEMS = 100_000;
/**
* Used with Downshift to get the height of each item
@ -22,9 +23,10 @@ export const useComboboxFloat = (
) => {
const inputRef = useRef<HTMLInputElement>(null);
const floatingRef = useRef<HTMLDivElement>(null);
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
const [popoverMaxWidth, setPopoverMaxWidth] = useState<number | undefined>(undefined);
const scrollbarWidth = useMemo(() => getScrollbarWidth(), []);
// the order of middleware is important!
const middleware = [
flip({
@ -48,49 +50,43 @@ export const useComboboxFloat = (
whileElementsMounted: autoUpdate,
});
useEffect(() => {
if (range === null) {
return;
}
const startVisibleIndex = range?.startIndex;
const endVisibleIndex = range?.endIndex;
const longestItemWidth = useMemo(() => {
let longestItem = '';
const itemsToLookAt = Math.min(items.length, WIDTH_CALCULATION_LIMIT_ITEMS);
if (typeof startVisibleIndex === 'undefined' || typeof endVisibleIndex === 'undefined') {
return;
for (let i = 0; i < itemsToLookAt; i++) {
const itemLabel = items[i].label;
longestItem = itemLabel.length > longestItem.length ? itemLabel : longestItem;
}
// Scroll down and default case
if (
startVisibleIndex === 0 ||
(startVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && startVisibleIndex >= INDEX_WIDTH_CALCULATION)
) {
let maxLength = 0;
const calculationEnd = Math.min(items.length, endVisibleIndex + INDEX_WIDTH_CALCULATION);
const size = measureText(longestItem, MENU_ITEM_FONT_SIZE, MENU_ITEM_FONT_WEIGHT).width;
for (let i = startVisibleIndex; i < calculationEnd; i++) {
maxLength = Math.max(maxLength, items[i].label.length);
}
setPopoverWidth(maxLength * WIDTH_MULTIPLIER);
} else if (endVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && endVisibleIndex >= INDEX_WIDTH_CALCULATION) {
// Scroll up case
let maxLength = 0;
const calculationStart = Math.max(0, startVisibleIndex - INDEX_WIDTH_CALCULATION);
for (let i = calculationStart; i < endVisibleIndex; i++) {
maxLength = Math.max(maxLength, items[i].label.length);
}
setPopoverWidth(maxLength * WIDTH_MULTIPLIER);
}
}, [items, range, setPopoverWidth]);
return size + MENU_ITEM_PADDING_X * 2 + scrollbarWidth;
}, [items, scrollbarWidth]);
const floatStyles = {
...floatingStyles,
width: popoverWidth,
width: longestItemWidth,
maxWidth: popoverMaxWidth,
minWidth: inputRef.current?.offsetWidth,
};
return { inputRef, floatingRef, floatStyles };
};
// Creates a temporary div with a scrolling inner div to calculate the width of the scrollbar
function getScrollbarWidth(): number {
const outer = document.createElement('div');
outer.style.visibility = 'hidden';
outer.style.overflow = 'scroll';
document.body.appendChild(outer);
const inner = document.createElement('div');
outer.appendChild(inner);
const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
outer.parentNode?.removeChild(outer);
return scrollbarWidth;
}