diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx index 7358119d986..2022354a901 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.internal.story.tsx @@ -6,7 +6,7 @@ import { ComponentProps } from 'react'; import { Field } from '../Forms/Field'; import { MultiCombobox } from './MultiCombobox'; -import { generateOptions, fakeSearchAPI } from './storyUtils'; +import { generateOptions, fakeSearchAPI, generateGroupingOptions } from './storyUtils'; import { ComboboxOption } from './types'; const meta: Meta = { @@ -107,6 +107,40 @@ export const ManyOptions: StoryObj = { render: ManyOptionsStory, }; +const ManyOptionsGroupedStory: StoryFn = ({ numberOfOptions = 1e5, ...args }) => { + const [dynamicArgs, setArgs] = useArgs(); + + const [options, setOptions] = useState([]); + + useEffect(() => { + setTimeout(async () => { + const options = await generateGroupingOptions(numberOfOptions); + setOptions(options); + }, 1000); + }, [numberOfOptions]); + const { onChange, ...rest } = args; + return ( + { + setArgs({ value: opts }); + onChangeAction(opts); + }} + /> + ); +}; + +export const ManyOptionsGrouped: StoryObj = { + args: { + numberOfOptions: 1e4, + options: undefined, + value: undefined, + }, + render: ManyOptionsGroupedStory, +}; + function loadOptionsWithLabels(inputValue: string) { loadOptionsAction(inputValue); return fakeSearchAPI(`http://example.com/search?errorOnQuery=break&query=${inputValue}`); diff --git a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx index 0fbfe84d36a..7b41ca2ee53 100644 --- a/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx @@ -246,8 +246,18 @@ export const MultiCombobox = (props: MultiComboboxPro const virtualizerOptions = { count: options.length, getScrollElement: () => scrollRef.current, - estimateSize: (index: number) => - 'description' in options[index] ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT, + estimateSize: (index: number) => { + const firstGroupItem = isNewGroup(options[index], index > 0 ? options[index - 1] : undefined); + const hasDescription = 'description' in options[index]; + let itemHeight = MENU_OPTION_HEIGHT; + if (hasDescription) { + itemHeight = MENU_OPTION_HEIGHT_DESCRIPTION; + } + if (firstGroupItem) { + itemHeight += MENU_OPTION_HEIGHT; + } + return itemHeight; + }, overscan: VIRTUAL_OVERSCAN_ITEMS, }; @@ -337,6 +347,7 @@ export const MultiCombobox = (props: MultiComboboxPro
    {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const startingNewGroup = isNewGroup(options[virtualRow.index], options[virtualRow.index - 1]); const index = virtualRow.index; const item = options[index]; const itemProps = getItemProps({ item, index }); @@ -354,29 +365,46 @@ export const MultiCombobox = (props: MultiComboboxPro key={`${item.value}-${index}`} data-index={index} {...itemProps} - className={cx(styles.option, { [styles.optionFocused]: highlightedIndex === index })} + className={styles.optionBasic} style={{ height: virtualRow.size, transform: `translateY(${virtualRow.start}px)` }} > - - 0 && !allItemsSelected} - aria-labelledby={id} - onClick={(e) => { - e.stopPropagation(); - }} - /> - + + {startingNewGroup && ( +
    + +
    + )} +
    + + 0 && !allItemsSelected} + aria-labelledby={id} + onClick={(e) => { + e.stopPropagation(); + }} + /> + + +
    ); @@ -425,3 +453,17 @@ function isComboboxOptions( ): value is Array> { return typeof value[0] === 'object'; } + +const isNewGroup = (option: ComboboxOption, prevOption?: ComboboxOption) => { + const currentGroup = option.group; + + if (!currentGroup) { + return prevOption?.group ? true : false; + } + + if (!prevOption) { + return true; + } + + return prevOption.group !== currentGroup; +}; diff --git a/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx b/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx index 436a662e204..3a929be51a9 100644 --- a/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx +++ b/packages/grafana-ui/src/components/Combobox/OptionListItem.tsx @@ -1,3 +1,5 @@ +import { cx } from '@emotion/css'; + import { useStyles2 } from '../../themes'; import { getComboboxStyles } from './getComboboxStyles'; @@ -6,13 +8,14 @@ interface Props { label: string; description?: string; id: string; + isGroup?: boolean; } -export const OptionListItem = ({ label, description, id }: Props) => { +export const OptionListItem = ({ label, description, id, isGroup = false }: Props) => { const styles = useStyles2(getComboboxStyles); return ( -
    - +
    + {label} {description && {description}} diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts index 5da03591348..f0ea95cacb2 100644 --- a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts @@ -32,9 +32,8 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { label: 'combobox-menu-ul-container', listStyle: 'none', }), - option: css({ + optionBasic: css({ label: 'combobox-option', - padding: MENU_ITEM_PADDING, position: 'absolute', display: 'flex', alignItems: 'center', @@ -43,7 +42,11 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { whiteSpace: 'nowrap', width: '100%', overflow: 'hidden', + }), + option: css({ + padding: MENU_ITEM_PADDING, cursor: 'pointer', + width: '100%', '&:hover': { background: theme.colors.action.hover, '@media (forced-colors: active), (prefers-contrast: more)': { @@ -51,6 +54,11 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { }, }, }), + optionGroup: css({ + cursor: 'default', + padding: MENU_ITEM_PADDING, + borderTop: `1px solid ${theme.colors.border.weak}`, + }), optionBody: css({ label: 'combobox-option-body', display: 'flex', @@ -67,6 +75,12 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { 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 }), + optionLabelGroup: css({ + label: 'combobox-option-label-group', + color: theme.colors.text.secondary, + fontSize: theme.typography.bodySmall.fontSize, + fontWeight: theme.typography.fontWeightLight, + }), optionDescription: css({ label: 'combobox-option-description', fontWeight: theme.typography.fontWeightRegular, diff --git a/packages/grafana-ui/src/components/Combobox/storyUtils.ts b/packages/grafana-ui/src/components/Combobox/storyUtils.ts index 48563324f54..d39040a6121 100644 --- a/packages/grafana-ui/src/components/Combobox/storyUtils.ts +++ b/packages/grafana-ui/src/components/Combobox/storyUtils.ts @@ -37,3 +37,11 @@ export async function generateOptions(amount: number): Promise value: index.toString(), })); } + +export async function generateGroupingOptions(amount: number): Promise { + return Array.from({ length: amount }, (_, index) => ({ + label: 'Option ' + index, + value: index.toString(), + group: index % 9 !== 0 ? 'Group ' + Math.floor(index / 10) : undefined, + })); +} diff --git a/packages/grafana-ui/src/components/Combobox/types.ts b/packages/grafana-ui/src/components/Combobox/types.ts index c942d7a6357..9bc5cde0204 100644 --- a/packages/grafana-ui/src/components/Combobox/types.ts +++ b/packages/grafana-ui/src/components/Combobox/types.ts @@ -4,4 +4,5 @@ export type ComboboxOption = { label?: string; value: T; description?: string; + group?: string; }; diff --git a/packages/grafana-ui/src/components/Combobox/useOptions.ts b/packages/grafana-ui/src/components/Combobox/useOptions.ts index b76339cf54e..fc2747cee1e 100644 --- a/packages/grafana-ui/src/components/Combobox/useOptions.ts +++ b/packages/grafana-ui/src/components/Combobox/useOptions.ts @@ -95,16 +95,39 @@ export function useOptions(rawOptions: AsyncOptions { - let currentOptions = []; - if (isAsync) { - currentOptions = addCustomValue(asyncOptions); - } else { - currentOptions = addCustomValue(rawOptions.filter(itemFilter(userTypedSearch))); + const organizeOptionsByGroup = useCallback((options: Array>) => { + const groupedOptions = new Map>>(); + for (const option of options) { + const groupExists = groupedOptions.has(option.group); + if (groupExists) { + groupedOptions.get(option.group)?.push(option); + } else { + groupedOptions.set(option.group, [option]); + } } - return currentOptions; - }, [isAsync, addCustomValue, asyncOptions, rawOptions, userTypedSearch]); + // Reorganize options to have groups first, then undefined group + const reorganizeOptions = []; + for (const [group, groupOptions] of groupedOptions) { + if (!group) { + continue; + } + reorganizeOptions.push(...groupOptions); + } + + const undefinedGroupOptions = groupedOptions.get(undefined); + if (undefinedGroupOptions) { + reorganizeOptions.push(...undefinedGroupOptions); + } + return reorganizeOptions; + }, []); + + const finalOptions = useMemo(() => { + const currentOptions = isAsync ? asyncOptions : rawOptions.filter(itemFilter(userTypedSearch)); + const currentOptionsOrganised = organizeOptionsByGroup(currentOptions); + + return addCustomValue(currentOptionsOrganised); + }, [isAsync, organizeOptionsByGroup, addCustomValue, asyncOptions, rawOptions, userTypedSearch]); return { options: finalOptions, updateOptions, asyncLoading, asyncError }; } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c739515378a..2c90bf29511 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -724,6 +724,9 @@ "custom-value": { "description": "Use custom value" }, + "group": { + "undefined": "No group" + }, "options": { "no-found": "No options found." } diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 4c73778c894..61b47f9f9a5 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -724,6 +724,9 @@ "custom-value": { "description": "Ůşę čūşŧőm väľūę" }, + "group": { + "undefined": "Ńő ģřőūp" + }, "options": { "no-found": "Ńő őpŧįőʼnş ƒőūʼnđ." }