Skip to content

Commit

Permalink
feat: add multi select component
Browse files Browse the repository at this point in the history
  • Loading branch information
petermakowski committed Jan 9, 2024
1 parent a08f01d commit 06fe3bf
Show file tree
Hide file tree
Showing 6 changed files with 352 additions and 0 deletions.
103 changes: 103 additions & 0 deletions src/lib/components/MultiSelect/MultiSelect.scss
Original file line number Diff line number Diff line change
@@ -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;
}
41 changes: 41 additions & 0 deletions src/lib/components/MultiSelect/MultiSelect.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<MultiSelectItem[]>([]);
return (
<MultiSelect
{...props}
selectedItems={selectedItems}
onItemsUpdate={setSelectedItems}
/>
);
};

const meta: Meta<typeof MultiSelect> = {
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",
},
};
91 changes: 91 additions & 0 deletions src/lib/components/MultiSelect/MultiSelect.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MultiSelect items={items} />);
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(<MultiSelect items={items} />);
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(<MultiSelect items={items} />);
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("combobox"));
expect(screen.getByRole("listbox")).toBeInTheDocument();
});

it("can have some options preselected", () => {
render(<MultiSelect items={items} selectedItems={[items[0]]} />);
expect(screen.getByRole("button", { name: items[0] })).toBeInTheDocument();
});

it("can select options from the dropdown", async () => {
const onItemsUpdate = vi.fn();
render(<MultiSelect items={items} onItemsUpdate={onItemsUpdate} />);
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(<MultiSelect items={items} />);
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(
<MultiSelect
items={items}
selectedItems={items}
onItemsUpdate={onItemsUpdate}
/>,
);
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(
<MultiSelect
items={[...items, "other"]}
filterFunction={(item, filter) => 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(
<MultiSelect
header={<span data-testid="dropdown-header">Header</span>}
items={items}
/>,
);
await userEvent.click(screen.getByRole("combobox"));
expect(screen.getByRole("heading", { name: "Header" })).toBeInTheDocument();
});
115 changes: 115 additions & 0 deletions src/lib/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -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<MultiSelectProps> = ({
disabled,
error,
selectedItems = [],
help,
label,
onItemsUpdate,
placeholder = "Select items",
required = false,
items = [],
renderItem,
filterFunction,
header,
...props
}: MultiSelectProps) => {
const wrapperRef = useClickOutside<HTMLDivElement>(() =>
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) => (
<li key={item} className="multi-select__dropdown-item">
<Button
appearance="base"
onClick={() => updateItems([...selectedItems, item])}
type="button"
className="multi-select__dropdown-button"
>
{renderItem ? renderItem(item) : item}
</Button>
</li>
));

const selectedElements = selectedItems.map((item) => (
<li key={item} className="multi-select__selected-item">
<Button
appearance="base"
onClick={() => updateItems(selectedItems.filter((i) => i !== item))}
type="button"
className="multi-select__selected-button"
>
{item}
</Button>
</li>
));

// TODO: remove once https://github.com/canonical/react-components/issues/1011 is fixed
const inputId = useId();

return (
<div ref={wrapperRef}>
<Field error={error} help={help} {...props}>
<div className="multi-select">
{selectedItems.length > 0 && (
<ul className="multi-select__selected-list" aria-label="selected">
{selectedElements}
</ul>
)}
<Input
id={inputId}
role="combobox"
label={label}
disabled={disabled}
onChange={(e) => setFilter(e.target.value)}
onFocus={() => setDropdownOpen(true)}
placeholder={placeholder}
required={required}
type="text"
value={filter}
className="multi-select__input"
/>
{dropdownOpen && renderedItems.length > 0 && (
<div className="multi-select__dropdown" role="listbox">
{header && (
<h4 className="multi-select__dropdown-header">{header}</h4>
)}
<ul className="multi-select__dropdown-list">{renderedItems}</ul>
</div>
)}
</div>
</Field>
</div>
);
};
1 change: 1 addition & 0 deletions src/lib/components/MultiSelect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./MultiSelect";
1 change: 1 addition & 0 deletions src/lib/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./NestedFormGroup";
export * from "./Pagination";
export * from "./Stepper";
export * from "./MultiSelect";

0 comments on commit 06fe3bf

Please sign in to comment.