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(", ")); +};