mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
MultiCombobox: Add grouping (#100297)
This commit is contained in:
parent
8e436fc473
commit
b44b82606a
@ -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<typeof MultiCombobox> = {
|
||||
@ -107,6 +107,40 @@ export const ManyOptions: StoryObj<ManyOptionsArgs> = {
|
||||
render: ManyOptionsStory,
|
||||
};
|
||||
|
||||
const ManyOptionsGroupedStory: StoryFn<ManyOptionsArgs> = ({ numberOfOptions = 1e5, ...args }) => {
|
||||
const [dynamicArgs, setArgs] = useArgs();
|
||||
|
||||
const [options, setOptions] = useState<ComboboxOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(async () => {
|
||||
const options = await generateGroupingOptions(numberOfOptions);
|
||||
setOptions(options);
|
||||
}, 1000);
|
||||
}, [numberOfOptions]);
|
||||
const { onChange, ...rest } = args;
|
||||
return (
|
||||
<MultiCombobox
|
||||
{...rest}
|
||||
{...dynamicArgs}
|
||||
options={options}
|
||||
onChange={(opts) => {
|
||||
setArgs({ value: opts });
|
||||
onChangeAction(opts);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ManyOptionsGrouped: StoryObj<ManyOptionsArgs> = {
|
||||
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}`);
|
||||
|
@ -246,8 +246,18 @@ export const MultiCombobox = <T extends string | number>(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 = <T extends string | number>(props: MultiComboboxPro
|
||||
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef}>
|
||||
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||
{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 = <T extends string | number>(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)` }}
|
||||
>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Checkbox
|
||||
key={id}
|
||||
value={allItemsSelected || isSelected}
|
||||
indeterminate={isAll && selectedItems.length > 0 && !allItemsSelected}
|
||||
aria-labelledby={id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<OptionListItem
|
||||
label={
|
||||
isAll
|
||||
? (item.label ?? item.value.toString()) +
|
||||
(isAll && inputValue !== '' ? ` (${options.length - 1})` : '')
|
||||
: (item.label ?? item.value.toString())
|
||||
}
|
||||
description={item?.description}
|
||||
id={id}
|
||||
/>
|
||||
<Stack direction="column" justifyContent="space-between" width={'100%'} height={'100%'} gap={0}>
|
||||
{startingNewGroup && (
|
||||
<div className={styles.optionGroup}>
|
||||
<OptionListItem
|
||||
label={item.group ?? t('combobox.group.undefined', 'No group')}
|
||||
id={id}
|
||||
isGroup={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cx(styles.option, {
|
||||
[styles.optionFocused]: highlightedIndex === index,
|
||||
})}
|
||||
>
|
||||
<Stack direction="row" alignItems="center">
|
||||
<Checkbox
|
||||
key={id}
|
||||
value={allItemsSelected || isSelected}
|
||||
indeterminate={isAll && selectedItems.length > 0 && !allItemsSelected}
|
||||
aria-labelledby={id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<OptionListItem
|
||||
label={
|
||||
isAll
|
||||
? (item.label ?? item.value.toString()) +
|
||||
(isAll && inputValue !== '' ? ` (${options.length - 1})` : '')
|
||||
: (item.label ?? item.value.toString())
|
||||
}
|
||||
description={item?.description}
|
||||
id={id}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</li>
|
||||
);
|
||||
@ -425,3 +453,17 @@ function isComboboxOptions<T extends string | number>(
|
||||
): value is Array<ComboboxOption<T>> {
|
||||
return typeof value[0] === 'object';
|
||||
}
|
||||
|
||||
const isNewGroup = <T extends string | number>(option: ComboboxOption<T>, prevOption?: ComboboxOption<T>) => {
|
||||
const currentGroup = option.group;
|
||||
|
||||
if (!currentGroup) {
|
||||
return prevOption?.group ? true : false;
|
||||
}
|
||||
|
||||
if (!prevOption) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return prevOption.group !== currentGroup;
|
||||
};
|
||||
|
@ -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 (
|
||||
<div className={styles.optionBody}>
|
||||
<span className={styles.optionLabel} id={id}>
|
||||
<div className={styles.optionBody} aria-disabled={isGroup}>
|
||||
<span className={cx(styles.optionLabel, { [styles.optionLabelGroup]: isGroup })} id={id}>
|
||||
{label}
|
||||
</span>
|
||||
{description && <span className={styles.optionDescription}>{description}</span>}
|
||||
|
@ -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,
|
||||
|
@ -37,3 +37,11 @@ export async function generateOptions(amount: number): Promise<ComboboxOption[]>
|
||||
value: index.toString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateGroupingOptions(amount: number): Promise<ComboboxOption[]> {
|
||||
return Array.from({ length: amount }, (_, index) => ({
|
||||
label: 'Option ' + index,
|
||||
value: index.toString(),
|
||||
group: index % 9 !== 0 ? 'Group ' + Math.floor(index / 10) : undefined,
|
||||
}));
|
||||
}
|
||||
|
@ -4,4 +4,5 @@ export type ComboboxOption<T extends string | number = string> = {
|
||||
label?: string;
|
||||
value: T;
|
||||
description?: string;
|
||||
group?: string;
|
||||
};
|
||||
|
@ -95,16 +95,39 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
[debouncedLoadOptions, isAsync]
|
||||
);
|
||||
|
||||
const finalOptions = useMemo(() => {
|
||||
let currentOptions = [];
|
||||
if (isAsync) {
|
||||
currentOptions = addCustomValue(asyncOptions);
|
||||
} else {
|
||||
currentOptions = addCustomValue(rawOptions.filter(itemFilter(userTypedSearch)));
|
||||
const organizeOptionsByGroup = useCallback((options: Array<ComboboxOption<T>>) => {
|
||||
const groupedOptions = new Map<string | undefined, Array<ComboboxOption<T>>>();
|
||||
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 };
|
||||
}
|
||||
|
@ -724,6 +724,9 @@
|
||||
"custom-value": {
|
||||
"description": "Use custom value"
|
||||
},
|
||||
"group": {
|
||||
"undefined": "No group"
|
||||
},
|
||||
"options": {
|
||||
"no-found": "No options found."
|
||||
}
|
||||
|
@ -724,6 +724,9 @@
|
||||
"custom-value": {
|
||||
"description": "Ůşę čūşŧőm väľūę"
|
||||
},
|
||||
"group": {
|
||||
"undefined": "Ńő ģřőūp"
|
||||
},
|
||||
"options": {
|
||||
"no-found": "Ńő őpŧįőʼnş ƒőūʼnđ."
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user