From 396b1e83db0e6fffd9730deed98c7920eb180839 Mon Sep 17 00:00:00 2001 From: Peter Makowski Date: Tue, 9 Jan 2024 15:49:18 +0100 Subject: [PATCH] feat: add multi select component --- src/lib/components/FadeInDown/FadeInDown.scss | 19 ++ .../FadeInDown/FadeInDown.stories.tsx | 20 ++ .../components/FadeInDown/FadeInDown.test.tsx | 32 +++ src/lib/components/FadeInDown/FadeInDown.tsx | 28 +++ src/lib/components/FadeInDown/index.ts | 1 + .../components/MultiSelect/MultiSelect.scss | 113 +++++++++++ .../MultiSelect/MultiSelect.stories.tsx | 40 ++++ .../MultiSelect/MultiSelect.test.tsx | 91 +++++++++ .../components/MultiSelect/MultiSelect.tsx | 190 ++++++++++++++++++ src/lib/components/MultiSelect/index.ts | 1 + .../NestedFormGroup/NestedFormGroup.scss | 19 -- .../NestedFormGroup/NestedFormGroup.tsx | 10 +- src/lib/components/index.ts | 4 +- 13 files changed, 544 insertions(+), 24 deletions(-) create mode 100644 src/lib/components/FadeInDown/FadeInDown.scss create mode 100644 src/lib/components/FadeInDown/FadeInDown.stories.tsx create mode 100644 src/lib/components/FadeInDown/FadeInDown.test.tsx create mode 100644 src/lib/components/FadeInDown/FadeInDown.tsx create mode 100644 src/lib/components/FadeInDown/index.ts create mode 100644 src/lib/components/MultiSelect/MultiSelect.scss create mode 100644 src/lib/components/MultiSelect/MultiSelect.stories.tsx create mode 100644 src/lib/components/MultiSelect/MultiSelect.test.tsx create mode 100644 src/lib/components/MultiSelect/MultiSelect.tsx create mode 100644 src/lib/components/MultiSelect/index.ts diff --git a/src/lib/components/FadeInDown/FadeInDown.scss b/src/lib/components/FadeInDown/FadeInDown.scss new file mode 100644 index 0000000..ccca1dc --- /dev/null +++ b/src/lib/components/FadeInDown/FadeInDown.scss @@ -0,0 +1,19 @@ + @import "vanilla-framework"; + +.fade-in--down { + @include vf-transition(#{transform, opacity, visibility}, fast); + + &[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/lib/components/FadeInDown/FadeInDown.stories.tsx b/src/lib/components/FadeInDown/FadeInDown.stories.tsx new file mode 100644 index 0000000..33b0d9a --- /dev/null +++ b/src/lib/components/FadeInDown/FadeInDown.stories.tsx @@ -0,0 +1,20 @@ +import { Meta } from "@storybook/react"; + +import { FadeInDown } from "@/lib/components/FadeInDown"; + +const meta: Meta = { + title: "components/FadeInDown", + component: FadeInDown, + tags: ["autodocs"], + parameters: { + status: { + type: "candidate", + }, + }, +}; + +export default meta; + +export const Example = { + args: {}, +}; diff --git a/src/lib/components/FadeInDown/FadeInDown.test.tsx b/src/lib/components/FadeInDown/FadeInDown.test.tsx new file mode 100644 index 0000000..8854396 --- /dev/null +++ b/src/lib/components/FadeInDown/FadeInDown.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from "@testing-library/react"; + +import { FadeInDown } from "./FadeInDown"; + +it("renders with correct attributes", () => { + render( + +
Content
+
, + ); + + 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.queryByText("Content")).toBeInTheDocument(); + + rerender( + +
Test child
+
, + ); + + expect(screen.queryByText("Content")).not.toBeInTheDocument(); +}); diff --git a/src/lib/components/FadeInDown/FadeInDown.tsx b/src/lib/components/FadeInDown/FadeInDown.tsx new file mode 100644 index 0000000..603fdad --- /dev/null +++ b/src/lib/components/FadeInDown/FadeInDown.tsx @@ -0,0 +1,28 @@ +import { 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/lib/components/FadeInDown/index.ts b/src/lib/components/FadeInDown/index.ts new file mode 100644 index 0000000..104d29a --- /dev/null +++ b/src/lib/components/FadeInDown/index.ts @@ -0,0 +1 @@ +export * from "./FadeInDown"; diff --git a/src/lib/components/MultiSelect/MultiSelect.scss b/src/lib/components/MultiSelect/MultiSelect.scss new file mode 100644 index 0000000..be37bfd --- /dev/null +++ b/src/lib/components/MultiSelect/MultiSelect.scss @@ -0,0 +1,113 @@ +@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__input { + position: relative; + cursor: pointer; + z-index: 11; + + &.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; + + padding-top: $spv--x-small; + left: 0; + position: absolute; + right: 0; + top: calc(100% - #{$input-margin-bottom}); + z-index: 10; +} + +.multi-select__dropdown-list { + @extend %vf-list; + + margin-bottom: 0; + max-height: $dropdown-max-height; + overflow: auto; +} + +.multi-select__buttons { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + border-top: 1px solid $color-mid-light; + padding: $sph--small $sph--small 0 $sph--small; +} + +.multi-select__dropdown-header { + text-transform: uppercase; + margin-bottom: 0; + padding: $spv--x-small $sph--small; + position: relative; +} + +.multi-select__dropdown-item { + padding: 0 $sph--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; + padding: $spv--x-small $sph--small; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.is-error .multi-select__selected-list { + border-color: $color-negative; +} + +.multi-select__selected-item { + @include vf-inline-list-item; + + margin-right: 0; + + &:not(:last-child)::after { + content: ",\00a0"; + } +} + diff --git a/src/lib/components/MultiSelect/MultiSelect.stories.tsx b/src/lib/components/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 0000000..a0f60a9 --- /dev/null +++ b/src/lib/components/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; + +import { Meta } from "@storybook/react"; + +import { + MultiSelect, + MultiSelectItem, + MultiSelectProps, +} from "@/lib/components/MultiSelect/MultiSelect"; + +const Template = (props: MultiSelectProps) => { + const [selectedItems, setSelectedItems] = useState( + props.selectedItems || [], + ); + return ( + + ); +}; + +const meta: Meta = { + title: "components/MultiSelect", + component: MultiSelect, + render: Template, + tags: ["autodocs"], + parameters: {}, +}; + +export default meta; + +export const Example = { + args: { + items: Array.from({ length: 10 }, (_, i) => `Item ${i + 1}`), + selectedItems: ["Item 1", "Item 2"], + disabledItems: ["Item 1", "Item 3"], + }, +}; diff --git a/src/lib/components/MultiSelect/MultiSelect.test.tsx b/src/lib/components/MultiSelect/MultiSelect.test.tsx new file mode 100644 index 0000000..d807440 --- /dev/null +++ b/src/lib/components/MultiSelect/MultiSelect.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { vi } from "vitest"; + +import { MultiSelect } from "./MultiSelect"; + +const items = ["item one", "item two"]; + +it("shows options when opened", async () => { + render(); + + items.forEach((item) => { + expect( + screen.queryByRole("checkbox", { name: item }), + ).not.toBeInTheDocument(); + }); + + await userEvent.click(screen.getByRole("combobox")); + + items.forEach((item) => { + expect(screen.queryByRole("checkbox", { name: item })).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("listitem", { name: items[0] })).toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { name: items[0] }), + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getByRole("checkbox", { name: items[0] })).toBeInTheDocument(); +}); + +it("can select options from the dropdown", async () => { + const onItemsUpdate = vi.fn(); + render(); + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click(screen.getByLabelText(items[0])); + await waitFor(() => expect(onItemsUpdate).toHaveBeenCalledWith([items[0]])); +}); + +it("can hide the options that have been selected", async () => { + render(); + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click(screen.getByLabelText(items[0])); + expect(screen.queryByTestId("selected-option")).not.toBeInTheDocument(); +}); + +it("can remove options that have been selected", async () => { + const onItemsUpdate = vi.fn(); + render( + , + ); + expect(screen.getAllByRole("listitem", { name: /item/ })).toHaveLength(2); + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click( + within(screen.getByRole("listbox")).getByLabelText(items[0]), + ); + expect(onItemsUpdate).toHaveBeenCalledWith([items[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 dropdown header", async () => { + render( + Header} + items={items} + />, + ); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getByRole("heading", { name: "Header" })).toBeInTheDocument(); +}); diff --git a/src/lib/components/MultiSelect/MultiSelect.tsx b/src/lib/components/MultiSelect/MultiSelect.tsx new file mode 100644 index 0000000..fb263f5 --- /dev/null +++ b/src/lib/components/MultiSelect/MultiSelect.tsx @@ -0,0 +1,190 @@ +import type { ReactNode } from "react"; +import { useEffect, useId, useState } from "react"; + +import { + CheckboxInput, + Button, + Input, + useClickOutside, + useOnEscapePressed, +} from "@canonical/react-components"; + +import "./MultiSelect.scss"; +import { FadeInDown } from "@/lib/components/FadeInDown"; + +export type MultiSelectItem = 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; + header?: ReactNode; +}; + +type MultiSelectDropdownProps = { + isOpen: boolean; + items: string[]; + selectedItems: string[]; + disabledItems: string[]; + header?: ReactNode; + updateItems: (newItems: string[]) => void; +} & React.HTMLAttributes; + +export const MultiSelectDropdown: React.FC = ({ + items, + selectedItems, + disabledItems, + header, + updateItems, + isOpen, + ...props +}: MultiSelectDropdownProps) => { + return ( + +
+ {header &&
{header}
} +
    + {items.map((item) => ( +
  • + + updateItems( + selectedItems.includes(item) + ? selectedItems.filter((i) => i !== item) + : [...selectedItems, item], + ) + } + /> +
  • + ))} +
+
+ + +
+
+
+ ); +}; + +/** + * Component allowing to select multiple items from a list of options. + */ +export const MultiSelect: React.FC = ({ + disabled, + selectedItems: externalSelectedItems = [], + label, + onItemsUpdate, + placeholder = "Select items", + required = false, + items = [], + disabledItems = [], + header, +}: MultiSelectProps) => { + const wrapperRef = useClickOutside(() => { + setIsDropdownOpen(false); + }); + useOnEscapePressed(() => setIsDropdownOpen(false)); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [filter, setFilter] = useState(""); + + useEffect(() => { + if (!isDropdownOpen) { + setFilter(""); + } + }, [isDropdownOpen]); + + const [internalSelectedItems, setInternalSelectedItems] = useState( + [], + ); + const selectedItems = externalSelectedItems || internalSelectedItems; + + const updateItems = (newItems: MultiSelectItem[]) => { + const uniqueItems = Array.from(new Set(newItems)); + setInternalSelectedItems(uniqueItems); + onItemsUpdate && onItemsUpdate(uniqueItems); + }; + + const selectedElements = selectedItems.map((item) => ( +
  • + {item} +
  • + )); + + const dropdownId = useId(); + const inputId = useId(); + + return ( +
    +
    + {selectedItems.length > 0 && ( +
      + {selectedElements} +
    + )} + setFilter(e.target.value)} + onFocus={() => setIsDropdownOpen(true)} + placeholder={placeholder} + required={required} + type="text" + value={filter} + className="multi-select__input" + /> + 0 + ? items.filter((item) => item.includes(filter)) + : items + } + selectedItems={selectedItems} + disabledItems={disabledItems} + header={header} + updateItems={updateItems} + /> +
    +
    + ); +}; diff --git a/src/lib/components/MultiSelect/index.ts b/src/lib/components/MultiSelect/index.ts new file mode 100644 index 0000000..22ee6bc --- /dev/null +++ b/src/lib/components/MultiSelect/index.ts @@ -0,0 +1 @@ +export * from "./MultiSelect"; diff --git a/src/lib/components/NestedFormGroup/NestedFormGroup.scss b/src/lib/components/NestedFormGroup/NestedFormGroup.scss index b48e5f0..b0ca680 100644 --- a/src/lib/components/NestedFormGroup/NestedFormGroup.scss +++ b/src/lib/components/NestedFormGroup/NestedFormGroup.scss @@ -17,31 +17,12 @@ } } -@mixin accordion-reveal { - @include vf-transition(#{transform, opacity, visibility}, fast); - - &[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; - } -} - .p-form__nested-group { position: relative; margin-top:-(map_get($nudges, x-small)); margin-left: calc($form-tick-box-size / 2 - ($input-border-thickness / 2)); padding-left: calc($sph--large + $form-tick-box-size / 2); - @include accordion-reveal; @include vertical-line; .p-checkbox, diff --git a/src/lib/components/NestedFormGroup/NestedFormGroup.tsx b/src/lib/components/NestedFormGroup/NestedFormGroup.tsx index 99ef76c..f63ff7f 100644 --- a/src/lib/components/NestedFormGroup/NestedFormGroup.tsx +++ b/src/lib/components/NestedFormGroup/NestedFormGroup.tsx @@ -1,11 +1,15 @@ import { AriaAttributes, PropsWithChildren } from "react"; + import "./NestedFormGroup.scss"; +import { FadeInDown } from "@/lib/components/FadeInDown"; export const NestedFormGroup = ({ children, ...props }: PropsWithChildren & Pick) => ( -
    - {children} -
    + +
    + {children} +
    +
    ); diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index f7d5099..047c010 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -1,5 +1,5 @@ export * from "./NestedFormGroup"; export * from "./Pagination"; export * from "./Stepper"; - -export * from "./FileUpload"; \ No newline at end of file +export * from "./FileUpload"; +export * from "./MultiSelect";