Compare commits

...

1 Commits

9 changed files with 151 additions and 61 deletions

View File

@@ -1,12 +1,13 @@
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { useArgs } from '@storybook/preview-api'; import { useArgs } from '@storybook/preview-api';
import { Meta, StoryFn, StoryObj } from '@storybook/react'; import { Meta, StoryFn, StoryObj } from '@storybook/react';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Field } from '../Forms/Field'; import { Field } from '../Forms/Field';
import { Combobox, ComboboxProps } from './Combobox'; import { Combobox, ComboboxProps } from './Combobox';
import mdx from './Combobox.mdx'; import mdx from './Combobox.mdx';
import { MENU_ITEM_FONT_SIZE, MENU_ITEM_LINE_HEIGHT, MENU_OPTION_HEIGHT } from './getComboboxStyles';
import { fakeSearchAPI, generateGroupingOptions, generateOptions } from './storyUtils'; import { fakeSearchAPI, generateGroupingOptions, generateOptions } from './storyUtils';
import { ComboboxOption } from './types'; import { ComboboxOption } from './types';
@@ -171,6 +172,65 @@ export const ManyOptions: Story = {
}, },
}; };
export const NodeOptions: Story = {
args: {
numberOfOptions: 350,
options: undefined,
value: undefined,
},
parameters: {
docs: {
source: {
// necessary to keep storybook from choking on the option generation code
type: 'code',
},
},
},
render: ({ numberOfOptions, ...args }: PropsAndCustomArgs) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [dynamicArgs, setArgs] = useArgs();
// eslint-disable-next-line react-hooks/rules-of-hooks
const options: ComboboxOption[] = useMemo(() => {
return Array.from({ length: numberOfOptions }, (_, index) => {
const label =
index % 2 === 0
? {
node: (
<>
{`Option ${index}`}
<br />
(Even)
</>
),
text: `Option ${index}`,
size: MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT + MENU_OPTION_HEIGHT,
}
: `Option ${index}`;
return {
label,
value: String(index),
};
});
}, [numberOfOptions]);
const { onChange, ...rest } = args;
return (
<Field label="Test input" description="Input with some custom rendered options">
<Combobox
{...rest}
{...dynamicArgs}
options={options}
onChange={(value: ComboboxOption | null) => {
setArgs({ value: value?.value || null });
onChangeAction(value);
}}
/>
</Field>
);
},
};
function loadOptionsWithLabels(inputValue: string) { function loadOptionsWithLabels(inputValue: string) {
loadOptionsAction(inputValue); loadOptionsAction(inputValue);
return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`); return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`);

View File

@@ -1,7 +1,7 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { useVirtualizer, type Range } from '@tanstack/react-virtual';
import { useCombobox } from 'downshift'; import { useCombobox } from 'downshift';
import React, { ComponentProps, useCallback, useId, useMemo } from 'react'; import React, { ComponentProps, useId, useMemo } from 'react';
import { Subject } from 'rxjs';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
@@ -14,11 +14,10 @@ import { Portal } from '../Portal/Portal';
import { ComboboxList } from './ComboboxList'; import { ComboboxList } from './ComboboxList';
import { SuffixIcon } from './SuffixIcon'; import { SuffixIcon } from './SuffixIcon';
import { itemToString } from './filter'; import { itemToString } from './filter';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles'; import { getComboboxStyles } from './getComboboxStyles';
import { ComboboxOption } from './types'; import { ComboboxOption } from './types';
import { useComboboxFloat } from './useComboboxFloat'; import { useComboboxFloat } from './useComboboxFloat';
import { useOptions } from './useOptions'; import { useOptions } from './useOptions';
import { isNewGroup } from './utils';
// TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async), // TODO: It would be great if ComboboxOption["label"] was more generic so that if consumers do pass it in (for async),
// then the onChange handler emits ComboboxOption with the label as non-undefined. // then the onChange handler emits ComboboxOption with the label as non-undefined.
@@ -154,7 +153,6 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
const { const {
options: filteredOptions, options: filteredOptions,
groupStartIndices,
updateOptions, updateOptions,
asyncLoading, asyncLoading,
asyncError, asyncError,
@@ -195,49 +193,34 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
const styles = useStyles2(getComboboxStyles); const styles = useStyles2(getComboboxStyles);
// Maybe need to move this to ComboboxList? Is this actually necessary? it's only expanding the returned
// array of indexes, and that's already expanded by the overscan setting
// Injects the group header for the first rendered item into the range to render. // Injects the group header for the first rendered item into the range to render.
// Accepts the range that useVirtualizer wants to render, and then returns indexes // Accepts the range that useVirtualizer wants to render, and then returns indexes
// to actually render. // to actually render.
const rangeExtractor = useCallback( // const rangeExtractor = useCallback(
(range: Range) => { // (range: Range) => {
const startIndex = Math.max(0, range.startIndex - range.overscan); // const startIndex = Math.max(0, range.startIndex - range.overscan);
const endIndex = Math.min(filteredOptions.length - 1, range.endIndex + range.overscan); // const endIndex = Math.min(filteredOptions.length - 1, range.endIndex + range.overscan);
const rangeToReturn = Array.from({ length: endIndex - startIndex + 1 }, (_, i) => startIndex + i); // const rangeToReturn = Array.from({ length: endIndex - startIndex + 1 }, (_, i) => startIndex + i);
// If the first item doesn't have a group, no need to find a header for it // // If the first item doesn't have a group, no need to find a header for it
const firstDisplayedOption = filteredOptions[rangeToReturn[0]]; // const firstDisplayedOption = filteredOptions[rangeToReturn[0]];
if (firstDisplayedOption?.group) { // if (firstDisplayedOption?.group) {
const groupStartIndex = groupStartIndices.get(firstDisplayedOption.group); // const groupStartIndex = groupStartIndices.get(firstDisplayedOption.group);
if (groupStartIndex !== undefined && groupStartIndex < rangeToReturn[0]) { // if (groupStartIndex !== undefined && groupStartIndex < rangeToReturn[0]) {
rangeToReturn.unshift(groupStartIndex); // rangeToReturn.unshift(groupStartIndex);
} // }
} // }
return rangeToReturn; // return rangeToReturn;
}, // },
[filteredOptions, groupStartIndices] // [filteredOptions, groupStartIndices]
); // );
const rowVirtualizer = useVirtualizer({ const scrollToIndexObservable = useMemo(() => {
count: filteredOptions.length, return new Subject<number>();
getScrollElement: () => scrollRef.current, }, []);
estimateSize: (index: number) => {
const firstGroupItem = isNewGroup(filteredOptions[index], index > 0 ? filteredOptions[index - 1] : undefined);
const hasDescription = 'description' in filteredOptions[index];
const hasGroup = 'group' in filteredOptions[index];
let itemHeight = MENU_OPTION_HEIGHT;
if (hasDescription) {
itemHeight = MENU_OPTION_HEIGHT_DESCRIPTION;
}
if (firstGroupItem && hasGroup) {
itemHeight += MENU_OPTION_HEIGHT;
}
return itemHeight;
},
overscan: VIRTUAL_OVERSCAN_ITEMS,
rangeExtractor,
});
const { const {
isOpen, isOpen,
@@ -290,7 +273,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
onHighlightedIndexChange: ({ highlightedIndex, type }) => { onHighlightedIndexChange: ({ highlightedIndex, type }) => {
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) { if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
rowVirtualizer.scrollToIndex(highlightedIndex); scrollToIndexObservable.next(highlightedIndex);
} }
}, },
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => { onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
@@ -418,6 +401,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
scrollRef={scrollRef} scrollRef={scrollRef}
getItemProps={getItemProps} getItemProps={getItemProps}
error={asyncError} error={asyncError}
scrollToIndexObservable={scrollToIndexObservable}
/> />
)} )}
</div> </div>

View File

@@ -1,14 +1,15 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import type { UseComboboxPropGetters } from 'downshift'; import type { UseComboboxPropGetters } from 'downshift';
import { useCallback } from 'react'; import { useCallback, useEffect } from 'react';
import { Subject } from 'rxjs';
import { useStyles2 } from '../../themes/ThemeContext'; import { useStyles2 } from '../../themes/ThemeContext';
import { Checkbox } from '../Forms/Checkbox'; import { Checkbox } from '../Forms/Checkbox';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { AsyncError, LoadingOptions, NotFoundError } from './MessageRows'; import { AsyncError, LoadingOptions, NotFoundError } from './MessageRows';
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles'; import { DESCRIPTION_HEIGHT, getComboboxStyles, MENU_OPTION_HEIGHT } from './getComboboxStyles';
import { ALL_OPTION_VALUE, ComboboxOption } from './types'; import { ALL_OPTION_VALUE, ComboboxOption } from './types';
import { isNewGroup } from './utils'; import { isNewGroup } from './utils';
@@ -24,6 +25,7 @@ interface ComboboxListProps<T extends string | number> {
isMultiSelect?: boolean; isMultiSelect?: boolean;
error?: boolean; error?: boolean;
loading?: boolean; loading?: boolean;
scrollToIndexObservable?: Subject<number>;
} }
export const ComboboxList = <T extends string | number>({ export const ComboboxList = <T extends string | number>({
@@ -36,6 +38,7 @@ export const ComboboxList = <T extends string | number>({
isMultiSelect = false, isMultiSelect = false,
error = false, error = false,
loading = false, loading = false,
scrollToIndexObservable,
}: ComboboxListProps<T>) => { }: ComboboxListProps<T>) => {
const styles = useStyles2(getComboboxStyles); const styles = useStyles2(getComboboxStyles);
@@ -46,8 +49,11 @@ export const ComboboxList = <T extends string | number>({
const hasGroup = 'group' in options[index]; const hasGroup = 'group' in options[index];
let itemHeight = MENU_OPTION_HEIGHT; let itemHeight = MENU_OPTION_HEIGHT;
if (typeof options[index].label === 'object') {
itemHeight = options[index].label.size;
}
if (hasDescription) { if (hasDescription) {
itemHeight = MENU_OPTION_HEIGHT_DESCRIPTION; itemHeight += DESCRIPTION_HEIGHT;
} }
if (firstGroupItem && hasGroup) { if (firstGroupItem && hasGroup) {
itemHeight += MENU_OPTION_HEIGHT; itemHeight += MENU_OPTION_HEIGHT;
@@ -64,6 +70,19 @@ export const ComboboxList = <T extends string | number>({
overscan: VIRTUAL_OVERSCAN_ITEMS, overscan: VIRTUAL_OVERSCAN_ITEMS,
}); });
useEffect(() => {
if (scrollToIndexObservable) {
const subscription = scrollToIndexObservable.subscribe((index) => {
rowVirtualizer.scrollToIndex(index);
});
return () => {
subscription.unsubscribe();
};
}
return undefined;
}, [scrollToIndexObservable, rowVirtualizer]);
const isOptionSelected = useCallback( const isOptionSelected = useCallback(
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value), (item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
[selectedItems] [selectedItems]
@@ -90,6 +109,8 @@ export const ComboboxList = <T extends string | number>({
// the option for aria-describedby. // the option for aria-describedby.
const groupHeaderId = groupHeaderItem ? `combobox-option-group-${groupHeaderItem.value}` : undefined; const groupHeaderId = groupHeaderItem ? `combobox-option-group-${groupHeaderItem.value}` : undefined;
const label = typeof item.label === 'object' ? item.label.node : (item.label ?? item.value);
return ( return (
// Wrapping div should have no styling other than virtual list positioning. // Wrapping div should have no styling other than virtual list positioning.
// It's children (header and option) should appear as flat list items. // It's children (header and option) should appear as flat list items.
@@ -151,7 +172,7 @@ export const ComboboxList = <T extends string | number>({
)} )}
<div className={styles.optionBody}> <div className={styles.optionBody}>
<div className={styles.optionLabel}>{item.label ?? item.value}</div> <div className={styles.optionLabel}>{label}</div>
{item.description && <div className={styles.optionDescription}>{item.description}</div>} {item.description && <div className={styles.optionDescription}>{item.description}</div>}
</div> </div>

View File

@@ -6,6 +6,11 @@ export function itemToString<T extends string | number>(item?: ComboboxOption<T>
if (item == null) { if (item == null) {
return ''; return '';
} }
if (typeof item.label === 'object') {
return item.label.text;
}
return item.label ?? item.value.toString(); return item.label ?? item.value.toString();
} }

View File

@@ -13,8 +13,8 @@ export const MENU_ITEM_LINE_HEIGHT = 1.5;
// Used with Downshift to get the height of each item // Used with Downshift to get the height of each item
export const MENU_OPTION_HEIGHT = MENU_ITEM_GAP + MENU_ITEM_PADDING * 2 + MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT; export const MENU_OPTION_HEIGHT = MENU_ITEM_GAP + MENU_ITEM_PADDING * 2 + MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
export const MENU_OPTION_HEIGHT_DESCRIPTION = export const DESCRIPTION_HEIGHT = MENU_ITEM_DESCRIPTION_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
MENU_OPTION_HEIGHT + MENU_ITEM_DESCRIPTION_FONT_SIZE * MENU_ITEM_LINE_HEIGHT; export const MENU_OPTION_HEIGHT_DESCRIPTION = MENU_OPTION_HEIGHT + DESCRIPTION_HEIGHT;
export const POPOVER_MAX_HEIGHT = MENU_OPTION_HEIGHT * 8.5; export const POPOVER_MAX_HEIGHT = MENU_OPTION_HEIGHT * 8.5;
export const getComboboxStyles = (theme: GrafanaTheme2) => { export const getComboboxStyles = (theme: GrafanaTheme2) => {

View File

@@ -1,7 +1,7 @@
import { ComboboxOption } from './types'; import { ComboboxStringOption } from './types';
let fakeApiOptions: Array<ComboboxOption<string>>; let fakeApiOptions: Array<ComboboxStringOption<string>>;
export async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxOption<string>>> { export async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxStringOption<string>>> {
const searchParams = new URL(urlString).searchParams; const searchParams = new URL(urlString).searchParams;
const errorOnQuery = searchParams.get('errorOnQuery')?.toLowerCase(); const errorOnQuery = searchParams.get('errorOnQuery')?.toLowerCase();
@@ -26,19 +26,19 @@ export async function fakeSearchAPI(urlString: string): Promise<Array<ComboboxOp
const delay = searchQuery.length % 2 === 0 ? 200 : 1000; const delay = searchQuery.length % 2 === 0 ? 200 : 1000;
return new Promise<Array<ComboboxOption<string>>>((resolve) => { return new Promise<Array<ComboboxStringOption<string>>>((resolve) => {
setTimeout(() => resolve(filteredOptions), delay); setTimeout(() => resolve(filteredOptions), delay);
}); });
} }
export async function generateOptions(amount: number): Promise<ComboboxOption[]> { export async function generateOptions(amount: number): Promise<ComboboxStringOption[]> {
return Array.from({ length: amount }, (_, index) => ({ return Array.from({ length: amount }, (_, index) => ({
label: 'Option ' + index, label: 'Option ' + index,
value: index.toString(), value: index.toString(),
})); }));
} }
export async function generateGroupingOptions(amount: number): Promise<ComboboxOption[]> { export async function generateGroupingOptions(amount: number): Promise<ComboboxStringOption[]> {
return Array.from({ length: amount }, (_, index) => ({ return Array.from({ length: amount }, (_, index) => ({
label: 'Option ' + index, label: 'Option ' + index,
value: index.toString(), value: index.toString(),

View File

@@ -1,9 +1,28 @@
import { ReactNode } from 'react';
export const ALL_OPTION_VALUE = '__GRAFANA_INTERNAL_MULTICOMBOBOX_ALL_OPTION__'; export const ALL_OPTION_VALUE = '__GRAFANA_INTERNAL_MULTICOMBOBOX_ALL_OPTION__';
interface NodeOption {
node: ReactNode;
/**
* text property much exactly match the text in the `node` ReactNode property
*/
text: string;
size: number;
}
export type ComboboxOption<T extends string | number = string> = { export type ComboboxOption<T extends string | number = string> = {
label?: string; label?: string | NodeOption;
value: T; value: T;
description?: string; description?: string;
group?: string; group?: string;
infoOption?: boolean; infoOption?: boolean;
}; };
export interface ComboboxStringOption<T extends string | number = string> {
label?: string;
value: T;
description?: string;
group?: string;
infoOption?: boolean;
}

View File

@@ -67,7 +67,8 @@ export const useComboboxFloat = (items: Array<ComboboxOption<string | number>>,
const itemsToLookAt = Math.min(items.length, WIDTH_CALCULATION_LIMIT_ITEMS); const itemsToLookAt = Math.min(items.length, WIDTH_CALCULATION_LIMIT_ITEMS);
for (let i = 0; i < itemsToLookAt; i++) { for (let i = 0; i < itemsToLookAt; i++) {
const itemLabel = items[i].label ?? items[i].value.toString(); const label = items[i].label;
const itemLabel = (typeof label === 'object' ? label.text : label) ?? items[i].value.toString();
longestItem = itemLabel.length > longestItem.length ? itemLabel : longestItem; longestItem = itemLabel.length > longestItem.length ? itemLabel : longestItem;
} }

View File

@@ -30,9 +30,9 @@ export function useMeasureMulti<T extends string | number>(
let currWidth = 0; let currWidth = 0;
for (let i = 0; i < selectedItems.length; i++) { for (let i = 0; i < selectedItems.length; i++) {
// Measure text width and add size of padding, separator and close button // Measure text width and add size of padding, separator and close button
currWidth += const item = selectedItems[i];
measureText(selectedItems[i].label || '', FONT_SIZE).width + const text = typeof item.label === 'object' ? item.label.text : item.label || '';
(disabled ? EXTRA_PILL_DISABLED_SIZE : EXTRA_PILL_SIZE); currWidth += measureText(text, FONT_SIZE).width + (disabled ? EXTRA_PILL_DISABLED_SIZE : EXTRA_PILL_SIZE);
if (currWidth > maxWidth) { if (currWidth > maxWidth) {
// If there is no space for that item, show the current number of items, // If there is no space for that item, show the current number of items,
// but always show at least 1 item. Cap at maximum number of items. // but always show at least 1 item. Cap at maximum number of items.