MultiCombobox: Add loading, invalid and disabled states (#98423)

pull/98602/head
Joao Silva 4 months ago committed by GitHub
parent 96e8748266
commit 878f5957fb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 24
      packages/grafana-ui/src/components/Combobox/MultiCombobox.tsx
  2. 46
      packages/grafana-ui/src/components/Combobox/ValuePill.tsx
  3. 12
      packages/grafana-ui/src/components/Combobox/getMultiComboboxStyles.ts
  4. 15
      packages/grafana-ui/src/components/Combobox/useMeasureMulti.ts

@ -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…
Cancel
Save