Combobox: add grouping functionality (#100603)
* Use useOptions in Combobox * Clean code * Betterer results * Add grouping to Combobox * Fix code after mergin main * Manage ids * wip - first pass at improved dom structure for a11y * improve styling, remove old implementation * more style!!!! * more tidy up * deprecated comment * another comment! * tests * remember the index of each group --------- Co-authored-by: joshhunt <josh.hunt@grafana.com>
This commit is contained in:
co-authored by
joshhunt
parent
da53b3fb5e
commit
38151b1ae4
@@ -7,7 +7,7 @@ import { Field } from '../Forms/Field';
|
||||
|
||||
import { Combobox, ComboboxProps } from './Combobox';
|
||||
import mdx from './Combobox.mdx';
|
||||
import { fakeSearchAPI, generateOptions } from './storyUtils';
|
||||
import { fakeSearchAPI, generateGroupingOptions, generateOptions } from './storyUtils';
|
||||
import { ComboboxOption } from './types';
|
||||
|
||||
type PropsAndCustomArgs<T extends string | number = string> = ComboboxProps<T> & {
|
||||
@@ -107,6 +107,14 @@ export const CustomValue: Story = {
|
||||
render: BaseCombobox,
|
||||
};
|
||||
|
||||
export const Groups: Story = {
|
||||
args: {
|
||||
options: await generateGroupingOptions(500),
|
||||
value: '34',
|
||||
},
|
||||
render: BaseCombobox,
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
numberOfOptions: 1e5,
|
||||
|
||||
@@ -151,6 +151,49 @@ describe('Combobox', () => {
|
||||
expect(screen.getByRole('option', { name: 'Default' })).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
describe('groups', () => {
|
||||
it('renders group headers', async () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', value: '1', group: 'Group 1' },
|
||||
{ label: 'Option 2', value: '2', group: 'Group 1' },
|
||||
{ label: 'Option 3', value: '3', group: 'Group 2' },
|
||||
{ label: 'Option 4', value: '4', group: 'Group 2' },
|
||||
];
|
||||
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
|
||||
expect(screen.getByText('Group 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Group 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sorts options within groups', async () => {
|
||||
const options = [
|
||||
{ label: 'Option 1', value: '1', group: 'Group 1' },
|
||||
{ label: 'Option 2', value: '2', group: 'Group 2' },
|
||||
{ label: 'Option 3', value: '3', group: 'Group 1' },
|
||||
{ label: 'Option 4', value: '4', group: 'Group 2' },
|
||||
{ label: 'Option 5', value: '5', group: 'Group 2' },
|
||||
{ label: 'Option 6', value: '6', group: 'Group 1' },
|
||||
];
|
||||
|
||||
render(<Combobox options={options} value={null} onChange={onChangeHandler} />);
|
||||
|
||||
const input = screen.getByRole('combobox');
|
||||
await userEvent.click(input);
|
||||
|
||||
const allHeaders = await screen.findAllByRole('presentation');
|
||||
expect(allHeaders).toHaveLength(2);
|
||||
|
||||
const listbox = await screen.findByRole('listbox');
|
||||
expect(listbox).toHaveTextContent(
|
||||
['Group 1', 'Option 1', 'Option 3', 'Option 6', 'Group 2', 'Option 2', 'Option 4', 'Option 5'].join('')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('size support', () => {
|
||||
it('should require minWidth to be set with auto width', () => {
|
||||
// @ts-expect-error
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useVirtualizer, type Range } from '@tanstack/react-virtual';
|
||||
import { useCombobox } from 'downshift';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { useCallback, useId, useMemo } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { t } from '../../utils/i18n';
|
||||
@@ -17,6 +17,7 @@ import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION }
|
||||
import { ComboboxOption } from './types';
|
||||
import { useComboboxFloat } from './useComboboxFloat';
|
||||
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),
|
||||
// then the onChange handler emits ComboboxOption with the label as non-undefined.
|
||||
@@ -129,9 +130,11 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
// Value can be an actual scalar Value (string or number), or an Option (value + label), so
|
||||
// get a consistent Value from it
|
||||
const value = typeof valueProp === 'object' ? valueProp?.value : valueProp;
|
||||
const baseId = useId().replace(/:/g, '--');
|
||||
|
||||
const {
|
||||
options: filteredOptions,
|
||||
groupStartIndices,
|
||||
updateOptions,
|
||||
asyncLoading,
|
||||
asyncError,
|
||||
@@ -167,20 +170,52 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
return typeof valueProp === 'object' ? valueProp : { value: valueProp, label: valueProp.toString() };
|
||||
}, [selectedItemIndex, isAsync, valueProp, allOptions]);
|
||||
|
||||
const menuId = `downshift-${useId().replace(/:/g, '--')}-menu`;
|
||||
const labelId = `downshift-${useId().replace(/:/g, '--')}-label`;
|
||||
const menuId = `${baseId}-downshift-menu`;
|
||||
const labelId = `${baseId}-downshift-label`;
|
||||
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
|
||||
const virtualizerOptions = {
|
||||
// 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
|
||||
// to actually render.
|
||||
const rangeExtractor = useCallback(
|
||||
(range: Range) => {
|
||||
const startIndex = Math.max(0, range.startIndex - range.overscan);
|
||||
const endIndex = Math.min(filteredOptions.length - 1, range.endIndex + range.overscan);
|
||||
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
|
||||
const firstDisplayedOption = filteredOptions[rangeToReturn[0]];
|
||||
if (firstDisplayedOption?.group) {
|
||||
const groupStartIndex = groupStartIndices.get(firstDisplayedOption.group);
|
||||
if (groupStartIndex !== undefined && groupStartIndex < rangeToReturn[0]) {
|
||||
rangeToReturn.unshift(groupStartIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return rangeToReturn;
|
||||
},
|
||||
[filteredOptions, groupStartIndices]
|
||||
);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: filteredOptions.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: (index: number) =>
|
||||
filteredOptions[index].description ? MENU_OPTION_HEIGHT_DESCRIPTION : MENU_OPTION_HEIGHT,
|
||||
estimateSize: (index: number) => {
|
||||
const firstGroupItem = isNewGroup(filteredOptions[index], index > 0 ? filteredOptions[index - 1] : undefined);
|
||||
const hasDescription = 'description' in filteredOptions[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,
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer(virtualizerOptions);
|
||||
rangeExtractor,
|
||||
});
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -271,8 +306,8 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
const { inputRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(filteredOptions, isOpen);
|
||||
|
||||
const isAutoSize = width === 'auto';
|
||||
|
||||
const InputComponent = isAutoSize ? AutoSizeInput : Input;
|
||||
const placeholder = (isOpen ? itemToString(selectedItem) : null) || placeholderProp;
|
||||
|
||||
const suffixIcon = asyncLoading
|
||||
? 'spinner'
|
||||
@@ -281,7 +316,29 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
? 'search'
|
||||
: 'angle-down';
|
||||
|
||||
const placeholder = (isOpen ? itemToString(selectedItem) : null) || placeholderProp;
|
||||
const inputSuffix = (
|
||||
<>
|
||||
{value && value === selectedItem?.value && isClearable && (
|
||||
<Icon
|
||||
name="times"
|
||||
className={styles.clear}
|
||||
title={t('combobox.clear.title', 'Clear value')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => {
|
||||
selectItem(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
selectItem(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Icon name={suffixIcon} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={isAutoSize ? styles.addaptToParent : undefined}>
|
||||
@@ -294,36 +351,10 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
loading={loading}
|
||||
invalid={invalid}
|
||||
className={styles.input}
|
||||
suffix={
|
||||
<>
|
||||
{!!value && value === selectedItem?.value && isClearable && (
|
||||
<Icon
|
||||
name="times"
|
||||
className={styles.clear}
|
||||
title={t('combobox.clear.title', 'Clear value')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => {
|
||||
selectItem(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
selectItem(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Icon name={suffixIcon} />
|
||||
</>
|
||||
}
|
||||
suffix={inputSuffix}
|
||||
{...getInputProps({
|
||||
ref: inputRef,
|
||||
/* Empty onCall to avoid TS error
|
||||
* See issue here: https://github.com/downshift-js/downshift/issues/718
|
||||
* Downshift repo: https://github.com/downshift-js/downshift/tree/master
|
||||
*/
|
||||
onChange: noop,
|
||||
onChange: noop, // Empty onCall to avoid TS error https://github.com/downshift-js/downshift/issues/718
|
||||
'aria-labelledby': ariaLabelledBy, // Label should be handled with the Field component
|
||||
placeholder,
|
||||
})}
|
||||
@@ -331,9 +362,7 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
<Portal>
|
||||
<div
|
||||
className={cx(styles.menu, !isOpen && styles.menuClosed)}
|
||||
style={{
|
||||
...floatStyles,
|
||||
}}
|
||||
style={floatStyles}
|
||||
{...getMenuProps({
|
||||
ref: floatingRef,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
@@ -342,38 +371,67 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
{isOpen && (
|
||||
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef} padding={0.5}>
|
||||
{!asyncError && (
|
||||
<ul style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
<div style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index, allVirtualRows) => {
|
||||
const item = filteredOptions[virtualRow.index];
|
||||
const startingNewGroup = isNewGroup(item, filteredOptions[virtualRow.index - 1]);
|
||||
|
||||
// Find the item that renders the group header. It can be this same item if this is rendering it.
|
||||
const groupHeaderIndex = allVirtualRows.find((row) => {
|
||||
const rowItem = filteredOptions[row.index];
|
||||
return rowItem.group === item.group;
|
||||
});
|
||||
const groupHeaderItem = groupHeaderIndex && filteredOptions[groupHeaderIndex.index];
|
||||
|
||||
const itemId = `${baseId}-option-${item.value}`;
|
||||
// If we're rendering the group header, this is the ID for it. Otherwise its used on
|
||||
// the option for aria-describedby.
|
||||
const groupHeaderId = groupHeaderItem
|
||||
? `${baseId}-option-group-${groupHeaderItem.value}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${item.value}-${virtualRow.index}`}
|
||||
data-index={virtualRow.index}
|
||||
className={cx(
|
||||
styles.optionBasic,
|
||||
styles.option,
|
||||
selectedItem && item.value === selectedItem.value && styles.optionSelected,
|
||||
highlightedIndex === virtualRow.index && styles.optionFocused
|
||||
)}
|
||||
// Wrapping div should have no styling other than virtual list positioning.
|
||||
// It's children (header and option) should appear as flat list items.
|
||||
<div
|
||||
key={item.value}
|
||||
className={styles.listItem}
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
{...getItemProps({
|
||||
item: item,
|
||||
index: virtualRow.index,
|
||||
})}
|
||||
>
|
||||
<div className={styles.optionBody}>
|
||||
<span className={styles.optionLabel}>{item.label ?? item.value}</span>
|
||||
{item.description && <span className={styles.optionDescription}>{item.description}</span>}
|
||||
{startingNewGroup && (
|
||||
<div role="presentation" id={groupHeaderId} className={styles.newOptionGroup}>
|
||||
{item.group}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
styles.option,
|
||||
styles.optionBasic,
|
||||
selectedItem && item.value === selectedItem.value && styles.optionSelected,
|
||||
highlightedIndex === virtualRow.index && styles.optionFocused
|
||||
)}
|
||||
{...getItemProps({
|
||||
item: item,
|
||||
index: virtualRow.index,
|
||||
id: itemId,
|
||||
'aria-describedby': groupHeaderId,
|
||||
})}
|
||||
>
|
||||
<div className={styles.optionBody}>
|
||||
<span className={styles.optionLabel}>{item.label ?? item.value}</span>
|
||||
{item.description && <span className={styles.optionDescription}>{item.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div aria-live="polite">
|
||||
{asyncError && <AsyncError />}
|
||||
{filteredOptions.length === 0 && !asyncError && <NotFoundError />}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useComboboxFloat } from './useComboboxFloat';
|
||||
import { MAX_SHOWN_ITEMS, useMeasureMulti } from './useMeasureMulti';
|
||||
import { useMultiInputAutoSize } from './useMultiInputAutoSize';
|
||||
import { useOptions } from './useOptions';
|
||||
import { isNewGroup } from './utils';
|
||||
|
||||
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
|
||||
value?: T[] | Array<ComboboxOption<T>>;
|
||||
@@ -353,6 +354,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
const itemProps = getItemProps({ item, index });
|
||||
const isSelected = isOptionSelected(item);
|
||||
const id = 'multicombobox-option-' + item.value.toString();
|
||||
const groupHeaderid = 'multicombobox-option-group-' + item.value.toString();
|
||||
const isAll = item.value === ALL_OPTION_VALUE;
|
||||
|
||||
// TODO: fix bug where if the search filtered items list is the
|
||||
@@ -373,7 +375,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
<div className={styles.optionGroup}>
|
||||
<OptionListItem
|
||||
label={item.group ?? t('combobox.group.undefined', 'No group')}
|
||||
id={id}
|
||||
id={groupHeaderid}
|
||||
isGroup={true}
|
||||
/>
|
||||
</div>
|
||||
@@ -453,17 +455,3 @@ 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,12 +1,13 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
|
||||
import { getComboboxStyles } from './getComboboxStyles';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
description?: string;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
id: string;
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,27 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
label: 'combobox-menu-ul-container',
|
||||
listStyle: 'none',
|
||||
}),
|
||||
|
||||
// New class for on the virtual list item. Should be on the wrapper around the group header and option.
|
||||
listItem: css({
|
||||
label: 'list-item',
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
}),
|
||||
|
||||
// New class used in single combobox group headers
|
||||
newOptionGroup: css({
|
||||
label: 'combobox-new-option-group',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
letterSpacing: 0,
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontWeight: theme.typography.fontWeightLight,
|
||||
padding: MENU_ITEM_PADDING,
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
|
||||
optionBasic: css({
|
||||
label: 'combobox-option',
|
||||
position: 'absolute',
|
||||
@@ -55,6 +76,8 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
/** @deprecated - only used in multicombobox, will refactor it */
|
||||
optionGroup: css({
|
||||
cursor: 'default',
|
||||
padding: MENU_ITEM_PADDING,
|
||||
@@ -74,8 +97,10 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
overflow: 'hidden',
|
||||
fontSize: MENU_ITEM_FONT_SIZE,
|
||||
fontWeight: MENU_ITEM_FONT_WEIGHT,
|
||||
lineHeight: MENU_ITEM_LINE_HEIGHT,
|
||||
letterSpacing: 0, // pr todo: text in grafana has a slightly different letter spacing, which causes measureText() to be ~5% off
|
||||
}),
|
||||
/** @deprecated - only used in multicombobox, will refactor it */
|
||||
optionLabelGroup: css({
|
||||
label: 'combobox-option-label-group',
|
||||
color: theme.colors.text.secondary,
|
||||
@@ -93,7 +118,7 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
}),
|
||||
optionFocused: css({
|
||||
label: 'combobox-option-focused',
|
||||
top: 0,
|
||||
// top: 0,
|
||||
background: theme.colors.action.focus,
|
||||
'@media (forced-colors: active), (prefers-contrast: more)': {
|
||||
border: `1px solid ${theme.colors.primary.border}`,
|
||||
|
||||
@@ -42,6 +42,6 @@ export async function generateGroupingOptions(amount: number): Promise<ComboboxO
|
||||
return Array.from({ length: amount }, (_, index) => ({
|
||||
label: 'Option ' + index,
|
||||
value: index.toString(),
|
||||
group: index % 9 !== 0 ? 'Group ' + Math.floor(index / 10) : undefined,
|
||||
group: index % 19 !== 0 ? 'Group ' + Math.floor(index / 20) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -92,43 +92,64 @@ export function useOptions<T extends string | number>(rawOptions: AsyncOptions<T
|
||||
[debouncedLoadOptions, isAsync]
|
||||
);
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 stringifiedOptions = useMemo(() => {
|
||||
return isAsync ? [] : rawOptions.map(itemToString);
|
||||
}, [isAsync, rawOptions]);
|
||||
|
||||
const finalOptions = useMemo(() => {
|
||||
const currentOptions = isAsync ? asyncOptions : fuzzyFind(rawOptions, stringifiedOptions, userTypedSearch);
|
||||
const currentOptionsOrganised = organizeOptionsByGroup(currentOptions);
|
||||
// Create a list of options filtered by the current search.
|
||||
// If async, just returns the async options.
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (isAsync) {
|
||||
return asyncOptions;
|
||||
}
|
||||
|
||||
return addCustomValue(currentOptionsOrganised);
|
||||
}, [isAsync, organizeOptionsByGroup, addCustomValue, asyncOptions, rawOptions, userTypedSearch, stringifiedOptions]);
|
||||
return fuzzyFind(rawOptions, stringifiedOptions, userTypedSearch);
|
||||
}, [asyncOptions, isAsync, rawOptions, stringifiedOptions, userTypedSearch]);
|
||||
|
||||
return { options: finalOptions, updateOptions, asyncLoading, asyncError };
|
||||
const [finalOptions, groupStartIndices] = useMemo(() => {
|
||||
const { options, groupStartIndices } = sortByGroup(filteredOptions);
|
||||
|
||||
return [addCustomValue(options), groupStartIndices];
|
||||
}, [filteredOptions, addCustomValue]);
|
||||
|
||||
return { options: finalOptions, groupStartIndices, updateOptions, asyncLoading, asyncError };
|
||||
}
|
||||
|
||||
function sortByGroup<T extends string | number>(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]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a map to track the starting index of each group
|
||||
const groupStartIndices = new Map<string, number>();
|
||||
let currentIndex = 0;
|
||||
|
||||
// Reorganize options to have groups first, then undefined group
|
||||
const reorganizeOptions = [];
|
||||
for (const [group, groupOptions] of groupedOptions) {
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groupStartIndices.set(group, currentIndex);
|
||||
reorganizeOptions.push(...groupOptions);
|
||||
currentIndex += groupOptions.length;
|
||||
}
|
||||
|
||||
const undefinedGroupOptions = groupedOptions.get(undefined);
|
||||
if (undefinedGroupOptions) {
|
||||
groupStartIndices.set('undefined', currentIndex);
|
||||
reorganizeOptions.push(...undefinedGroupOptions);
|
||||
}
|
||||
|
||||
return {
|
||||
options: reorganizeOptions,
|
||||
groupStartIndices,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ComboboxOption } from './types';
|
||||
|
||||
export 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;
|
||||
};
|
||||
Reference in New Issue
Block a user