mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
88768d012d
commit
1a453828d9
@ -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 '';
|
||||
}
|
||||
|
@ -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...',
|
||||
},
|
||||
};
|
138
packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx
Normal file
138
packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
50
packages/grafana-ui/src/components/Combobox/ValuePill.tsx
Normal file
50
packages/grafana-ui/src/components/Combobox/ValuePill.tsx
Normal 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%',
|
||||
}),
|
||||
});
|
@ -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),
|
||||
}),
|
||||
};
|
||||
};
|
Loading…
Reference in New Issue
Block a user