From d8a13a381d44c8d6c1e8c00f780574aca75b53d6 Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Fri, 2 Feb 2024 16:26:33 +0100 Subject: [PATCH] feat: add multiselect component (#1036) --- .../MultiSelect/FadeInDown/FadeInDown.scss | 22 ++ .../FadeInDown/FadeInDown.test.tsx | 34 ++ .../MultiSelect/FadeInDown/FadeInDown.tsx | 28 ++ .../MultiSelect/FadeInDown/index.ts | 1 + src/components/MultiSelect/MultiSelect.scss | 158 +++++++++ .../MultiSelect/MultiSelect.stories.tsx | 72 ++++ .../MultiSelect/MultiSelect.test.tsx | 221 ++++++++++++ src/components/MultiSelect/MultiSelect.tsx | 320 ++++++++++++++++++ src/components/MultiSelect/index.ts | 1 + src/index.ts | 1 + 10 files changed, 858 insertions(+) create mode 100644 src/components/MultiSelect/FadeInDown/FadeInDown.scss create mode 100644 src/components/MultiSelect/FadeInDown/FadeInDown.test.tsx create mode 100644 src/components/MultiSelect/FadeInDown/FadeInDown.tsx create mode 100644 src/components/MultiSelect/FadeInDown/index.ts create mode 100644 src/components/MultiSelect/MultiSelect.scss create mode 100644 src/components/MultiSelect/MultiSelect.stories.tsx create mode 100644 src/components/MultiSelect/MultiSelect.test.tsx create mode 100644 src/components/MultiSelect/MultiSelect.tsx create mode 100644 src/components/MultiSelect/index.ts diff --git a/src/components/MultiSelect/FadeInDown/FadeInDown.scss b/src/components/MultiSelect/FadeInDown/FadeInDown.scss new file mode 100644 index 00000000..41fd629b --- /dev/null +++ b/src/components/MultiSelect/FadeInDown/FadeInDown.scss @@ -0,0 +1,22 @@ +@import "vanilla-framework"; + +.fade-in--down { + @include vf-transition(#{transform, opacity, visibility}, fast); + + position: relative; + z-index: 1; + + &[aria-hidden="true"] { + height: 0; + opacity: 0; + transform: translate3d(0, -0.5rem, 0); + visibility: hidden; + } + + &[aria-hidden="false"] { + height: auto; + opacity: 1; + transform: translate3d(0, 0, 0); + visibility: visible; + } +} diff --git a/src/components/MultiSelect/FadeInDown/FadeInDown.test.tsx b/src/components/MultiSelect/FadeInDown/FadeInDown.test.tsx new file mode 100644 index 00000000..73f21875 --- /dev/null +++ b/src/components/MultiSelect/FadeInDown/FadeInDown.test.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { FadeInDown } from "./FadeInDown"; + +it("renders with correct attributes", () => { + render( + +
Content
+
+ ); + + // eslint-disable-next-line testing-library/no-node-access + const element = screen.getByText("Content").parentElement; + expect(element).toHaveAttribute("aria-hidden", "false"); + expect(element).toHaveClass("fade-in--down test-class"); +}); + +it("hides and reveals children", () => { + const { rerender } = render( + +
Content
+
+ ); + expect(screen.getByText("Content")).toBeInTheDocument(); + + rerender( + +
Test child
+
+ ); + + expect(screen.queryByText("Content")).not.toBeInTheDocument(); +}); diff --git a/src/components/MultiSelect/FadeInDown/FadeInDown.tsx b/src/components/MultiSelect/FadeInDown/FadeInDown.tsx new file mode 100644 index 00000000..70734556 --- /dev/null +++ b/src/components/MultiSelect/FadeInDown/FadeInDown.tsx @@ -0,0 +1,28 @@ +import React, { FC, PropsWithChildren } from "react"; + +import classNames from "classnames"; +import "./FadeInDown.scss"; + +export interface FadeInDownProps extends PropsWithChildren { + isVisible: boolean; + className?: string; +} + +/** + * EXPERIMENTAL: This component is experimental and should be used internally only. + */ +export const FadeInDown: FC = ({ + children, + className, + isVisible, +}) => { + return ( +
+ {children} +
+ ); +}; diff --git a/src/components/MultiSelect/FadeInDown/index.ts b/src/components/MultiSelect/FadeInDown/index.ts new file mode 100644 index 00000000..104d29a3 --- /dev/null +++ b/src/components/MultiSelect/FadeInDown/index.ts @@ -0,0 +1 @@ +export * from "./FadeInDown"; diff --git a/src/components/MultiSelect/MultiSelect.scss b/src/components/MultiSelect/MultiSelect.scss new file mode 100644 index 00000000..fbb22026 --- /dev/null +++ b/src/components/MultiSelect/MultiSelect.scss @@ -0,0 +1,158 @@ +@use "sass:map"; +@import "vanilla-framework"; +@include vf-base; +@include vf-p-lists; + +$dropdown-max-height: 20rem; + +.multi-select { + position: relative; +} + +.multi-select .p-form-validation__message { + margin-top: 0; +} + +.multi-select__condensed-text { + margin-right: $sph--large + $sph--x-small; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.multi-select__input { + cursor: pointer; + position: relative; + + &.items-selected { + border-top: 0; + box-shadow: none; + top: -#{$border-radius}; + } + + &[disabled], + &[disabled="disabled"] { + opacity: 1; + } +} + +.multi-select__dropdown { + @extend %vf-bg--x-light; + @extend %vf-has-box-shadow; + left: 0; + max-height: $dropdown-max-height; + overflow: auto; + + padding-top: $spv--small; + position: absolute; + right: 0; + top: calc(100% - #{$input-margin-bottom}); +} + +.multi-select__dropdown--side-by-side { + display: flex; + flex-wrap: wrap; +} + +.multi-select__group { + flex: 1 0 auto; +} + +.multi-select__dropdown-list { + @extend %vf-list; + + margin-bottom: $sph--x-small; +} + +.multi-select__footer { + background: white; + border-top: 1px solid $color-mid-light; + bottom: 0; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: $sph--small $sph--large 0 $sph--large; + position: sticky; +} + +.multi-select__dropdown-header { + font-size: #{map.get($font-sizes, small)}rem; + letter-spacing: #{map.get($font-sizes, small)}px; + margin-bottom: 0; + padding: $spv--x-small $sph--large; + position: relative; + text-transform: uppercase; +} + +.multi-select__dropdown-item { + padding: 0 $sph--large; + + .p-checkbox { + padding-top: $sph--x-small; + } + + &, + .p-checkbox { + width: 100%; + } +} + +.multi-select__dropdown-item-description { + @extend %small-text; + + color: $color-mid-dark; +} + +.multi-select__dropdown-button { + border: 0; + margin-bottom: 0; + padding-left: $sph--small; + padding-right: $sph--small; + text-align: left; + width: 100%; +} + +.multi-select__selected-list { + background-color: $colors--light-theme--background-inputs; + border-bottom: 0; + margin: 0; + overflow: hidden; + padding: $spv--x-small $sph--small; + text-overflow: ellipsis; + white-space: nowrap; +} + +.multi-select__select-button { + @extend %vf-input-elements; + align-items: center; + display: inline-flex; + height: 2.5rem; + justify-content: space-between; + overflow: auto; + + position: relative; + z-index: 0; + + &::after { + content: ""; + margin-left: $sph--large; + position: absolute; + right: $sph--small; + top: 50%; + transform: translateY(-50%) rotate(-180deg); + + @extend %icon; + @include vf-icon-chevron($color-mid-dark); + @include vf-transition($property: transform, $duration: fast); + } + + &[aria-expanded="true"] { + background-color: $colors--light-theme--background-hover; + } + + &[aria-expanded="false"] { + &::after { + transform: translateY(-50%) rotate(0); + } + } +} diff --git a/src/components/MultiSelect/MultiSelect.stories.tsx b/src/components/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 00000000..315d7484 --- /dev/null +++ b/src/components/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import { useState } from "react"; + +import { Meta } from "@storybook/react"; + +import { MultiSelect, MultiSelectItem, MultiSelectProps } from "./MultiSelect"; + +const Template = (props: MultiSelectProps) => { + const [selectedItems, setSelectedItems] = useState( + props.selectedItems || [] + ); + return ( + + ); +}; + +const meta: Meta = { + title: "MultiSelect", + component: MultiSelect, + render: Template, + tags: ["autodocs"], + parameters: {}, +}; + +export default meta; + +export const CondensedExample = { + args: { + items: [ + ...Array.from({ length: 26 }, (_, i) => ({ + label: `${String.fromCharCode(i + 65)}`, + value: `${String.fromCharCode(i + 65)}`, + })), + ...Array.from({ length: 26 }, (_, i) => ({ + label: `Item ${i + 1}`, + value: i + 1, + })), + ], + selectedItems: [ + { label: "A", value: "A" }, + { label: "Item 2", value: 2 }, + ], + variant: "condensed", + }, +}; + +export const SearchExample = { + args: { + ...CondensedExample.args, + variant: "search", + items: [ + ...CondensedExample.args.items.map((item, i) => ({ + ...item, + group: i % 2 === 0 ? "Group 1" : "Group 2", + })), + ], + }, +}; + +export const WithDisabledItems = { + args: { + ...CondensedExample.args, + disabledItems: [ + { label: "Item 1", value: 1 }, + { label: "Item 2", value: 2 }, + ], + }, +}; diff --git a/src/components/MultiSelect/MultiSelect.test.tsx b/src/components/MultiSelect/MultiSelect.test.tsx new file mode 100644 index 00000000..59f0b7ef --- /dev/null +++ b/src/components/MultiSelect/MultiSelect.test.tsx @@ -0,0 +1,221 @@ +import React from "react"; +import { render, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; + +import { MultiSelect } from "./MultiSelect"; + +const items = [ + { label: "item one", value: 1 }, + { label: "item two", value: 2 }, + { label: "other", value: 3 }, +]; + +it("shows options when opened", async () => { + render(); + + items.forEach((item) => { + expect( + screen.queryByRole("checkbox", { name: item.label }) + ).not.toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("combobox")); + + items.forEach((item) => { + expect( + screen.getByRole("checkbox", { name: item.label }) + ).toBeInTheDocument(); + }); +}); + +it("opens the dropdown when the combobox is clicked", async () => { + render(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); +}); + +it("can have some options preselected", async () => { + render( + + ); + expect(screen.getByRole("combobox")).toHaveTextContent(items[0].label); + expect( + screen.queryByRole("checkbox", { name: items[0].label }) + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("combobox")); + expect( + screen.getByRole("checkbox", { name: items[0].label }) + ).toBeInTheDocument(); +}); + +it("can select options from the dropdown", async () => { + const onItemsUpdate = jest.fn(); + render(); + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click(screen.getByLabelText(items[0].label)); + await waitFor(() => expect(onItemsUpdate).toHaveBeenCalledWith([items[0]])); +}); + +it("can remove options that have been selected", async () => { + const onItemsUpdate = jest.fn(); + render( + + ); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + await userEvent.click( + within(screen.getByRole("listbox")).getByLabelText(items[0].label) + ); + expect(onItemsUpdate).toHaveBeenCalledWith(items.slice(1)); +}); + +it("can filter option list", async () => { + render(); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getAllByRole("listitem")).toHaveLength(3); + await userEvent.type(screen.getByRole("combobox"), "item"); + await waitFor(() => expect(screen.getAllByRole("listitem")).toHaveLength(2)); +}); + +it("can display a custom dropdown header and footer", async () => { + render( + custom header button} + dropdownFooter={} + items={items} + /> + ); + await userEvent.click(screen.getByRole("combobox")); + expect( + screen.getByRole("button", { name: "custom header button" }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "custom footer button" }) + ).toBeInTheDocument(); +}); + +it("selects all items and clears selection when respective buttons are clicked", async () => { + const onItemsUpdate = jest.fn(); + render( + + ); + await userEvent.click(screen.getByRole("combobox")); + await userEvent.type(screen.getByRole("combobox"), "item"); + await userEvent.click(screen.getByRole("button", { name: /select all/i })); + expect(onItemsUpdate).toHaveBeenCalledWith(items); + await userEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(onItemsUpdate).toHaveBeenCalledWith([]); +}); + +it("closes the dropdown when clicking outside of the dropdown", async () => { + render(); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + await userEvent.click(document.body); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); +}); + +it("updates text in the input field if something is selected", async () => { + render( + + ); + expect(screen.getByRole("combobox")).toHaveTextContent(items[0].label); +}); + +it("can have one or more sections with titles", async () => { + const itemsWithGroup = [ + { label: "item one", value: 1, group: "Group 1" }, + { label: "item two", value: 2, group: "Group 2" }, + { label: "other", value: 3, group: "Group 1" }, + ]; + + render(); + await userEvent.click(screen.getByRole("combobox")); + + itemsWithGroup.forEach((item) => { + expect( + screen.getByRole("heading", { name: item.group }) + ).toBeInTheDocument(); + expect(screen.getByRole("list", { name: item.group })).toBeInTheDocument(); + }); +}); + +it("sorts grouped options alphabetically", async () => { + const itemsUnsorted = [ + { label: "item B", value: 2, group: "Group 1" }, + { label: "item A", value: 1, group: "Group 1" }, + { label: "other B", value: 3, group: "Group 2" }, + { label: "other A", value: 4, group: "Group 2" }, + ]; + + render(); + await userEvent.click(screen.getByRole("combobox")); + + const checkGroupOrder = async ( + groupName: string, + expectedLabels: string[] + ) => { + const group = screen.getByRole("list", { name: groupName }); + await waitFor(() => + within(group) + .getAllByRole("listitem") + .forEach((item, index) => + expect(item).toHaveTextContent(expectedLabels[index]) + ) + ); + }; + + checkGroupOrder("Group 1", ["item A", "item B"]); + checkGroupOrder("Group 2", ["other A", "other B"]); +}); + +it("hides group title when no items match the search query", async () => { + const itemsWithGroup = [ + { label: "item one", value: 1, group: "Group 1" }, + { label: "item two", value: 2, group: "Group 2" }, + { label: "other", value: 3, group: "Group 1" }, + ]; + + render(); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getByRole("heading", { name: "Group 1" })).toBeInTheDocument(); + + await userEvent.type(screen.getByRole("combobox"), "item two"); + expect( + screen.queryByRole("heading", { name: "Group 1" }) + ).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Group 2" })).toBeInTheDocument(); +}); + +it("displays previously selected items at the top of the list in a sorted order", async () => { + const items = [ + { label: "item two", value: 2 }, + { label: "item one", value: 1 }, + ]; + const unSortedSelectedItems = [items[1], items[0]]; + + render(); + + await userEvent.click(screen.getByRole("combobox")); + + const listItems = screen.getAllByRole("listitem"); + + await waitFor(() => { + expect(listItems[0]).toHaveTextContent("item one"); + }); + expect(listItems[1]).toHaveTextContent("item two"); +}); + +it("opens and closes the dropdown on click", async () => { + render(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); +}); diff --git a/src/components/MultiSelect/MultiSelect.tsx b/src/components/MultiSelect/MultiSelect.tsx new file mode 100644 index 00000000..753e4f32 --- /dev/null +++ b/src/components/MultiSelect/MultiSelect.tsx @@ -0,0 +1,320 @@ +import type { ReactNode } from "react"; +import React, { useEffect, useId, useMemo, useState } from "react"; + +import "./MultiSelect.scss"; +import { + Button, + CheckboxInput, + SearchBox, + useClickOutside, + useOnEscapePressed, +} from "../../index"; + +import { FadeInDown } from "./FadeInDown"; + +export type MultiSelectItem = { + label: string; + value: string | number; + group?: string; +}; + +export type MultiSelectProps = { + disabled?: boolean; + error?: string; + selectedItems?: MultiSelectItem[]; + help?: string; + label?: string | null; + onItemsUpdate?: (items: MultiSelectItem[]) => void; + placeholder?: string; + required?: boolean; + items: MultiSelectItem[]; + disabledItems?: MultiSelectItem[]; + renderItem?: (item: MultiSelectItem) => ReactNode; + dropdownHeader?: ReactNode; + dropdownFooter?: ReactNode; + variant?: "condensed" | "search"; +}; + +type ValueSet = Set; +type GroupFn = ( + items: Parameters[0] +) => ReturnType; +type SortFn = typeof sortAlphabetically; +type MultiSelectDropdownProps = { + isOpen: boolean; + items: MultiSelectItem[]; + selectedItems: MultiSelectItem[]; + disabledItems: MultiSelectItem[]; + header?: ReactNode; + updateItems: (newItems: MultiSelectItem[]) => void; + footer?: ReactNode; + groupFn?: GroupFn; + sortFn?: SortFn; + shouldPinSelectedItems?: boolean; +} & React.HTMLAttributes; + +const sortAlphabetically = (a: MultiSelectItem, b: MultiSelectItem) => { + return a.label.localeCompare(b.label, "en", { numeric: true }); +}; + +const createSortSelectedItems = + (previouslySelectedItemValues: ValueSet) => + (a: MultiSelectItem, b: MultiSelectItem) => { + if (previouslySelectedItemValues) { + const aIsPreviouslySelected = previouslySelectedItemValues.has(a.value); + const bIsPreviouslySelected = previouslySelectedItemValues.has(b.value); + if (aIsPreviouslySelected && !bIsPreviouslySelected) return -1; + if (!aIsPreviouslySelected && bIsPreviouslySelected) return 1; + } + return 0; + }; + +const getGroupedItems = (items: MultiSelectItem[]) => { + const groups = new Map(); + + items.forEach((item) => { + const group = item.group || "Ungrouped"; + const groupItems = groups.get(group) || []; + groupItems.push(item); + groups.set(group, groupItems); + }); + + return Array.from(groups, ([group, items]) => ({ group, items })); +}; + +export const MultiSelectDropdown: React.FC = ({ + items, + selectedItems, + disabledItems, + header, + updateItems, + isOpen, + footer, + sortFn = sortAlphabetically, + groupFn = getGroupedItems, + ...props +}: MultiSelectDropdownProps) => { + const selectedItemValues = useMemo( + () => new Set(selectedItems.map((item) => item.value)), + [selectedItems] + ); + const disabledItemValues = useMemo( + () => new Set(disabledItems.map((item) => item.value)), + [disabledItems] + ); + const [previouslySelectedItemValues, setPreviouslySelectedItemValues] = + useState(new Set(selectedItemValues)); + + useEffect(() => { + if (isOpen) { + setPreviouslySelectedItemValues(new Set(selectedItemValues)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + const hasGroup = useMemo(() => items.some((item) => item.group), [items]); + const groupedItems = useMemo( + () => (hasGroup ? groupFn(items) : [{ group: "Ungrouped", items }]), + // eslint-disable-next-line react-hooks/exhaustive-deps + [items, groupFn] + ); + const handleOnChange = (event: React.ChangeEvent) => { + const { checked, value } = event.target; + const foundItem = items.find((item) => `${item.value}` === value); + if (foundItem) { + const newSelectedItems = checked + ? [...selectedItems, foundItem] + : selectedItems.filter((item) => `${item.value}` !== value) ?? []; + updateItems(newSelectedItems); + } + }; + + return ( + +
+ {header ? header : null} + {groupedItems.map(({ group, items }) => ( +
+ {hasGroup ? ( +
{group}
+ ) : null} +
    + {items + .sort(sortFn) + .sort(createSortSelectedItems(previouslySelectedItemValues)) + .map((item) => ( +
  • + +
  • + ))} +
+
+ ))} + {footer ?
{footer}
: null} +
+
+ ); +}; + +/** + * Component allowing to select multiple items from a list of options. + * + * `MultiSelectDropdown` displays the dropdown with options which are grouped and sorted alphabetically. + * `SearchBox` or `Button` is used to trigger the dropdown depending on the variant. + */ +export const MultiSelect: React.FC = ({ + disabled, + selectedItems: externalSelectedItems = [], + label, + onItemsUpdate, + placeholder, + required = false, + items = [], + disabledItems = [], + dropdownHeader, + dropdownFooter, + variant = "search", +}: MultiSelectProps) => { + const wrapperRef = useClickOutside(() => { + setIsDropdownOpen(false); + setFilter(""); + }); + useOnEscapePressed(() => { + setIsDropdownOpen(false); + setFilter(""); + }); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [filter, setFilter] = useState(""); + + useEffect(() => { + if (!isDropdownOpen) { + setFilter(""); + } + }, [isDropdownOpen]); + + const [internalSelectedItems, setInternalSelectedItems] = useState< + MultiSelectItem[] + >([]); + const selectedItems = externalSelectedItems || internalSelectedItems; + + const updateItems = (newItems: MultiSelectItem[]) => { + const uniqueItems = Array.from(new Set(newItems)); + setInternalSelectedItems(uniqueItems); + onItemsUpdate && onItemsUpdate(uniqueItems); + }; + + const dropdownId = useId(); + const inputId = useId(); + const selectedItemsLabel = selectedItems + .filter((selectedItem) => + items.some((item) => item.value === selectedItem.value) + ) + .map((el) => el.label) + .join(", "); + return ( +
+
+ {variant === "search" ? ( + { + setFilter(value); + // reopen if dropdown has been closed via ESC + setIsDropdownOpen(true); + }} + onFocus={() => setIsDropdownOpen(true)} + placeholder={placeholder ?? "Search"} + required={required} + type="text" + value={filter} + className="multi-select__input" + /> + ) : ( + + )} + 0 + ? items.filter((item) => + item.label.toLowerCase().includes(filter.toLowerCase()) + ) + : items + } + selectedItems={selectedItems} + disabledItems={disabledItems} + header={dropdownHeader} + updateItems={updateItems} + footer={ + dropdownFooter ? ( + dropdownFooter + ) : ( + <> + + + + ) + } + /> +
+
+ ); +}; diff --git a/src/components/MultiSelect/index.ts b/src/components/MultiSelect/index.ts new file mode 100644 index 00000000..22ee6bcf --- /dev/null +++ b/src/components/MultiSelect/index.ts @@ -0,0 +1 @@ +export * from "./MultiSelect"; diff --git a/src/index.ts b/src/index.ts index ad41e8f0..161c5955 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ export { default as MainTable } from "./components/MainTable"; export { default as ModularTable } from "./components/ModularTable"; export { default as Navigation } from "./components/Navigation"; export { default as Modal } from "./components/Modal"; +export * from "./components/MultiSelect"; export { default as Notification, NotificationSeverity,