mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
4d08f44667
commit
64bb94cc62
@ -3,8 +3,11 @@ import { Meta, StoryFn, StoryObj } from '@storybook/react';
|
|||||||
import { Chance } from 'chance';
|
import { Chance } from 'chance';
|
||||||
import React, { ComponentProps, useEffect, useState } from 'react';
|
import React, { ComponentProps, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useTheme2 } from '../../themes/ThemeContext';
|
||||||
import { Alert } from '../Alert/Alert';
|
import { Alert } from '../Alert/Alert';
|
||||||
|
import { Divider } from '../Divider/Divider';
|
||||||
import { Field } from '../Forms/Field';
|
import { Field } from '../Forms/Field';
|
||||||
|
import { Select } from '../Select/Select';
|
||||||
|
|
||||||
import { Combobox, ComboboxOption } from './Combobox';
|
import { Combobox, ComboboxOption } from './Combobox';
|
||||||
|
|
||||||
@ -73,7 +76,6 @@ async function generateOptions(amount: number): Promise<ComboboxOption[]> {
|
|||||||
return Array.from({ length: amount }, (_, index) => ({
|
return Array.from({ length: amount }, (_, index) => ({
|
||||||
label: chance.sentence({ words: index % 5 }),
|
label: chance.sentence({ words: index % 5 }),
|
||||||
value: chance.guid(),
|
value: chance.guid(),
|
||||||
//description: chance.sentence(),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,7 +90,6 @@ const ManyOptionsStory: StoryFn<PropsAndCustomArgs> = ({ numberOfOptions, ...arg
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setOptions(options);
|
setOptions(options);
|
||||||
setValue(options[5].value);
|
setValue(options[5].value);
|
||||||
console.log("I've set stuff");
|
|
||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}, [numberOfOptions]);
|
}, [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> = {
|
export const AutoSize: StoryObj<PropsAndCustomArgs> = {
|
||||||
args: {
|
args: {
|
||||||
width: 'auto',
|
width: 'auto',
|
||||||
@ -130,6 +248,13 @@ export const CustomValue: StoryObj<PropsAndCustomArgs> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ComparisonToSelect: StoryObj<PropsAndCustomArgs> = {
|
||||||
|
args: {
|
||||||
|
numberOfOptions: 100,
|
||||||
|
},
|
||||||
|
render: SelectComparisonStory,
|
||||||
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
|
||||||
function InDevDecorator(Story: React.ElementType) {
|
function InDevDecorator(Story: React.ElementType) {
|
||||||
|
@ -4,6 +4,12 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
|
|
||||||
const MAX_HEIGHT = 400;
|
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) => {
|
export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||||
return {
|
return {
|
||||||
menuClosed: css({
|
menuClosed: css({
|
||||||
@ -24,7 +30,7 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
|||||||
}),
|
}),
|
||||||
option: css({
|
option: css({
|
||||||
label: 'grafana-select-option',
|
label: 'grafana-select-option',
|
||||||
padding: '8px',
|
padding: MENU_ITEM_PADDING_X,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -53,6 +59,9 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
|||||||
label: 'grafana-select-option-label',
|
label: 'grafana-select-option-label',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
overflow: 'hidden',
|
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({
|
optionDescription: css({
|
||||||
label: 'grafana-select-option-description',
|
label: 'grafana-select-option-description',
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { autoUpdate, flip, size, useFloating } from '@floating-ui/react';
|
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 { 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.
|
// Only consider the first n items when calculating the width of the popover.
|
||||||
const INDEX_WIDTH_CALCULATION = 100;
|
const WIDTH_CALCULATION_LIMIT_ITEMS = 100_000;
|
||||||
// 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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used with Downshift to get the height of each item
|
* Used with Downshift to get the height of each item
|
||||||
@ -22,9 +23,10 @@ export const useComboboxFloat = (
|
|||||||
) => {
|
) => {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const floatingRef = useRef<HTMLDivElement>(null);
|
const floatingRef = useRef<HTMLDivElement>(null);
|
||||||
const [popoverWidth, setPopoverWidth] = useState<number | undefined>(undefined);
|
|
||||||
const [popoverMaxWidth, setPopoverMaxWidth] = useState<number | undefined>(undefined);
|
const [popoverMaxWidth, setPopoverMaxWidth] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const scrollbarWidth = useMemo(() => getScrollbarWidth(), []);
|
||||||
|
|
||||||
// the order of middleware is important!
|
// the order of middleware is important!
|
||||||
const middleware = [
|
const middleware = [
|
||||||
flip({
|
flip({
|
||||||
@ -48,49 +50,43 @@ export const useComboboxFloat = (
|
|||||||
whileElementsMounted: autoUpdate,
|
whileElementsMounted: autoUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const longestItemWidth = useMemo(() => {
|
||||||
if (range === null) {
|
let longestItem = '';
|
||||||
return;
|
const itemsToLookAt = Math.min(items.length, WIDTH_CALCULATION_LIMIT_ITEMS);
|
||||||
}
|
|
||||||
const startVisibleIndex = range?.startIndex;
|
|
||||||
const endVisibleIndex = range?.endIndex;
|
|
||||||
|
|
||||||
if (typeof startVisibleIndex === 'undefined' || typeof endVisibleIndex === 'undefined') {
|
for (let i = 0; i < itemsToLookAt; i++) {
|
||||||
return;
|
const itemLabel = items[i].label;
|
||||||
|
longestItem = itemLabel.length > longestItem.length ? itemLabel : longestItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll down and default case
|
const size = measureText(longestItem, MENU_ITEM_FONT_SIZE, MENU_ITEM_FONT_WEIGHT).width;
|
||||||
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);
|
|
||||||
|
|
||||||
for (let i = startVisibleIndex; i < calculationEnd; i++) {
|
return size + MENU_ITEM_PADDING_X * 2 + scrollbarWidth;
|
||||||
maxLength = Math.max(maxLength, items[i].label.length);
|
}, [items, scrollbarWidth]);
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const floatStyles = {
|
const floatStyles = {
|
||||||
...floatingStyles,
|
...floatingStyles,
|
||||||
width: popoverWidth,
|
width: longestItemWidth,
|
||||||
maxWidth: popoverMaxWidth,
|
maxWidth: popoverMaxWidth,
|
||||||
minWidth: inputRef.current?.offsetWidth,
|
minWidth: inputRef.current?.offsetWidth,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { inputRef, floatingRef, floatStyles };
|
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;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user