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