Select: Overflow ellipsis and control over multi value wrapping (#76405)

* Select: Better overflow and wrapping behavior and control

* Update

* truncate

* minor update

* review fixes

* Remove legacy big
This commit is contained in:
Torkel Ödegaard
2023-11-14 08:29:12 +01:00
committed by GitHub
parent ea37a116f7
commit 867ff52b38
10 changed files with 75 additions and 68 deletions

View File

@@ -860,8 +860,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "8"], [0, 0, 0, "Do not use any type assertions.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"], [0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"], [0, 0, 0, "Unexpected any. Specify a different type.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"], [0, 0, 0, "Unexpected any. Specify a different type.", "11"]
[0, 0, 0, "Unexpected any. Specify a different type.", "12"]
], ],
"packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx:5381": [ "packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],

View File

@@ -1,13 +1,11 @@
import React from 'react'; import React from 'react';
import { DropdownIndicatorProps } from 'react-select';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
interface DropdownIndicatorProps { export function DropdownIndicator({ selectProps }: DropdownIndicatorProps) {
isOpen: boolean; const isOpen = selectProps.menuIsOpen;
}
export const DropdownIndicator = ({ isOpen }: DropdownIndicatorProps) => {
const icon = isOpen ? 'search' : 'angle-down'; const icon = isOpen ? 'search' : 'angle-down';
const size = isOpen ? 'sm' : 'md'; const size = isOpen ? 'sm' : 'md';
return <Icon name={icon} size={size} />; return <Icon name={icon} size={size} />;
}; }

View File

@@ -45,7 +45,6 @@ const meta: Meta = {
'renderControl', 'renderControl',
'options', 'options',
'isOptionDisabled', 'isOptionDisabled',
'maxVisibleValues',
'aria-label', 'aria-label',
'noOptionsMessage', 'noOptionsMessage',
'menuPosition', 'menuPosition',
@@ -225,7 +224,7 @@ export const MultiSelectBasic: Story = (args) => {
const [value, setValue] = useState<Array<SelectableValue<string>>>([]); const [value, setValue] = useState<Array<SelectableValue<string>>>([]);
return ( return (
<> <div style={{ maxWidth: '450px' }}>
<MultiSelect <MultiSelect
options={generateOptions()} options={generateOptions()}
value={value} value={value}
@@ -236,13 +235,15 @@ export const MultiSelectBasic: Story = (args) => {
prefix={getPrefix(args.icon)} prefix={getPrefix(args.icon)}
{...args} {...args}
/> />
</> </div>
); );
}; };
MultiSelectBasic.args = { MultiSelectBasic.args = {
isClearable: false, isClearable: false,
closeMenuOnSelect: false, closeMenuOnSelect: false,
maxVisibleValues: 5, maxVisibleValues: 5,
noMultiValueWrap: false,
}; };
export const MultiSelectAsync: Story = (args) => { export const MultiSelectAsync: Story = (args) => {

View File

@@ -1,6 +1,6 @@
import { t } from 'i18next'; import { t } from 'i18next';
import React, { ComponentProps, useCallback, useEffect, useRef, useState } from 'react'; import React, { ComponentProps, useCallback, useEffect, useRef, useState } from 'react';
import { default as ReactSelect } from 'react-select'; import { default as ReactSelect, IndicatorsContainerProps, Props as ReactSelectProps } from 'react-select';
import { default as ReactAsyncSelect } from 'react-select/async'; import { default as ReactAsyncSelect } from 'react-select/async';
import { default as AsyncCreatable } from 'react-select/async-creatable'; import { default as AsyncCreatable } from 'react-select/async-creatable';
import Creatable from 'react-select/creatable'; import Creatable from 'react-select/creatable';
@@ -25,31 +25,6 @@ import { useCustomSelectStyles } from './resetSelectStyles';
import { ActionMeta, InputActionMeta, SelectBaseProps } from './types'; import { ActionMeta, InputActionMeta, SelectBaseProps } from './types';
import { cleanValue, findSelectedValue, omitDescriptions } from './utils'; import { cleanValue, findSelectedValue, omitDescriptions } from './utils';
interface ExtraValuesIndicatorProps {
maxVisibleValues?: number | undefined;
selectedValuesCount: number;
menuIsOpen: boolean;
showAllSelectedWhenOpen: boolean;
}
const renderExtraValuesIndicator = (props: ExtraValuesIndicatorProps) => {
const { maxVisibleValues, selectedValuesCount, menuIsOpen, showAllSelectedWhenOpen } = props;
if (
maxVisibleValues !== undefined &&
selectedValuesCount > maxVisibleValues &&
!(showAllSelectedWhenOpen && menuIsOpen)
) {
return (
<span key="excess-values" id="excess-values">
(+{selectedValuesCount - maxVisibleValues})
</span>
);
}
return null;
};
const CustomControl = (props: any) => { const CustomControl = (props: any) => {
const { const {
children, children,
@@ -88,6 +63,12 @@ const CustomControl = (props: any) => {
); );
}; };
interface SelectPropsWithExtras extends ReactSelectProps {
maxVisibleValues?: number | undefined;
showAllSelectedWhenOpen: boolean;
noMultiValueWrap?: boolean;
}
export function SelectBase<T, Rest = {}>({ export function SelectBase<T, Rest = {}>({
allowCustomValue = false, allowCustomValue = false,
allowCreateWhileLoading = false, allowCreateWhileLoading = false,
@@ -145,6 +126,7 @@ export function SelectBase<T, Rest = {}>({
tabSelectsValue = true, tabSelectsValue = true,
value, value,
virtualized = false, virtualized = false,
noMultiValueWrap,
width, width,
isValidNewOption, isValidNewOption,
formatOptionLabel, formatOptionLabel,
@@ -274,6 +256,7 @@ export function SelectBase<T, Rest = {}>({
showAllSelectedWhenOpen, showAllSelectedWhenOpen,
tabSelectsValue, tabSelectsValue,
value: isMulti ? selectedValue : selectedValue?.[0], value: isMulti ? selectedValue : selectedValue?.[0],
noMultiValueWrap,
}; };
if (allowCustomValue) { if (allowCustomValue) {
@@ -305,31 +288,8 @@ export function SelectBase<T, Rest = {}>({
MenuList: SelectMenuComponent, MenuList: SelectMenuComponent,
Group: SelectOptionGroup, Group: SelectOptionGroup,
ValueContainer, ValueContainer,
IndicatorsContainer(props: any) { IndicatorsContainer: CustomIndicatorsContainer,
const { selectProps } = props; IndicatorSeparator: IndicatorSeparator,
const { value, showAllSelectedWhenOpen, maxVisibleValues, menuIsOpen } = selectProps;
if (maxVisibleValues !== undefined) {
const selectedValuesCount = value.length;
const indicatorChildren = [...props.children];
indicatorChildren.splice(
-1,
0,
renderExtraValuesIndicator({
maxVisibleValues,
selectedValuesCount,
showAllSelectedWhenOpen,
menuIsOpen,
})
);
return <IndicatorsContainer {...props}>{indicatorChildren}</IndicatorsContainer>;
}
return <IndicatorsContainer {...props} />;
},
IndicatorSeparator() {
return <></>;
},
Control: CustomControl, Control: CustomControl,
Option: SelectMenuOptions, Option: SelectMenuOptions,
ClearIndicator(props: any) { ClearIndicator(props: any) {
@@ -361,9 +321,7 @@ export function SelectBase<T, Rest = {}>({
</div> </div>
); );
}, },
DropdownIndicator(props) { DropdownIndicator: DropdownIndicator,
return <DropdownIndicator isOpen={props.selectProps.menuIsOpen} />;
},
SingleValue(props: any) { SingleValue(props: any) {
return <SingleValue {...props} isDisabled={disabled} />; return <SingleValue {...props} isDisabled={disabled} />;
}, },
@@ -394,3 +352,37 @@ function defaultFormatCreateLabel(input: string) {
</div> </div>
); );
} }
type CustomIndicatorsContainerProps = IndicatorsContainerProps & {
selectProps: SelectPropsWithExtras;
children: React.ReactNode;
};
function CustomIndicatorsContainer(props: CustomIndicatorsContainerProps) {
const { showAllSelectedWhenOpen, maxVisibleValues, menuIsOpen } = props.selectProps;
const value = props.getValue();
if (maxVisibleValues !== undefined && Array.isArray(props.children)) {
const selectedValuesCount = value.length;
if (selectedValuesCount > maxVisibleValues && !(showAllSelectedWhenOpen && menuIsOpen)) {
const indicatorChildren = [...props.children];
indicatorChildren.splice(
-1,
0,
<span key="excess-values" id="excess-values">
(+{selectedValuesCount - maxVisibleValues})
</span>
);
return <IndicatorsContainer {...props}>{indicatorChildren}</IndicatorsContainer>;
}
}
return <IndicatorsContainer {...props} />;
}
function IndicatorSeparator() {
return <></>;
}

View File

@@ -30,8 +30,15 @@ class UnthemedValueContainer extends Component<any & { theme: GrafanaTheme2 }> {
renderContainer(children?: ReactNode) { renderContainer(children?: ReactNode) {
const { isMulti, theme } = this.props; const { isMulti, theme } = this.props;
const noWrap = this.props.selectProps?.noMultiValueWrap && !this.props.selectProps?.menuIsOpen;
const styles = getSelectStyles(theme); const styles = getSelectStyles(theme);
const className = cx(styles.valueContainer, isMulti && styles.valueContainerMulti);
const className = cx(
styles.valueContainer,
isMulti && styles.valueContainerMulti,
noWrap && styles.valueContainerMultiNoWrap
);
return <div className={className}>{children}</div>; return <div className={className}>{children}</div>;
} }
} }

View File

@@ -96,6 +96,9 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme2) => {
flexWrap: 'wrap', flexWrap: 'wrap',
display: 'flex', display: 'flex',
}), }),
valueContainerMultiNoWrap: css({
flexWrap: 'nowrap',
}),
loadingMessage: css({ loadingMessage: css({
label: 'grafana-select-loading-message', label: 'grafana-select-loading-message',
padding: theme.spacing(1), padding: theme.spacing(1),
@@ -113,6 +116,8 @@ export const getSelectStyles = stylesFactory((theme: GrafanaTheme2) => {
padding: theme.spacing(0.25, 0, 0.25, 1), padding: theme.spacing(0.25, 0, 0.25, 1),
color: theme.colors.text.primary, color: theme.colors.text.primary,
fontSize: theme.typography.size.sm, fontSize: theme.typography.size.sm,
overflow: 'hidden',
whiteSpace: 'nowrap',
'&:hover': { '&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary), background: theme.colors.emphasize(theme.colors.background.secondary),

View File

@@ -24,6 +24,7 @@ export const generateOptions = (desc = false) => {
'Ok Vicente', 'Ok Vicente',
'Garry Spitz', 'Garry Spitz',
'Han Harnish', 'Han Harnish',
'A very long value that is very long and takes up a lot of space and should be truncated preferrably if it does not fit',
]; ];
return values.map<SelectableValue<string>>((name) => ({ return values.map<SelectableValue<string>>((name) => ({

View File

@@ -30,7 +30,10 @@ export default function resetSelectStyles(theme: GrafanaTheme2) {
maxHeight, maxHeight,
}), }),
multiValue: () => ({}), multiValue: () => ({}),
multiValueLabel: () => ({}), multiValueLabel: () => ({
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
multiValueRemove: () => ({}), multiValueRemove: () => ({}),
noOptionsMessage: () => ({}), noOptionsMessage: () => ({}),
option: () => ({}), option: () => ({}),

View File

@@ -99,6 +99,8 @@ export interface SelectCommonProps<T> {
) => boolean; ) => boolean;
/** Message to display isLoading=true*/ /** Message to display isLoading=true*/
loadingMessage?: string; loadingMessage?: string;
/** Disables wrapping of multi value values when closed */
noMultiValueWrap?: boolean;
} }
export interface SelectAsyncProps<T> { export interface SelectAsyncProps<T> {

View File

@@ -227,7 +227,6 @@ export { FieldArray } from './Forms/FieldArray';
// Select // Select
export { default as resetSelectStyles } from './Select/resetSelectStyles'; export { default as resetSelectStyles } from './Select/resetSelectStyles';
export * from './Select/Select'; export * from './Select/Select';
export { DropdownIndicator } from './Select/DropdownIndicator';
export { getSelectStyles } from './Select/getSelectStyles'; export { getSelectStyles } from './Select/getSelectStyles';
export * from './Select/types'; export * from './Select/types';