From 94b32be5d3dd022b3e1126bd5b23995361d560bd Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Tue, 21 Nov 2023 16:51:32 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(react)=20add=20monoline=20props=20to?= =?UTF-8?q?=20multi=20select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/seven-shirts-divide.md | 5 + .../src/components/Forms/Select/_index.scss | 63 +++++-- .../src/components/Forms/Select/index.tsx | 24 +-- .../components/Forms/Select/mono-common.tsx | 4 + .../components/Forms/Select/multi-common.tsx | 165 +++++------------- .../components/Forms/Select/multi-menu.tsx | 100 +++++++++++ .../Forms/Select/multi-searchable.tsx | 1 + .../Forms/Select/multi-selected-items.tsx | 63 +++++++ .../components/Forms/Select/multi-simple.tsx | 45 ++++- .../src/components/Forms/Select/multi.mdx | 12 ++ .../components/Forms/Select/multi.spec.tsx | 134 ++++++++++++++ .../components/Forms/Select/multi.stories.tsx | 11 ++ .../src/components/Forms/Select/multi.tsx | 9 +- .../components/Forms/Select/test-utils.tsx | 5 + 14 files changed, 490 insertions(+), 151 deletions(-) create mode 100644 .changeset/seven-shirts-divide.md create mode 100644 packages/react/src/components/Forms/Select/multi-menu.tsx create mode 100644 packages/react/src/components/Forms/Select/multi-selected-items.tsx diff --git a/.changeset/seven-shirts-divide.md b/.changeset/seven-shirts-divide.md new file mode 100644 index 0000000..8a2dd50 --- /dev/null +++ b/.changeset/seven-shirts-divide.md @@ -0,0 +1,5 @@ +--- +"@openfun/cunningham-react": minor +--- + +add monoline props to multi select diff --git a/packages/react/src/components/Forms/Select/_index.scss b/packages/react/src/components/Forms/Select/_index.scss index ff5d989..987911c 100644 --- a/packages/react/src/components/Forms/Select/_index.scss +++ b/packages/react/src/components/Forms/Select/_index.scss @@ -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; - } } diff --git a/packages/react/src/components/Forms/Select/index.tsx b/packages/react/src/components/Forms/Select/index.tsx index 56dbb04..1fd78fe 100644 --- a/packages/react/src/components/Forms/Select/index.tsx +++ b/packages/react/src/components/Forms/Select/index.tsx @@ -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 & { + 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((props, ref) => { if (props.defaultValue && props.value) { diff --git a/packages/react/src/components/Forms/Select/mono-common.tsx b/packages/react/src/components/Forms/Select/mono-common.tsx index ee2848a..68064e0 100644 --- a/packages/react/src/components/Forms/Select/mono-common.tsx +++ b/packages/react/src/components/Forms/Select/mono-common.tsx @@ -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(); diff --git a/packages/react/src/components/Forms/Select/multi-common.tsx b/packages/react/src/components/Forms/Select/multi-common.tsx index fadcd74..844f208 100644 --- a/packages/react/src/components/Forms/Select/multi-common.tsx +++ b/packages/react/src/components/Forms/Select/multi-common.tsx @@ -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 & { 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; }; useMultipleSelectionReturn: ReturnType>; -}) => { +} + +export const SelectMultiAux = ({ children, ...props }: SelectMultiAuxProps) => { const { t } = useCunningham(); - const labelProps = downshiftReturn.getLabelProps(); + const labelProps = props.downshiftReturn.getLabelProps(); return (
0, + "c__select--disabled": props.disabled, + "c__select--populated": props.selectedItems.length > 0, + "c__select--monoline": props.monoline, + "c__select--multiline": !props.monoline, }, )} >
- {selectedItems.map((selectedItem, index) => ( + {props.selectedItems.map((selectedItem, index) => ( ))}
- {clearable && !disabled && selectedItems.length > 0 && ( - <> -
- {selectedItems.map((selectedItemForRender, index) => { - return ( -
- -
- ); - })} + {children}
-
-
    - {downshiftReturn.isOpen && ( - <> - {options.map((option, index) => { - const isActive = index === downshiftReturn.highlightedIndex; - return ( -
  • - {renderOption(option)} -
  • - ); - })} - {options.length === 0 && ( -
  • - {t("components.forms.select.menu_empty_placeholder")} -
  • - )} - - )} -
-
+
); diff --git a/packages/react/src/components/Forms/Select/multi-menu.tsx b/packages/react/src/components/Forms/Select/multi-menu.tsx new file mode 100644 index 0000000..5b4c291 --- /dev/null +++ b/packages/react/src/components/Forms/Select/multi-menu.tsx @@ -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 ( +
+
    + {props.downshiftReturn.isOpen && ( + <> + {props.options.map((option, index) => ( + + ))} + {props.options.length === 0 && ( +
  • + {t("components.forms.select.menu_empty_placeholder")} +
  • + )} + + )} +
+
+ ); +}; + +type MenuItemProps = SelectMultiAuxProps & { option: Option; index: number }; + +const MenuItem = (props: MenuItemProps) => { + if (props.menuOptionsStyle === "plain") { + return ; + } + if (props.menuOptionsStyle === "checkbox") { + return ; + } + throw new Error("Unknown menuOptionsStyle"); +}; + +const MenuItemPlain = ({ option, index, ...props }: MenuItemProps) => { + const isHighlighted = + index === props.downshiftReturn.highlightedIndex || option.highlighted; + + return ( +
  • + {renderOption(option)} +
  • + ); +}; + +const MenuItemCheckbox = ({ option, index, ...props }: MenuItemProps) => { + return ( +
  • + +
  • + ); +}; diff --git a/packages/react/src/components/Forms/Select/multi-searchable.tsx b/packages/react/src/components/Forms/Select/multi-searchable.tsx index b9287db..6de5bcc 100644 --- a/packages/react/src/components/Forms/Select/multi-searchable.tsx +++ b/packages/react/src/components/Forms/Select/multi-searchable.tsx @@ -127,6 +127,7 @@ export const SelectMultiSearchable = forwardRef( return ( { + if (props.selectedItemsStyle === "pills") { + return ; + } + if (props.selectedItemsStyle === "text") { + return ; + } + throw new Error("Unknown selectedItemsStyle"); +}; + +const SelectedItemsChips = ({ + selectedItems, + selectedItemsStyle, + disabled, + useMultipleSelectionReturn, + ...props +}: SelectMultiAuxProps) => { + const { t } = useCunningham(); + return selectedItems.map((selectedItemForRender, index) => { + return ( +
    + +
    + ); + }); +}; + +const SelectedItemsText = ({ selectedItems }: SelectMultiAuxProps) => { + return selectedItems.map((item) => optionToString(item)).join(", "); +}; diff --git a/packages/react/src/components/Forms/Select/multi-simple.tsx b/packages/react/src/components/Forms/Select/multi-simple.tsx index 49d8a59..b57d37c 100644 --- a/packages/react/src/components/Forms/Select/multi-simple.tsx +++ b/packages/react/src/components/Forms/Select/multi-simple.tsx @@ -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( (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( 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( 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( options={options} labelAsPlaceholder={props.selectedItems.length === 0} selectedItems={props.selectedItems} + selectedItemsStyle={props.monoline ? "text" : "pills"} + menuOptionsStyle={props.monoline ? "checkbox" : "plain"} downshiftReturn={{ ...downshiftReturn, wrapperProps: { diff --git a/packages/react/src/components/Forms/Select/multi.mdx b/packages/react/src/components/Forms/Select/multi.mdx index 840bb05..187f8a6 100644 --- a/packages/react/src/components/Forms/Select/multi.mdx +++ b/packages/react/src/components/Forms/Select/multi.mdx @@ -88,6 +88,18 @@ For some reasons you might want to hide the label of the Multi-Select. You can d +## 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. + + + + + ## Custom render option You can give customize the look of the options by providing `render` callback. diff --git a/packages/react/src/components/Forms/Select/multi.spec.tsx b/packages/react/src/components/Forms/Select/multi.spec.tsx index 01e50b4..1b06d96 100644 --- a/packages/react/src/components/Forms/Select/multi.spec.tsx +++ b/packages/react/src/components/Forms/Select/multi.spec.tsx @@ -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(" + , + ); + 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 () => { diff --git a/packages/react/src/components/Forms/Select/multi.stories.tsx b/packages/react/src/components/Forms/Select/multi.stories.tsx index 7ce07fa..63fbae1 100644 --- a/packages/react/src/components/Forms/Select/multi.stories.tsx +++ b/packages/react/src/components/Forms/Select/multi.stories.tsx @@ -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: { diff --git a/packages/react/src/components/Forms/Select/multi.tsx b/packages/react/src/components/Forms/Select/multi.tsx index 32b458c..678435e 100644 --- a/packages/react/src/components/Forms/Select/multi.tsx +++ b/packages/react/src/components/Forms/Select/multi.tsx @@ -44,11 +44,17 @@ export const SelectMulti = forwardRef( newSelectedItems, ) => { setSelectedItems(newSelectedItems); - // props.onSelectedItemsChange?.(newSelectedItems); + }; + + const defaultProps: Partial = { + selectedItemsStyle: "pills", + menuOptionsStyle: "plain", + clearable: true, }; return props.searchable ? ( ( /> ) : ( { }); expect(actualOptions).toEqual(expectedOptions); }; + +export const expectSelectedOptionsText = (expectedOptions: string[]) => { + const valueElement = document.querySelector(".c__select__inner__value"); + expect(valueElement?.textContent).toEqual(expectedOptions.join(", ")); +};