MultiCombobox: Initial downshift setup (#96664)

* Initial multi state handling

* Add basic styling

* Extract OptionListItem

* Fix linting issues

* Export types

* Remove React imports

* Make story internal

* Add proper story title

* Remove empty styles
This commit is contained in:
Tobias Skarhed 2024-11-25 16:24:24 +01:00 committed by GitHub
parent 88768d012d
commit 1a453828d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 281 additions and 3 deletions

View File

@ -27,7 +27,7 @@ export type ComboboxOption<T extends string | number = string> = {
// 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.
interface ComboboxBaseProps<T extends string | number>
export interface ComboboxBaseProps<T extends string | number>
extends Pick<
InputProps,
'placeholder' | 'autoFocus' | 'id' | 'aria-labelledby' | 'disabled' | 'loading' | 'invalid'
@ -66,7 +66,7 @@ type ClearableConditionals<T extends number | string> =
}
| { isClearable?: false; onChange: (option: ComboboxOption<T>) => void };
type AutoSizeConditionals =
export type AutoSizeConditionals =
| {
width: 'auto';
/**
@ -86,7 +86,7 @@ type AutoSizeConditionals =
type ComboboxProps<T extends string | number> = ComboboxBaseProps<T> & AutoSizeConditionals & ClearableConditionals<T>;
function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) {
export function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) {
if (!item) {
return '';
}

View File

@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MultiCombobox } from './MultiCombobox';
import mdx from './MultiCombobox.mdx';
const meta: Meta<typeof MultiCombobox> = {
title: 'Forms/MultiCombobox',
component: MultiCombobox,
parameters: {
docs: {
page: mdx,
},
},
};
export default meta;
type Story = StoryObj<typeof MultiCombobox>;
export const Basic: Story = {
args: {
options: [
{ label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' },
{ label: 'Option 3', value: 'option3' },
],
placeholder: 'Select multiple options...',
},
};

View File

@ -0,0 +1,138 @@
import { useCombobox, useMultipleSelection } from 'downshift';
import { useState } from 'react';
import { useStyles2 } from '../../themes';
import { Checkbox } from '../Forms/Checkbox';
import { Portal } from '../Portal/Portal';
import { ComboboxOption, ComboboxBaseProps, AutoSizeConditionals, itemToString } from './Combobox';
import { OptionListItem } from './OptionListItem';
import { ValuePill } from './ValuePill';
import { getMultiComboboxStyles } from './getMultiComboboxStyles';
interface MultiComboboxBaseProps<T extends string | number> extends Omit<ComboboxBaseProps<T>, 'value' | 'onChange'> {
value?: string | Array<ComboboxOption<T>>;
onChange: (items?: Array<ComboboxOption<T>>) => void;
}
type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
const { options, placeholder } = props;
const multiStyles = useStyles2(getMultiComboboxStyles);
const isAsync = typeof options === 'function';
const [items, _baseSetItems] = useState(isAsync ? [] : options);
const [isOpen, setIsOpen] = useState(false);
const [selectedItems, setSelectedItems] = useState<Array<ComboboxOption<T>>>([]);
const [inputValue, setInputValue] = useState('');
const { getSelectedItemProps, getDropdownProps, removeSelectedItem } = useMultipleSelection({
selectedItems, //initally selected items,
onStateChange: ({ type, selectedItems: newSelectedItems }) => {
switch (type) {
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.SelectedItemKeyDownDelete:
case useMultipleSelection.stateChangeTypes.DropdownKeyDownBackspace:
case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
if (newSelectedItems) {
setSelectedItems(newSelectedItems);
}
break;
default:
break;
}
},
});
const {
//getToggleButtonProps,
//getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
//selectedItem,
} = useCombobox({
isOpen,
items,
itemToString,
inputValue,
//defaultHighlightedIndex: 0,
selectedItem: null,
onStateChange: ({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) => {
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
if (newSelectedItem) {
const isAlreadySelected = selectedItems.some((opt) => opt.value === newSelectedItem.value);
if (!isAlreadySelected) {
setSelectedItems([...selectedItems, newSelectedItem]);
break;
}
removeSelectedItem(newSelectedItem);
}
break;
case useCombobox.stateChangeTypes.InputBlur:
setIsOpen(false);
setInputValue('');
break;
case useCombobox.stateChangeTypes.InputChange:
setInputValue(newInputValue ?? '');
break;
default:
break;
}
},
});
return (
<div className={multiStyles.wrapper}>
<span className={multiStyles.pillWrapper}>
{selectedItems.map((item, index) => (
<ValuePill
onRemove={() => {
removeSelectedItem(item);
}}
key={`${item.value}${index}`}
{...getSelectedItemProps({ selectedItem: item, index })}
>
{itemToString(item)}
</ValuePill>
))}
</span>
<input
className={multiStyles.input}
{...getInputProps(getDropdownProps({ preventKeyAction: isOpen, placeholder, onFocus: () => setIsOpen(true) }))}
/>
<div {...getMenuProps()}>
<Portal>
{isOpen && (
<div>
{items.map((item, index) => {
const itemProps = getItemProps({ item, index });
const isSelected = selectedItems.some((opt) => opt.value === item.value);
return (
<li
key={item.value}
{...itemProps}
style={highlightedIndex === index ? { backgroundColor: 'blue' } : {}}
>
{' '}
{/* Add styling with virtualization */}
<Checkbox key={`${item.value}${index}`} value={isSelected} />
<OptionListItem option={item} />
</li>
);
})}
</div>
)}
</Portal>
</div>
</div>
);
};

View File

@ -0,0 +1,18 @@
import { useStyles2 } from '../../themes';
import { ComboboxOption } from './Combobox';
import { getComboboxStyles } from './getComboboxStyles';
interface Props {
option: ComboboxOption<string | number>;
}
export const OptionListItem = ({ option }: Props) => {
const styles = useStyles2(getComboboxStyles);
return (
<div className={styles.optionBody}>
<span className={styles.optionLabel}>{option.label ?? option.value}</span>
{option.description && <span className={styles.optionDescription}>{option.description}</span>}
</div>
);
};

View File

@ -0,0 +1,50 @@
import { css } from '@emotion/css';
import { forwardRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { IconButton } from '../IconButton/IconButton';
interface ValuePillProps {
children: string;
onRemove: () => void;
}
export const ValuePill = forwardRef<HTMLSpanElement, ValuePillProps>(({ children, onRemove, ...rest }, ref) => {
const styles = useStyles2(getValuePillStyles);
return (
<span className={styles.wrapper} {...rest} ref={ref}>
{children}
<span className={styles.separator} />
<IconButton
name="times"
size="md"
aria-label={`Remove ${children}`}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
/>
</span>
);
});
const getValuePillStyles = (theme: GrafanaTheme2) => ({
wrapper: css({
display: 'inline-flex',
gap: theme.spacing(0.5),
borderRadius: theme.shape.radius.default,
color: theme.colors.text.primary,
background: theme.colors.background.secondary,
padding: theme.spacing(0.25),
fontSize: theme.typography.bodySmall.fontSize,
}),
separator: css({
background: theme.colors.border.weak,
width: '2px',
marginLeft: theme.spacing(0.25),
height: '100%',
}),
});

View File

@ -0,0 +1,43 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { getFocusStyles } from '../../themes/mixins';
import { getInputStyles } from '../Input/Input';
export const getMultiComboboxStyles = (theme: GrafanaTheme2) => {
const inputStyles = getInputStyles({ theme });
const focusStyles = getFocusStyles(theme);
return {
wrapper: cx(
inputStyles.input,
css({
display: 'flex',
gap: theme.spacing(0.5),
padding: theme.spacing(0.5),
'&:focus-within': {
...focusStyles,
},
})
),
input: css({
border: 'none',
outline: 'none',
background: 'transparent',
flexGrow: 1,
minWidth: '0',
'&::placeholder': {
color: theme.colors.text.disabled,
},
'&:focus': {
outline: 'none',
},
}),
pillWrapper: css({
display: 'inline-flex',
flexWrap: 'wrap',
gap: theme.spacing(0.5),
}),
};
};