(react) add monoline props to multi select

We want to enable a mode that prevent the pills the wrap on multiple
lines in order to control any height overflowing. In monoline mode the
selected items are displayed as text to allow text ellipsis, and the
menu renders the list items with checkboxes.
This commit is contained in:
Nathan Vasse
2023-11-21 16:51:32 +01:00
committed by NathanVss
parent 7c13badeb2
commit 94b32be5d3
14 changed files with 490 additions and 151 deletions

View File

@@ -157,6 +157,23 @@
color: var(--c--theme--colors--greyscale-600);
font-style: italic;
}
&.c__select__menu--checkbox {
.c__select__menu__item {
padding: 0;
.c__checkbox {
padding: 0.75rem;
margin-left: 0;
&__label {
font-size: var(--c--components--forms-select--item-font-size);
color: var(--c--components--forms-select--item-color);
font-weight: normal;
}
}
}
}
}
/** Modifiers */
@@ -233,13 +250,44 @@
}
&--multi {
.c__select__wrapper {
height: auto;
min-height: var(--c--components--forms-select--height);
&.c__select--monoline {
.c__select__inner {
.c__select__inner__value {
overflow: hidden;
}
.c__select__inner__actions {
order: 2;
}
}
}
&.c__select--multiline {
.c__select__wrapper {
height: auto;
min-height: var(--c--components--forms-select--height);
}
.c__select__inner {
display: block;
&__actions {
float: right;
position: relative;
height: 0;
top: px-to-rem(3px);
}
}
}
&.c__select--text {
.c__select__inner__value {
white-space: nowrap;
text-overflow: ellipsis;
}
}
.c__select__inner {
display: block;
&__value {
gap: 0.25rem;
@@ -298,13 +346,6 @@
}
}
}
&__actions {
float: right;
position: relative;
height: 0;
top: 3px;
}
}

View File

@@ -6,21 +6,22 @@ import { FieldProps } from ":/components/Forms/Field";
export * from ":/components/Forms/Select/mono";
export * from ":/components/Forms/Select/multi";
export type OptionWithRender = {
disabled?: boolean;
type BaseOption = {
value: string;
label: string;
render: () => ReactNode;
highlighted?: boolean;
disabled?: boolean;
};
export type Option =
| {
disabled?: boolean;
value?: string;
label: string;
render?: undefined;
}
| OptionWithRender;
export type OptionWithRender = BaseOption;
export type OptionWithoutRender = Omit<BaseOption, "value" | "render"> & {
value?: string;
render?: undefined;
};
export type Option = OptionWithoutRender | OptionWithRender;
export interface SelectHandle {
blur: () => void;
@@ -45,6 +46,9 @@ export type SelectProps = PropsWithChildren &
clearable?: boolean;
multi?: boolean;
showLabelWhenSelected?: boolean;
monoline?: boolean;
selectedItemsStyle?: "pills" | "text";
menuOptionsStyle?: "plain" | "checkbox";
};
export const Select = forwardRef<SelectHandle, SelectProps>((props, ref) => {
if (props.defaultValue && props.value) {

View File

@@ -29,6 +29,10 @@ export const optionToValue = (option: Option) => {
return option.value ?? option.label;
};
export const optionsEqual = (a: Option, b: Option) => {
return optionToValue(a) === optionToValue(b);
};
export const renderOption = (option: Option) => {
if (isOptionWithRender(option)) {
return option.render();

View File

@@ -9,9 +9,9 @@ import { Option, SelectProps } from ":/components/Forms/Select";
import {
getOptionsFilter,
optionToValue,
renderOption,
} from ":/components/Forms/Select/mono-common";
import { SelectedOption } from ":/components/Forms/Select/utils";
import { SelectedItems } from ":/components/Forms/Select/multi-selected-items";
import { SelectMultiMenu } from ":/components/Forms/Select/multi-menu";
/**
* This method returns a comparator that can be used to filter out options for multi select.
@@ -43,19 +43,7 @@ export type SubProps = Omit<SelectProps, "onChange"> & {
selectedItems: Option[];
};
export const SelectMultiAux = ({
options,
labelAsPlaceholder,
selectedItems,
clearable = true,
disabled,
hideLabel,
name,
downshiftReturn,
useMultipleSelectionReturn,
children,
...props
}: SubProps & {
export interface SelectMultiAuxProps extends SubProps {
options: Option[];
labelAsPlaceholder: boolean;
selectedItems: Option[];
@@ -70,9 +58,11 @@ export const SelectMultiAux = ({
wrapperProps?: HTMLAttributes<HTMLDivElement>;
};
useMultipleSelectionReturn: ReturnType<typeof useMultipleSelection<Option>>;
}) => {
}
export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => {
const { t } = useCunningham();
const labelProps = downshiftReturn.getLabelProps();
const labelProps = props.downshiftReturn.getLabelProps();
return (
<Field {...props}>
<div
@@ -80,54 +70,60 @@ export const SelectMultiAux = ({
"c__select",
"c__select--multi",
"c__select--" + props.state,
"c__select--" + props.selectedItemsStyle,
{
"c__select--disabled": disabled,
"c__select--populated": selectedItems.length > 0,
"c__select--disabled": props.disabled,
"c__select--populated": props.selectedItems.length > 0,
"c__select--monoline": props.monoline,
"c__select--multiline": !props.monoline,
},
)}
>
<div
className={classNames("c__select__wrapper", {
"c__select__wrapper--focus": downshiftReturn.isOpen && !disabled,
"c__select__wrapper--focus":
props.downshiftReturn.isOpen && !props.disabled,
})}
{...downshiftReturn.wrapperProps}
{...props.downshiftReturn.wrapperProps}
>
{selectedItems.map((selectedItem, index) => (
{props.selectedItems.map((selectedItem, index) => (
<input
key={`${optionToValue(selectedItem)}${index.toString()}`}
type="hidden"
name={name}
name={props.name}
value={optionToValue(selectedItem)}
/>
))}
<LabelledBox
label={props.label}
labelAsPlaceholder={labelAsPlaceholder}
labelAsPlaceholder={props.labelAsPlaceholder}
htmlFor={labelProps.htmlFor}
labelId={labelProps.id}
hideLabel={hideLabel}
disabled={disabled}
hideLabel={props.hideLabel}
disabled={props.disabled}
>
<div className="c__select__inner">
<div className="c__select__inner__actions">
{clearable && !disabled && selectedItems.length > 0 && (
<>
<Button
color="tertiary"
size="nano"
aria-label={t(
"components.forms.select.clear_all_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
e.stopPropagation();
props.onSelectedItemsChange([]);
}}
icon={<span className="material-icons">close</span>}
/>
<div className="c__select__inner__actions__separator" />
</>
)}
{props.clearable &&
!props.disabled &&
props.selectedItems.length > 0 && (
<>
<Button
color="tertiary"
size="nano"
aria-label={t(
"components.forms.select.clear_all_button_aria_label",
)}
className="c__select__inner__actions__clear"
onClick={(e) => {
e.stopPropagation();
props.onSelectedItemsChange([]);
}}
icon={<span className="material-icons">close</span>}
/>
<div className="c__select__inner__actions__separator" />
</>
)}
<Button
color="tertiary"
size="nano"
@@ -135,95 +131,24 @@ export const SelectMultiAux = ({
icon={
<span
className={classNames("material-icons", {
opened: downshiftReturn.isOpen,
opened: props.downshiftReturn.isOpen,
})}
>
arrow_drop_down
</span>
}
disabled={disabled}
{...downshiftReturn.toggleButtonProps}
disabled={props.disabled}
{...props.downshiftReturn.toggleButtonProps}
/>
</div>
<div className="c__select__inner__value">
{selectedItems.map((selectedItemForRender, index) => {
return (
<div
className="c__select__inner__value__pill"
key={`${optionToValue(
selectedItemForRender,
)}${index.toString()}`}
{...useMultipleSelectionReturn.getSelectedItemProps({
selectedItem: selectedItemForRender,
index,
})}
>
<SelectedOption
option={selectedItemForRender}
{...props}
/>
<Button
tabIndex={-1}
color="tertiary"
disabled={disabled}
size="small"
aria-label={t(
"components.forms.select.clear_button_aria_label",
)}
type="button"
className="c__select__inner__value__pill__clear"
onClick={(e) => {
e.stopPropagation();
useMultipleSelectionReturn.removeSelectedItem(
selectedItemForRender,
);
}}
icon={<span className="material-icons">close</span>}
/>
</div>
);
})}
<SelectedItems {...props} />
{children}
</div>
</div>
</LabelledBox>
</div>
<div
className={classNames("c__select__menu", {
"c__select__menu--opened": downshiftReturn.isOpen,
})}
{...downshiftReturn.getMenuProps()}
>
<ul>
{downshiftReturn.isOpen && (
<>
{options.map((option, index) => {
const isActive = index === downshiftReturn.highlightedIndex;
return (
<li
className={classNames("c__select__menu__item", {
"c__select__menu__item--highlight": isActive,
"c__select__menu__item--disabled": option.disabled,
})}
key={`${optionToValue(option)}${index.toString()}`}
{...downshiftReturn.getItemProps({
item: option,
index,
})}
>
<span>{renderOption(option)}</span>
</li>
);
})}
{options.length === 0 && (
<li className="c__select__menu__item c__select__menu__empty-placeholder">
{t("components.forms.select.menu_empty_placeholder")}
</li>
)}
</>
)}
</ul>
</div>
<SelectMultiMenu {...props} />
</div>
</Field>
);

View File

@@ -0,0 +1,100 @@
import classNames from "classnames";
import React from "react";
import { SelectMultiAuxProps } from ":/components/Forms/Select/multi-common";
import {
optionToValue,
renderOption,
} from ":/components/Forms/Select/mono-common";
import { useCunningham } from ":/components/Provider";
import { Checkbox } from ":/components/Forms/Checkbox";
import { Option } from ":/components/Forms/Select/index";
export const SelectMultiMenu = (props: SelectMultiAuxProps) => {
const { t } = useCunningham();
return (
<div
className={classNames(
"c__select__menu",
"c__select__menu--" + props.menuOptionsStyle,
{
"c__select__menu--opened": props.downshiftReturn.isOpen,
},
)}
{...props.downshiftReturn.getMenuProps()}
>
<ul>
{props.downshiftReturn.isOpen && (
<>
{props.options.map((option, index) => (
<MenuItem
{...props}
option={option}
index={index}
key={optionToValue(option)}
/>
))}
{props.options.length === 0 && (
<li className="c__select__menu__item c__select__menu__empty-placeholder">
{t("components.forms.select.menu_empty_placeholder")}
</li>
)}
</>
)}
</ul>
</div>
);
};
type MenuItemProps = SelectMultiAuxProps & { option: Option; index: number };
const MenuItem = (props: MenuItemProps) => {
if (props.menuOptionsStyle === "plain") {
return <MenuItemPlain {...props} />;
}
if (props.menuOptionsStyle === "checkbox") {
return <MenuItemCheckbox {...props} />;
}
throw new Error("Unknown menuOptionsStyle");
};
const MenuItemPlain = ({ option, index, ...props }: MenuItemProps) => {
const isHighlighted =
index === props.downshiftReturn.highlightedIndex || option.highlighted;
return (
<li
className={classNames("c__select__menu__item", {
"c__select__menu__item--highlight": isHighlighted,
"c__select__menu__item--disabled": option.disabled,
})}
{...props.downshiftReturn.getItemProps({
item: option,
index,
})}
>
<span>{renderOption(option)}</span>
</li>
);
};
const MenuItemCheckbox = ({ option, index, ...props }: MenuItemProps) => {
return (
<li
className={classNames("c__select__menu__item", {
"c__select__menu__item--highlight":
index === props.downshiftReturn.highlightedIndex,
"c__select__menu__item--disabled": option.disabled,
})}
{...props.downshiftReturn.getItemProps({
item: option,
index,
})}
>
<Checkbox
label={renderOption(option) as any}
checked={option.highlighted}
fullWidth={true}
/>
</li>
);
};

View File

@@ -127,6 +127,7 @@ export const SelectMultiSearchable = forwardRef<SelectHandle, SubProps>(
return (
<SelectMultiAux
{...props}
monoline={false}
options={options}
labelAsPlaceholder={labelAsPlaceholder}
selectedItems={props.selectedItems}

View File

@@ -0,0 +1,63 @@
import React from "react";
import { useCunningham } from ":/components/Provider";
import {
optionToString,
optionToValue,
} from ":/components/Forms/Select/mono-common";
import { SelectedOption } from ":/components/Forms/Select/utils";
import { Button } from ":/components/Button";
import { SelectMultiAuxProps } from ":/components/Forms/Select/multi-common";
export const SelectedItems = (props: SelectMultiAuxProps) => {
if (props.selectedItemsStyle === "pills") {
return <SelectedItemsChips {...props} />;
}
if (props.selectedItemsStyle === "text") {
return <SelectedItemsText {...props} />;
}
throw new Error("Unknown selectedItemsStyle");
};
const SelectedItemsChips = ({
selectedItems,
selectedItemsStyle,
disabled,
useMultipleSelectionReturn,
...props
}: SelectMultiAuxProps) => {
const { t } = useCunningham();
return selectedItems.map((selectedItemForRender, index) => {
return (
<div
className="c__select__inner__value__pill"
key={`${optionToValue(selectedItemForRender)}${index.toString()}`}
{...useMultipleSelectionReturn.getSelectedItemProps({
selectedItem: selectedItemForRender,
index,
})}
>
<SelectedOption option={selectedItemForRender} {...props} />
<Button
tabIndex={-1}
color="tertiary"
disabled={disabled}
size="small"
aria-label={t("components.forms.select.clear_button_aria_label")}
type="button"
className="c__select__inner__value__pill__clear"
onClick={(e) => {
e.stopPropagation();
useMultipleSelectionReturn.removeSelectedItem(
selectedItemForRender,
);
}}
icon={<span className="material-icons">close</span>}
/>
</div>
);
});
};
const SelectedItemsText = ({ selectedItems }: SelectMultiAuxProps) => {
return selectedItems.map((item) => optionToString(item)).join(", ");
};

View File

@@ -5,16 +5,30 @@ import {
SelectMultiAux,
SubProps,
} from ":/components/Forms/Select/multi-common";
import { optionToString } from ":/components/Forms/Select/mono-common";
import { SelectHandle } from ":/components/Forms/Select/index";
import {
optionsEqual,
optionToString,
} from ":/components/Forms/Select/mono-common";
import { Option, SelectHandle } from ":/components/Forms/Select/index";
export const SelectMultiSimple = forwardRef<SelectHandle, SubProps>(
(props, ref) => {
const options = React.useMemo(
() =>
props.options.filter(getMultiOptionsFilter(props.selectedItems, "")),
[props.selectedItems],
);
const isSelected = (option: Option) =>
!!props.selectedItems.find((selectedItem) =>
optionsEqual(selectedItem, option),
);
const options = React.useMemo(() => {
if (props.monoline) {
return props.options.map((option) => ({
...option,
highlighted: isSelected(option),
}));
}
return props.options.filter(
getMultiOptionsFilter(props.selectedItems, ""),
);
}, [props.selectedItems]);
const useMultipleSelectionReturn = useMultipleSelection({
selectedItems: props.selectedItems,
@@ -47,7 +61,7 @@ export const SelectMultiSimple = forwardRef<SelectHandle, SubProps>(
return {
...changes,
isOpen: true, // keep the menu open after selection.
highlightedIndex: 0, // with the first option highlighted.
highlightedIndex: state.highlightedIndex, // avoid automatic scroll up on click.
};
}
return changes;
@@ -57,7 +71,18 @@ export const SelectMultiSimple = forwardRef<SelectHandle, SubProps>(
case useSelect.stateChangeTypes.ToggleButtonKeyDownEnter:
case useSelect.stateChangeTypes.ToggleButtonKeyDownSpaceButton:
case useSelect.stateChangeTypes.ItemClick:
if (newSelectedItem) {
if (!newSelectedItem) {
break;
}
if (isSelected(newSelectedItem)) {
// Remove the item if it is already selected.
props.onSelectedItemsChange(
props.selectedItems.filter(
(selectedItem) =>
!optionsEqual(selectedItem, newSelectedItem),
),
);
} else {
props.onSelectedItemsChange([
...props.selectedItems,
newSelectedItem,
@@ -86,6 +111,8 @@ export const SelectMultiSimple = forwardRef<SelectHandle, SubProps>(
options={options}
labelAsPlaceholder={props.selectedItems.length === 0}
selectedItems={props.selectedItems}
selectedItemsStyle={props.monoline ? "text" : "pills"}
menuOptionsStyle={props.monoline ? "checkbox" : "plain"}
downshiftReturn={{
...downshiftReturn,
wrapperProps: {

View File

@@ -88,6 +88,18 @@ For some reasons you might want to hide the label of the Multi-Select. You can d
<Story id="components-forms-select-multi--hidden-label"/>
</Canvas>
## Monoline
You can use `monoline` props in order to make sure the component will never wrap on multiple lines.
When using this mode the selected options will be kept in the menu with checkboxes in order to make sure the user can
always see the full list of selected options without having to scroll.
> At the moment this props cannot be used in conjunction with `searchable` props.
<Canvas sourceState="shown">
<Story id="components-forms-select-multi--monoline"/>
</Canvas>
## Custom render option
You can give customize the look of the options by providing `render` callback.

View File

@@ -16,6 +16,7 @@ import {
expectOptionToBeDisabled,
expectOptionToBeUnselected,
expectSelectedOptions,
expectSelectedOptionsText,
} from ":/components/Forms/Select/test-utils";
import { Button } from ":/components/Button";
@@ -938,6 +939,139 @@ describe("<Select multi={true} />", () => {
name: "London flag",
});
});
it("renders checkboxes in the menu and selected options as text when using monoline mode", async () => {
const user = userEvent.setup();
render(
<CunninghamProvider>
<Select
label="Cities"
options={[
{
label: "Paris",
},
{
label: "Panama",
},
{
label: "London",
},
{
label: "New York",
},
{
label: "Tokyo",
},
]}
multi={true}
monoline={true}
/>
</CunninghamProvider>,
);
const input = screen.getByRole("combobox", {
name: "Cities",
});
const menu: HTMLDivElement = screen.getByRole("listbox", {
name: "Cities",
});
const label = screen.getByText("Cities")!.parentElement!;
const expectCheckedOptions = ({
checked,
notChecked,
}: {
checked: string[];
notChecked: string[];
}) => {
checked.forEach((option) => {
const optionElement = screen.getByRole("option", { name: option });
const checkbox: HTMLInputElement =
within(optionElement).getByRole("checkbox");
expect(checkbox.checked).toEqual(true);
});
notChecked.forEach((option) => {
const optionElement = screen.getByRole("option", { name: option });
const checkbox: HTMLInputElement =
within(optionElement).getByRole("checkbox");
expect(checkbox.checked).toEqual(false);
});
};
// Expect no options to be selected.
expectSelectedOptionsText([]);
// Make sure the label is set as placeholder.
expect(Array.from(label.classList)).toContain("placeholder");
await user.click(input);
// Make sure the menu is opened and options are rendered.
expectMenuToBeOpen(menu);
expectOptions(["Paris", "Panama", "London", "New York", "Tokyo"]);
expectCheckedOptions({
checked: [],
notChecked: ["Paris", "Panama", "London", "New York", "Tokyo"],
});
// Make sure the option is not selected.
const option: HTMLLIElement = screen.getByRole("option", {
name: "London",
});
expectOptionToBeUnselected(option);
// Select an option.
await user.click(option);
// Make sure the option is selected.
expectSelectedOptionsText(["London"]);
// Make sure the menu stays open.
expectMenuToBeOpen(menu);
// Make sure the option is still present in the menu and is highlighted.
expectCheckedOptions({
checked: ["London"],
notChecked: ["Paris", "Panama", "New York", "Tokyo"],
});
// Select Paris
await user.click(screen.getByRole("option", { name: "Paris" }));
// Make sure the option is selected.
expectSelectedOptionsText(["London", "Paris"]);
screen.logTestingPlaygroundURL();
// Make sure the menu stays open.
expectMenuToBeOpen(menu);
// Make sure the option is still present in the menu and is highlighted.
expectCheckedOptions({
checked: ["London", "Paris"],
notChecked: ["Panama", "New York", "Tokyo"],
});
// Click on London and make sure it is removed from the selected.
await user.click(screen.getByRole("option", { name: "London" }));
// We need to move the cursor away from London otherwise the option will be highlighted.
await user.hover(screen.getByRole("option", { name: "Paris" }));
// Make sure the option is not selected anymore.
expectSelectedOptionsText(["Paris"]);
// Make sure the menu stays open.
expectMenuToBeOpen(menu);
// Make sure the option is still present in the menu and is not highlighted anymore.
await waitFor(() =>
expectCheckedOptions({
checked: ["Paris"],
notChecked: ["London", "Panama", "New York", "Tokyo"],
}),
);
});
});
describe("Searchable", async () => {

View File

@@ -50,6 +50,17 @@ export const Disabled = {
},
};
export const Monoline = {
render: Template,
args: {
label: "Select cities",
options: OPTIONS,
defaultValue: [OPTIONS[4].value, OPTIONS[2].value, OPTIONS[1].value],
monoline: true,
clearable: true,
},
};
export const WithText = {
render: Template,
args: {

View File

@@ -44,11 +44,17 @@ export const SelectMulti = forwardRef<SelectHandle, SelectMultiProps>(
newSelectedItems,
) => {
setSelectedItems(newSelectedItems);
// props.onSelectedItemsChange?.(newSelectedItems);
};
const defaultProps: Partial<SelectMultiProps> = {
selectedItemsStyle: "pills",
menuOptionsStyle: "plain",
clearable: true,
};
return props.searchable ? (
<SelectMultiSearchable
{...defaultProps}
{...props}
selectedItems={selectedItems}
onSelectedItemsChange={onSelectedItemsChange}
@@ -56,6 +62,7 @@ export const SelectMulti = forwardRef<SelectHandle, SelectMultiProps>(
/>
) : (
<SelectMultiSimple
{...defaultProps}
{...props}
selectedItems={selectedItems}
onSelectedItemsChange={onSelectedItemsChange}

View File

@@ -44,3 +44,8 @@ export const expectSelectedOptions = (expectedOptions: string[]) => {
});
expect(actualOptions).toEqual(expectedOptions);
};
export const expectSelectedOptionsText = (expectedOptions: string[]) => {
const valueElement = document.querySelector(".c__select__inner__value");
expect(valueElement?.textContent).toEqual(expectedOptions.join(", "));
};