Combobox: Unify menu/list into ComboboxList component (#102677)
* tighten up, lots of multi select stuff still broken * add 2px margin * fix All * put async error message back * translations * fix styling
This commit is contained in:
@@ -514,11 +514,9 @@ describe('Combobox', () => {
|
||||
});
|
||||
|
||||
const customItem = screen.getByRole('option');
|
||||
const customValue = customItem.getElementsByTagName('span')[0].textContent;
|
||||
const customDescription = customItem.getElementsByTagName('span')[1].textContent;
|
||||
expect(customItem).toBeInTheDocument();
|
||||
expect(customValue).toBe('fir');
|
||||
expect(customDescription).toBe('Use custom value');
|
||||
|
||||
expect(customItem).toHaveTextContent('fir');
|
||||
expect(customItem).toHaveTextContent('Use custom value');
|
||||
});
|
||||
|
||||
it('should display message when there is an error loading async options', async () => {
|
||||
|
||||
@@ -9,9 +9,8 @@ import { Icon } from '../Icon/Icon';
|
||||
import { AutoSizeInput } from '../Input/AutoSizeInput';
|
||||
import { Input, Props as InputProps } from '../Input/Input';
|
||||
import { Portal } from '../Portal/Portal';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
|
||||
import { AsyncError, NotFoundError } from './MessageRows';
|
||||
import { ComboboxList } from './ComboboxList';
|
||||
import { itemToString } from './filter';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { ComboboxOption } from './types';
|
||||
@@ -375,82 +374,14 @@ export const Combobox = <T extends string | number>(props: ComboboxProps<T>) =>
|
||||
})}
|
||||
>
|
||||
{isOpen && (
|
||||
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef} padding={0.5}>
|
||||
{!asyncError && (
|
||||
<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 (
|
||||
// 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)`,
|
||||
}}
|
||||
>
|
||||
{startingNewGroup && (
|
||||
<div
|
||||
role="presentation"
|
||||
id={groupHeaderId}
|
||||
className={cx(
|
||||
styles.newOptionGroup,
|
||||
item.group && styles.newOptionGroupLabel,
|
||||
virtualRow.index === 0 && styles.newOptionGroupNoBorder
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div aria-live="polite">
|
||||
{asyncError && <AsyncError />}
|
||||
{filteredOptions.length === 0 && !asyncError && <NotFoundError />}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
<ComboboxList
|
||||
options={filteredOptions}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItems={selectedItem ? [selectedItem] : []}
|
||||
scrollRef={scrollRef}
|
||||
getItemProps={getItemProps}
|
||||
error={asyncError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Portal>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { UseComboboxPropGetters } from 'downshift';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
|
||||
import { AsyncError, NotFoundError } from './MessageRows';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
|
||||
import { isNewGroup } from './utils';
|
||||
|
||||
export const VIRTUAL_OVERSCAN_ITEMS = 4;
|
||||
|
||||
interface ComboboxListProps<T extends string | number> {
|
||||
options: Array<ComboboxOption<T>>;
|
||||
highlightedIndex: number | null;
|
||||
selectedItems?: Array<ComboboxOption<T>>;
|
||||
scrollRef: React.RefObject<HTMLDivElement>;
|
||||
getItemProps: UseComboboxPropGetters<ComboboxOption<T>>['getItemProps'];
|
||||
enableAllOption?: boolean;
|
||||
isMultiSelect?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const ComboboxList = <T extends string | number>({
|
||||
options,
|
||||
highlightedIndex,
|
||||
selectedItems = [],
|
||||
scrollRef,
|
||||
getItemProps,
|
||||
enableAllOption,
|
||||
isMultiSelect = false,
|
||||
error = false,
|
||||
}: ComboboxListProps<T>) => {
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
|
||||
const estimateSize = useCallback(
|
||||
(index: number) => {
|
||||
const firstGroupItem = isNewGroup(options[index], index > 0 ? options[index - 1] : undefined);
|
||||
const hasDescription = 'description' in options[index];
|
||||
const hasGroup = 'group' in options[index];
|
||||
|
||||
let itemHeight = MENU_OPTION_HEIGHT;
|
||||
if (hasDescription) {
|
||||
itemHeight = MENU_OPTION_HEIGHT_DESCRIPTION;
|
||||
}
|
||||
if (firstGroupItem && hasGroup) {
|
||||
itemHeight += MENU_OPTION_HEIGHT;
|
||||
}
|
||||
return itemHeight;
|
||||
},
|
||||
[options]
|
||||
);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: options.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize,
|
||||
overscan: VIRTUAL_OVERSCAN_ITEMS,
|
||||
});
|
||||
|
||||
const isOptionSelected = useCallback(
|
||||
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
|
||||
[selectedItems]
|
||||
);
|
||||
|
||||
const allItemsSelected = enableAllOption && selectedItems.length === options.length - 1;
|
||||
|
||||
return (
|
||||
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef} padding={0.5}>
|
||||
<div style={{ height: rowVirtualizer.getTotalSize() }} className={styles.menuUlContainer}>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow, index, allVirtualRows) => {
|
||||
const item = options[virtualRow.index];
|
||||
const startingNewGroup = isNewGroup(item, options[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 = options[row.index];
|
||||
return rowItem.group === item.group;
|
||||
});
|
||||
const groupHeaderItem = groupHeaderIndex && options[groupHeaderIndex.index];
|
||||
|
||||
const itemId = `combobox-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 ? `combobox-option-group-${groupHeaderItem.value}` : undefined;
|
||||
|
||||
return (
|
||||
// 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)`,
|
||||
}}
|
||||
>
|
||||
{/* Group header */}
|
||||
{startingNewGroup && (
|
||||
<div
|
||||
role="presentation"
|
||||
id={groupHeaderId}
|
||||
className={cx(
|
||||
styles.optionGroupHeader,
|
||||
item.group && styles.optionGroupLabel,
|
||||
virtualRow.index === 0 && styles.optionFirstGroupHeader
|
||||
)}
|
||||
>
|
||||
{item.group}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Option */}
|
||||
<div
|
||||
className={cx(
|
||||
styles.option,
|
||||
!isMultiSelect && isOptionSelected(item) && styles.optionSelected,
|
||||
highlightedIndex === virtualRow.index && styles.optionFocused
|
||||
)}
|
||||
{...getItemProps({
|
||||
item: item,
|
||||
index: virtualRow.index,
|
||||
id: itemId,
|
||||
'aria-describedby': groupHeaderId,
|
||||
})}
|
||||
>
|
||||
{isMultiSelect && (
|
||||
<div className={styles.optionAccessory}>
|
||||
<Checkbox
|
||||
key={itemId}
|
||||
value={allItemsSelected || isOptionSelected(item)}
|
||||
indeterminate={item.value === ALL_OPTION_VALUE && selectedItems.length > 0 && !allItemsSelected}
|
||||
aria-labelledby={itemId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.optionBody}>
|
||||
<div className={styles.optionLabel}>{item.label ?? item.value}</div>
|
||||
|
||||
{item.description && <div className={styles.optionDescription}>{item.description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div aria-live="polite">
|
||||
{error && <AsyncError />}
|
||||
{options.length === 0 && !error && <NotFoundError />}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
);
|
||||
};
|
||||
@@ -25,11 +25,15 @@ const onChangeAction = action('onChange called');
|
||||
|
||||
const commonArgs = {
|
||||
options: [
|
||||
{ label: 'wasd - 1', value: 'option1' },
|
||||
{ label: 'wasd - 2', value: 'option2' },
|
||||
{ label: 'wasd - 3', value: 'option3' },
|
||||
{ label: 'asdf - 1', value: 'option4' },
|
||||
{ label: 'asdf - 2', value: 'option5' },
|
||||
{ label: 'Australia', value: 'option1' },
|
||||
{ label: 'Austria', value: 'option2' },
|
||||
{ label: 'Fiji', value: 'option3' },
|
||||
{ label: 'Iceland', value: 'option4' },
|
||||
{ label: 'Ireland', value: 'option5' },
|
||||
{ label: 'Finland', value: 'option6' },
|
||||
{ label: 'The Netherlands', value: 'option7' },
|
||||
{ label: 'Switzerland', value: 'option8' },
|
||||
{ label: 'United Kingdom of Great Britain and Northern Ireland ', value: 'option9' },
|
||||
],
|
||||
value: ['option2'],
|
||||
placeholder: 'Select multiple options...',
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCombobox, useMultipleSelection } from 'downshift';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
import { t } from '../../utils/i18n';
|
||||
import { Checkbox } from '../Forms/Checkbox';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { Box } from '../Layout/Box/Box';
|
||||
import { Stack } from '../Layout/Stack/Stack';
|
||||
import { Portal } from '../Portal/Portal';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
import { Text } from '../Text/Text';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
import { ComboboxBaseProps, AutoSizeConditionals, VIRTUAL_OVERSCAN_ITEMS } from './Combobox';
|
||||
import { NotFoundError } from './MessageRows';
|
||||
import { OptionListItem } from './OptionListItem';
|
||||
import { ComboboxBaseProps, AutoSizeConditionals } from './Combobox';
|
||||
import { ComboboxList } from './ComboboxList';
|
||||
import { SuffixIcon } from './SuffixIcon';
|
||||
import { ValuePill } from './ValuePill';
|
||||
import { itemToString } from './filter';
|
||||
import { getComboboxStyles, MENU_OPTION_HEIGHT, MENU_OPTION_HEIGHT_DESCRIPTION } from './getComboboxStyles';
|
||||
import { getComboboxStyles } from './getComboboxStyles';
|
||||
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
|
||||
import { ALL_OPTION_VALUE, ComboboxOption } from './types';
|
||||
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>>;
|
||||
@@ -67,7 +61,12 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
}, [inputValue]);
|
||||
|
||||
// Handle async options and the 'All' option
|
||||
const { options: baseOptions, updateOptions, asyncLoading } = useOptions(props.options, createCustomValue);
|
||||
const {
|
||||
options: baseOptions,
|
||||
updateOptions,
|
||||
asyncLoading,
|
||||
asyncError,
|
||||
} = useOptions(props.options, createCustomValue);
|
||||
const options = useMemo(() => {
|
||||
// Only add the 'All' option if there's more than 1 option
|
||||
const addAllOption = enableAllOption && baseOptions.length > 1;
|
||||
@@ -244,26 +243,6 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
isClearable
|
||||
);
|
||||
|
||||
const virtualizerOptions = {
|
||||
count: options.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
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,
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer(virtualizerOptions);
|
||||
|
||||
// Selected items that show up in the input field
|
||||
const visibleItems = isOpen ? selectedItems.slice(0, MAX_SHOWN_ITEMS) : selectedItems.slice(0, shownItems);
|
||||
|
||||
@@ -341,79 +320,23 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
<Portal>
|
||||
<div
|
||||
className={cx(styles.menu, !isOpen && styles.menuClosed)}
|
||||
style={{ ...floatStyles }}
|
||||
style={{
|
||||
...floatStyles,
|
||||
width: floatStyles.width + 24, // account for checkbox
|
||||
}}
|
||||
{...getMenuProps({ ref: floatingRef })}
|
||||
>
|
||||
{isOpen && (
|
||||
<ScrollContainer showScrollIndicators maxHeight="inherit" ref={scrollRef} padding={0.5}>
|
||||
<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 });
|
||||
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
|
||||
// same length, but different, than the selected items (ask tobias)
|
||||
const allItemsSelected =
|
||||
options[0]?.value === ALL_OPTION_VALUE && selectedItems.length === options.length - 1;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={`${item.value}-${index}`}
|
||||
data-index={index}
|
||||
{...itemProps}
|
||||
className={styles.optionBasic}
|
||||
style={{ height: virtualRow.size, transform: `translateY(${virtualRow.start}px)` }}
|
||||
>
|
||||
<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={groupHeaderid}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div aria-live="polite">{options.length === 0 && <NotFoundError />}</div>
|
||||
</ScrollContainer>
|
||||
<ComboboxList
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItems={selectedItems}
|
||||
scrollRef={scrollRef}
|
||||
getItemProps={getItemProps}
|
||||
enableAllOption={enableAllOption}
|
||||
isMultiSelect={true}
|
||||
error={asyncError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Portal>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { useStyles2 } from '../../themes';
|
||||
|
||||
import { getComboboxStyles } from './getComboboxStyles';
|
||||
|
||||
interface Props {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
id: string;
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
export const OptionListItem = ({ label, description, id, isGroup = false }: Props) => {
|
||||
const styles = useStyles2(getComboboxStyles);
|
||||
return (
|
||||
<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>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,13 +5,16 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
// 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_DESCRIPTION_FONT_SIZE = 12;
|
||||
export const MENU_ITEM_FONT_WEIGHT = 500;
|
||||
export const MENU_ITEM_PADDING = 8;
|
||||
export const MENU_ITEM_GAP = 2;
|
||||
export const MENU_ITEM_LINE_HEIGHT = 1.5;
|
||||
|
||||
// Used with Downshift to get the height of each item
|
||||
export const MENU_OPTION_HEIGHT = MENU_ITEM_PADDING * 2 + MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
|
||||
export const MENU_OPTION_HEIGHT_DESCRIPTION = MENU_OPTION_HEIGHT + MENU_ITEM_LINE_HEIGHT * MENU_ITEM_FONT_SIZE;
|
||||
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 =
|
||||
MENU_OPTION_HEIGHT + MENU_ITEM_DESCRIPTION_FONT_SIZE * MENU_ITEM_LINE_HEIGHT;
|
||||
export const POPOVER_MAX_HEIGHT = MENU_OPTION_HEIGHT * 8.5;
|
||||
|
||||
export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
@@ -33,20 +36,24 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
listStyle: 'none',
|
||||
}),
|
||||
|
||||
// New class for on the virtual list item. Should be on the wrapper around the group header and option.
|
||||
// The wrapper around the group header and option, not the option itself.
|
||||
// Should not contain visual styling itself.
|
||||
listItem: css({
|
||||
label: 'list-item',
|
||||
width: '100%',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
}),
|
||||
|
||||
// New class used in single combobox group headers
|
||||
newOptionGroup: css({
|
||||
optionGroupHeader: css({
|
||||
label: 'combobox-new-option-group',
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
}),
|
||||
|
||||
newOptionGroupLabel: css({
|
||||
optionFirstGroupHeader: css({
|
||||
borderTop: 'none',
|
||||
}),
|
||||
|
||||
optionGroupLabel: css({
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
letterSpacing: 0,
|
||||
@@ -56,26 +63,20 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
padding: MENU_ITEM_PADDING,
|
||||
}),
|
||||
|
||||
newOptionGroupNoBorder: css({
|
||||
borderTop: 'none',
|
||||
}),
|
||||
|
||||
optionBasic: css({
|
||||
label: 'combobox-option',
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
option: css({
|
||||
padding: MENU_ITEM_PADDING,
|
||||
cursor: 'pointer',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
label: 'combobox-option',
|
||||
position: 'relative', // for the selection gradient to grab to
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
gap: theme.spacing(1),
|
||||
alignItems: 'center',
|
||||
padding: MENU_ITEM_PADDING,
|
||||
marginBottom: MENU_ITEM_GAP,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
background: theme.colors.action.hover,
|
||||
'@media (forced-colors: active), (prefers-contrast: more)': {
|
||||
@@ -84,45 +85,40 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => {
|
||||
},
|
||||
}),
|
||||
|
||||
/** @deprecated - only used in multicombobox, will refactor it */
|
||||
optionGroup: css({
|
||||
cursor: 'default',
|
||||
padding: MENU_ITEM_PADDING,
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
optionAccessory: css({
|
||||
label: 'combobox-option-accessory',
|
||||
height: MENU_ITEM_FONT_SIZE * MENU_ITEM_LINE_HEIGHT, // Ensure the accessory doesn't make the option too tall
|
||||
}),
|
||||
|
||||
optionBody: css({
|
||||
label: 'combobox-option-body',
|
||||
display: 'flex',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
|
||||
optionLabel: css({
|
||||
label: 'combobox-option-label',
|
||||
textOverflow: 'ellipsis',
|
||||
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,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
fontWeight: theme.typography.fontWeightLight,
|
||||
}),
|
||||
optionDescription: css({
|
||||
label: 'combobox-option-description',
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
color: theme.colors.text.secondary,
|
||||
lineHeight: MENU_ITEM_LINE_HEIGHT,
|
||||
letterSpacing: 0,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
|
||||
optionDescription: css({
|
||||
label: 'combobox-option-description',
|
||||
color: theme.colors.text.secondary,
|
||||
fontSize: MENU_ITEM_DESCRIPTION_FONT_SIZE,
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
lineHeight: MENU_ITEM_LINE_HEIGHT,
|
||||
letterSpacing: 0,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
|
||||
optionFocused: css({
|
||||
label: 'combobox-option-focused',
|
||||
// top: 0,
|
||||
|
||||
@@ -18,6 +18,8 @@ const WIDTH_CALCULATION_LIMIT_ITEMS = 100_000;
|
||||
// Clearance around the popover to prevent it from being too close to the edge of the viewport
|
||||
const POPOVER_PADDING = 16;
|
||||
|
||||
const SCROLL_CONTAINER_PADDING = 8;
|
||||
|
||||
export const useComboboxFloat = (items: Array<ComboboxOption<string | number>>, isOpen: boolean) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const floatingRef = useRef<HTMLDivElement>(null);
|
||||
@@ -70,7 +72,7 @@ export const useComboboxFloat = (items: Array<ComboboxOption<string | number>>,
|
||||
|
||||
const size = measureText(longestItem, MENU_ITEM_FONT_SIZE, MENU_ITEM_FONT_WEIGHT).width;
|
||||
|
||||
return size + MENU_ITEM_PADDING * 2 + scrollbarWidth;
|
||||
return size + SCROLL_CONTAINER_PADDING + MENU_ITEM_PADDING * 2 + scrollbarWidth;
|
||||
}, [items, scrollbarWidth]);
|
||||
|
||||
const floatStyles = {
|
||||
|
||||
@@ -1135,9 +1135,6 @@
|
||||
"custom-value": {
|
||||
"description": "Use custom value"
|
||||
},
|
||||
"group": {
|
||||
"undefined": "No group"
|
||||
},
|
||||
"options": {
|
||||
"no-found": "No options found."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user