mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
MultiCombobox: Add loading, invalid and disabled states (#98423)
This commit is contained in:
parent
96e8748266
commit
878f5957fb
@ -9,6 +9,7 @@ import { Box } from '../Layout/Box/Box';
|
||||
import { Stack } from '../Layout/Stack/Stack';
|
||||
import { Portal } from '../Portal/Portal';
|
||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
||||
import { Spinner } from '../Spinner/Spinner';
|
||||
import { Text } from '../Text/Text';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
|
||||
@ -34,7 +35,7 @@ interface MultiComboboxBaseProps<T extends string | number> extends Omit<Combobo
|
||||
export type MultiComboboxProps<T extends string | number> = MultiComboboxBaseProps<T> & AutoSizeConditionals;
|
||||
|
||||
export const MultiCombobox = <T extends string | number>(props: MultiComboboxProps<T>) => {
|
||||
const { options, placeholder, onChange, value, width } = props;
|
||||
const { options, placeholder, onChange, value, width, invalid, loading, disabled } = props;
|
||||
const isAsync = typeof options === 'function';
|
||||
|
||||
const selectedItems = useMemo(() => {
|
||||
@ -59,9 +60,13 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
|
||||
const { inputRef: containerRef, floatingRef, floatStyles, scrollRef } = useComboboxFloat(items, isOpen);
|
||||
|
||||
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen);
|
||||
const multiStyles = useStyles2(getMultiComboboxStyles, isOpen, invalid, disabled);
|
||||
|
||||
const { measureRef, suffixMeasureRef, shownItems } = useMeasureMulti(selectedItems, width);
|
||||
const { measureRef, counterMeasureRef, suffixMeasureRef, shownItems } = useMeasureMulti(
|
||||
selectedItems,
|
||||
width,
|
||||
disabled
|
||||
);
|
||||
|
||||
const isOptionSelected = useCallback(
|
||||
(item: ComboboxOption<T>) => selectedItems.some((opt) => opt.value === item.value),
|
||||
@ -157,13 +162,14 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
<div ref={containerRef}>
|
||||
<div
|
||||
style={{ width: width === 'auto' ? undefined : width }}
|
||||
className={multiStyles.wrapper}
|
||||
className={cx(multiStyles.wrapper, { [multiStyles.disabled]: disabled })}
|
||||
ref={measureRef}
|
||||
onClick={() => selectedItems.length > 0 && setIsOpen(!isOpen)}
|
||||
onClick={() => !disabled && selectedItems.length > 0 && setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className={multiStyles.pillWrapper}>
|
||||
{visibleItems.map((item, index) => (
|
||||
<ValuePill
|
||||
disabled={disabled}
|
||||
onRemove={() => {
|
||||
removeSelectedItem(item);
|
||||
}}
|
||||
@ -174,7 +180,7 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
</ValuePill>
|
||||
))}
|
||||
{selectedItems.length > shownItems && !isOpen && (
|
||||
<Box display="flex" direction="row" marginLeft={0.5} gap={1} ref={suffixMeasureRef}>
|
||||
<Box display="flex" direction="row" marginLeft={0.5} gap={1} ref={counterMeasureRef}>
|
||||
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
|
||||
<Text>...</Text>
|
||||
<Tooltip
|
||||
@ -197,12 +203,18 @@ export const MultiCombobox = <T extends string | number>(props: MultiComboboxPro
|
||||
})}
|
||||
{...getInputProps(
|
||||
getDropdownProps({
|
||||
disabled,
|
||||
preventKeyAction: isOpen,
|
||||
placeholder: selectedItems.length > 0 ? undefined : placeholder,
|
||||
onFocus: () => setIsOpen(true),
|
||||
})
|
||||
)}
|
||||
/>
|
||||
{loading && (
|
||||
<div className={multiStyles.suffix} ref={suffixMeasureRef}>
|
||||
<Spinner inline={true} />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Portal>
|
||||
|
@ -9,28 +9,35 @@ import { IconButton } from '../IconButton/IconButton';
|
||||
interface ValuePillProps {
|
||||
children: string;
|
||||
onRemove: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ValuePill = forwardRef<HTMLSpanElement, ValuePillProps>(({ children, onRemove, ...rest }, ref) => {
|
||||
const styles = useStyles2(getValuePillStyles);
|
||||
return (
|
||||
<span className={styles.wrapper} {...rest} ref={ref}>
|
||||
<span className={styles.text}>{children}</span>
|
||||
<span className={styles.separator} />
|
||||
<IconButton
|
||||
name="times"
|
||||
size="md"
|
||||
aria-label={`Remove ${children}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
export const ValuePill = forwardRef<HTMLSpanElement, ValuePillProps>(
|
||||
({ children, onRemove, disabled, ...rest }, ref) => {
|
||||
const styles = useStyles2(getValuePillStyles, disabled);
|
||||
return (
|
||||
<span className={styles.wrapper} {...rest} ref={ref}>
|
||||
<span className={styles.text}>{children}</span>
|
||||
{!disabled && (
|
||||
<>
|
||||
<span className={styles.separator} />
|
||||
<IconButton
|
||||
name="times"
|
||||
size="md"
|
||||
aria-label={`Remove ${children}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const getValuePillStyles = (theme: GrafanaTheme2) => ({
|
||||
const getValuePillStyles = (theme: GrafanaTheme2, disabled?: boolean) => ({
|
||||
wrapper: css({
|
||||
display: 'inline-flex',
|
||||
gap: theme.spacing(0.5),
|
||||
@ -38,6 +45,7 @@ const getValuePillStyles = (theme: GrafanaTheme2) => ({
|
||||
color: theme.colors.text.primary,
|
||||
background: theme.colors.background.secondary,
|
||||
padding: theme.spacing(0.25),
|
||||
border: disabled ? `1px solid ${theme.colors.border.weak}` : 'none',
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
flexShrink: 0,
|
||||
minWidth: '50px',
|
||||
|
@ -5,8 +5,13 @@ import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { getFocusStyles } from '../../themes/mixins';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
|
||||
export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) => {
|
||||
const inputStyles = getInputStyles({ theme });
|
||||
export const getMultiComboboxStyles = (
|
||||
theme: GrafanaTheme2,
|
||||
isOpen: boolean,
|
||||
invalid?: boolean,
|
||||
disabled?: boolean
|
||||
) => {
|
||||
const inputStyles = getInputStyles({ theme, invalid });
|
||||
const focusStyles = getFocusStyles(theme);
|
||||
|
||||
return {
|
||||
@ -52,6 +57,7 @@ export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) =>
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: theme.spacing(0, 1),
|
||||
border: disabled ? `1px solid ${theme.colors.border.weak}` : 'none',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: theme.colors.background.secondary,
|
||||
cursor: 'pointer',
|
||||
@ -59,5 +65,7 @@ export const getMultiComboboxStyles = (theme: GrafanaTheme2, isOpen: boolean) =>
|
||||
backgroundColor: theme.colors.action.hover,
|
||||
},
|
||||
}),
|
||||
suffix: inputStyles.suffix,
|
||||
disabled: inputStyles.inputDisabled,
|
||||
};
|
||||
};
|
||||
|
@ -7,26 +7,31 @@ import { ComboboxOption } from './Combobox';
|
||||
|
||||
const FONT_SIZE = 12;
|
||||
const EXTRA_PILL_SIZE = 50;
|
||||
const EXTRA_PILL_DISABLED_SIZE = 10;
|
||||
|
||||
/**
|
||||
* Updates the number of shown items in the multi combobox based on the available width.
|
||||
*/
|
||||
export function useMeasureMulti<T extends string | number>(
|
||||
selectedItems: Array<ComboboxOption<T>>,
|
||||
width?: number | 'auto'
|
||||
width?: number | 'auto',
|
||||
disabled?: boolean
|
||||
) {
|
||||
const [shownItems, setShownItems] = useState<number>(selectedItems.length);
|
||||
const [measureRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
|
||||
const [counterMeasureRef, { width: counterWidth }] = useMeasure<HTMLDivElement>();
|
||||
const [suffixMeasureRef, { width: suffixWidth }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const finalWidth = width && width !== 'auto' ? width : containerWidth;
|
||||
|
||||
useEffect(() => {
|
||||
const maxWidth = finalWidth - suffixWidth;
|
||||
const maxWidth = finalWidth - counterWidth - suffixWidth;
|
||||
let currWidth = 0;
|
||||
for (let i = 0; i < selectedItems.length; i++) {
|
||||
// Measure text width and add size of padding, separator and close button
|
||||
currWidth += measureText(selectedItems[i].label || '', FONT_SIZE).width + EXTRA_PILL_SIZE;
|
||||
currWidth +=
|
||||
measureText(selectedItems[i].label || '', FONT_SIZE).width +
|
||||
(disabled ? EXTRA_PILL_DISABLED_SIZE : EXTRA_PILL_SIZE);
|
||||
if (currWidth > maxWidth) {
|
||||
// If there is no space for that item, show the current number of items,
|
||||
// but always show at least 1 item
|
||||
@ -38,7 +43,7 @@ export function useMeasureMulti<T extends string | number>(
|
||||
setShownItems(selectedItems.length);
|
||||
}
|
||||
}
|
||||
}, [finalWidth, suffixWidth, selectedItems, setShownItems]);
|
||||
}, [finalWidth, counterWidth, suffixWidth, selectedItems, setShownItems, disabled]);
|
||||
|
||||
return { measureRef, suffixMeasureRef, shownItems };
|
||||
return { measureRef, counterMeasureRef, suffixMeasureRef, shownItems };
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user