diff --git a/src/lib/components/MultiSelect/MultiSelect.scss b/src/lib/components/MultiSelect/MultiSelect.scss new file mode 100644 index 00000000..d3923819 --- /dev/null +++ b/src/lib/components/MultiSelect/MultiSelect.scss @@ -0,0 +1,103 @@ +@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; + + &.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; + 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__dropdown-header { + margin-bottom: 0; + padding: $spv--x-small $sph--small; + position: relative; + + &::after { + border-bottom: 1px solid $color-mid-light; + bottom: 0; + content: ""; + height: 1px; + left: 0; + position: absolute; + right: 0; + } + } + + .multi-select__dropdown-item { + @extend %vf-list-item; + @include vf-list-item-divided; + } + + .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; + } + + .is-error .multi-select__selected-list { + border-color: $color-negative; + } + + .multi-select__selected-item { + @include vf-inline-list-item; + } + + .multi-select__selected-button { + padding: 0 $sph--small 0 0 !important; + } diff --git a/src/lib/components/MultiSelect/MultiSelect.stories.tsx b/src/lib/components/MultiSelect/MultiSelect.stories.tsx new file mode 100644 index 00000000..f399ea0f --- /dev/null +++ b/src/lib/components/MultiSelect/MultiSelect.stories.tsx @@ -0,0 +1,41 @@ +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([]); + return ( + + ); +}; + +const meta: Meta = { + title: "components/MultiSelect", + component: MultiSelect, + render: Template, + tags: ["autodocs"], + parameters: { + status: { + type: "candidate", + }, + }, +}; + +export default meta; + +export const Example = { + args: { + items: ["Item 1", "Item 2", "Item 3"], + label: "Multiple select", + }, +}; diff --git a/src/lib/components/MultiSelect/MultiSelect.test.tsx b/src/lib/components/MultiSelect/MultiSelect.test.tsx new file mode 100644 index 00000000..261e0895 --- /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("doesn't show options when closed", () => { + render(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + expect(screen.queryByText(items[0])).not.toBeInTheDocument(); + expect(screen.queryByText(items[1])).not.toBeInTheDocument(); +}); + +it("shows options when opened", async () => { + render(); + await userEvent.click(screen.getByRole("combobox")); + expect(screen.getByRole("button", { name: items[0] })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: items[1] })).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", () => { + render(); + expect(screen.getByRole("button", { 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.getByRole("button", { name: 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.getByRole("button", { name: 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("button", { name: /item/ })).toHaveLength(2); + await userEvent.click(screen.getByRole("combobox")); + await userEvent.click( + within(screen.getByRole("list", { name: "selected" })).getByRole("button", { + name: items[0], + }), + ); + expect(onItemsUpdate).toHaveBeenCalledWith([items[1]]); +}); + +it("can filter option list", async () => { + render( + item.includes(filter)} + />, + ); + 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 00000000..b9665042 --- /dev/null +++ b/src/lib/components/MultiSelect/MultiSelect.tsx @@ -0,0 +1,115 @@ +import type { ReactNode } from "react"; +import { useId, useState } from "react"; + +import { Button, Input, useClickOutside } from "@canonical/react-components"; +import Field from "@canonical/react-components/dist/components/Field"; +import "./MultiSelect.scss"; + +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[]; + renderItem?: (item: MultiSelectItem) => ReactNode; + filterFunction?: (item: MultiSelectItem, filter: string) => boolean; + header?: ReactNode; +}; + +export const MultiSelect: React.FC = ({ + disabled, + error, + selectedItems = [], + help, + label, + onItemsUpdate, + placeholder = "Select items", + required = false, + items = [], + renderItem, + filterFunction, + header, + ...props +}: MultiSelectProps) => { + const wrapperRef = useClickOutside(() => + setDropdownOpen(false), + ); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [filter, setFilter] = useState(""); + + const updateItems = (newItems: MultiSelectItem[]) => { + onItemsUpdate && onItemsUpdate(newItems); + }; + + const renderedItems = items + .filter((item) => (filterFunction ? filterFunction(item, filter) : true)) + .map((item) => ( +
  • + +
  • + )); + + const selectedElements = selectedItems.map((item) => ( +
  • + +
  • + )); + + // TODO: remove once https://github.com/canonical/react-components/issues/1011 is fixed + const inputId = useId(); + + return ( +
    + +
    + {selectedItems.length > 0 && ( +
      + {selectedElements} +
    + )} + setFilter(e.target.value)} + onFocus={() => setDropdownOpen(true)} + placeholder={placeholder} + required={required} + type="text" + value={filter} + className="multi-select__input" + /> + {dropdownOpen && renderedItems.length > 0 && ( +
    + {header && ( +

    {header}

    + )} +
      {renderedItems}
    +
    + )} +
    +
    +
    + ); +}; diff --git a/src/lib/components/MultiSelect/index.ts b/src/lib/components/MultiSelect/index.ts new file mode 100644 index 00000000..22ee6bcf --- /dev/null +++ b/src/lib/components/MultiSelect/index.ts @@ -0,0 +1 @@ +export * from "./MultiSelect"; diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts index b2b75b8b..4147d82b 100644 --- a/src/lib/components/index.ts +++ b/src/lib/components/index.ts @@ -1,3 +1,4 @@ export * from "./NestedFormGroup"; export * from "./Pagination"; export * from "./Stepper"; +export * from "./MultiSelect";