-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
a08f01d
commit 06fe3bf
Showing
6 changed files
with
352 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./MultiSelect"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |