Skip to content

Commit 9ee53e5

Browse files
chore(site): refactor filter component to be more extendable (coder#13688)
1 parent 21a923a commit 9ee53e5

20 files changed

+790
-661
lines changed

site/src/components/Filter/OptionItem.stories.tsx

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { action } from "@storybook/addon-actions";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { userEvent, within, expect } from "@storybook/test";
4+
import { useState } from "react";
5+
import { UserAvatar } from "components/UserAvatar/UserAvatar";
6+
import { withDesktopViewport } from "testHelpers/storybook";
7+
import {
8+
SelectFilter,
9+
SelectFilterSearch,
10+
type SelectFilterOption,
11+
} from "./SelectFilter";
12+
13+
const options: SelectFilterOption[] = Array.from({ length: 50 }, (_, i) => ({
14+
startIcon: <UserAvatar username={`username ${i + 1}`} size="xs" />,
15+
label: `Option ${i + 1}`,
16+
value: `option-${i + 1}`,
17+
}));
18+
19+
const meta: Meta<typeof SelectFilter> = {
20+
title: "components/SelectFilter",
21+
component: SelectFilter,
22+
args: {
23+
options,
24+
placeholder: "All options",
25+
},
26+
decorators: [withDesktopViewport],
27+
render: function SelectFilterWithState(args) {
28+
const [selectedOption, setSelectedOption] = useState<
29+
SelectFilterOption | undefined
30+
>(args.selectedOption);
31+
return (
32+
<SelectFilter
33+
{...args}
34+
selectedOption={selectedOption}
35+
onSelect={setSelectedOption}
36+
/>
37+
);
38+
},
39+
play: async ({ canvasElement }) => {
40+
const canvas = within(canvasElement);
41+
const button = canvas.getByRole("button");
42+
await userEvent.click(button);
43+
},
44+
};
45+
46+
export default meta;
47+
type Story = StoryObj<typeof SelectFilter>;
48+
49+
export const Closed: Story = {
50+
play: () => {},
51+
};
52+
53+
export const Open: Story = {};
54+
55+
export const Selected: Story = {
56+
args: {
57+
selectedOption: options[25],
58+
},
59+
};
60+
61+
export const WithSearch: Story = {
62+
args: {
63+
selectedOption: options[25],
64+
selectFilterSearch: (
65+
<SelectFilterSearch
66+
value=""
67+
onChange={action("onSearch")}
68+
placeholder="Search options..."
69+
/>
70+
),
71+
},
72+
};
73+
74+
export const LoadingOptions: Story = {
75+
args: {
76+
options: undefined,
77+
},
78+
};
79+
80+
export const NoOptionsFound: Story = {
81+
args: {
82+
options: [],
83+
},
84+
};
85+
86+
export const SelectingOption: Story = {
87+
play: async ({ canvasElement }) => {
88+
const canvas = within(canvasElement);
89+
const button = canvas.getByRole("button");
90+
await userEvent.click(button);
91+
const option = canvas.getByText("Option 25");
92+
await userEvent.click(option);
93+
await expect(button).toHaveTextContent("Option 25");
94+
},
95+
};
96+
97+
export const UnselectingOption: Story = {
98+
args: {
99+
selectedOption: options[25],
100+
},
101+
play: async ({ canvasElement }) => {
102+
const canvas = within(canvasElement);
103+
const button = canvas.getByRole("button");
104+
await userEvent.click(button);
105+
const menu = canvasElement.querySelector<HTMLElement>("[role=menu]")!;
106+
const option = within(menu).getByText("Option 26");
107+
await userEvent.click(option);
108+
await expect(button).toHaveTextContent("All options");
109+
},
110+
};
111+
112+
export const SearchingOption: Story = {
113+
render: function SelectFilterWithSearch(args) {
114+
const [selectedOption, setSelectedOption] = useState<
115+
SelectFilterOption | undefined
116+
>(args.selectedOption);
117+
const [search, setSearch] = useState("");
118+
const visibleOptions = options.filter((option) =>
119+
option.value.includes(search),
120+
);
121+
122+
return (
123+
<SelectFilter
124+
{...args}
125+
selectedOption={selectedOption}
126+
onSelect={setSelectedOption}
127+
options={visibleOptions}
128+
selectFilterSearch={
129+
<SelectFilterSearch
130+
value={search}
131+
onChange={setSearch}
132+
placeholder="Search options..."
133+
inputProps={{ "aria-label": "Search options" }}
134+
/>
135+
}
136+
/>
137+
);
138+
},
139+
play: async ({ canvasElement }) => {
140+
const canvas = within(canvasElement);
141+
const button = canvas.getByRole("button");
142+
await userEvent.click(button);
143+
const search = canvas.getByLabelText("Search options");
144+
await userEvent.type(search, "option-2");
145+
},
146+
};
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { useState, type FC, type ReactNode } from "react";
2+
import { Loader } from "components/Loader/Loader";
3+
import {
4+
SelectMenu,
5+
SelectMenuTrigger,
6+
SelectMenuButton,
7+
SelectMenuContent,
8+
SelectMenuSearch,
9+
SelectMenuList,
10+
SelectMenuItem,
11+
SelectMenuIcon,
12+
} from "components/SelectMenu/SelectMenu";
13+
14+
const BASE_WIDTH = 200;
15+
const POPOVER_WIDTH = 320;
16+
17+
export type SelectFilterOption = {
18+
startIcon?: ReactNode;
19+
label: string;
20+
value: string;
21+
};
22+
23+
export type SelectFilterProps = {
24+
options: SelectFilterOption[] | undefined;
25+
selectedOption?: SelectFilterOption;
26+
// Used to add a accessibility label to the select
27+
label: string;
28+
// Used when there is no option selected
29+
placeholder: string;
30+
// Used to customize the empty state message
31+
emptyText?: string;
32+
onSelect: (option: SelectFilterOption | undefined) => void;
33+
// SelectFilterSearch element
34+
selectFilterSearch?: ReactNode;
35+
};
36+
37+
export const SelectFilter: FC<SelectFilterProps> = ({
38+
label,
39+
options,
40+
selectedOption,
41+
onSelect,
42+
placeholder,
43+
emptyText,
44+
selectFilterSearch,
45+
}) => {
46+
const [open, setOpen] = useState(false);
47+
48+
return (
49+
<SelectMenu open={open} onOpenChange={setOpen}>
50+
<SelectMenuTrigger>
51+
<SelectMenuButton
52+
startIcon={selectedOption?.startIcon}
53+
css={{ width: BASE_WIDTH }}
54+
aria-label={label}
55+
>
56+
{selectedOption?.label ?? placeholder}
57+
</SelectMenuButton>
58+
</SelectMenuTrigger>
59+
<SelectMenuContent
60+
horizontal="right"
61+
css={{
62+
"& .MuiPaper-root": {
63+
// When including selectFilterSearch, we aim for the width to be as
64+
// wide as possible.
65+
width: selectFilterSearch ? "100%" : undefined,
66+
maxWidth: POPOVER_WIDTH,
67+
minWidth: BASE_WIDTH,
68+
},
69+
}}
70+
>
71+
{selectFilterSearch}
72+
{options ? (
73+
options.length > 0 ? (
74+
<SelectMenuList>
75+
{options.map((o) => {
76+
const isSelected = o.value === selectedOption?.value;
77+
return (
78+
<SelectMenuItem
79+
key={o.value}
80+
selected={isSelected}
81+
onClick={() => {
82+
setOpen(false);
83+
onSelect(isSelected ? undefined : o);
84+
}}
85+
>
86+
{o.startIcon && (
87+
<SelectMenuIcon>{o.startIcon}</SelectMenuIcon>
88+
)}
89+
{o.label}
90+
</SelectMenuItem>
91+
);
92+
})}
93+
</SelectMenuList>
94+
) : (
95+
<div
96+
css={(theme) => ({
97+
display: "flex",
98+
alignItems: "center",
99+
justifyContent: "center",
100+
padding: 32,
101+
color: theme.palette.text.secondary,
102+
lineHeight: 1,
103+
})}
104+
>
105+
{emptyText || "No options found"}
106+
</div>
107+
)
108+
) : (
109+
<Loader size={16} />
110+
)}
111+
</SelectMenuContent>
112+
</SelectMenu>
113+
);
114+
};
115+
116+
export const SelectFilterSearch = SelectMenuSearch;

0 commit comments

Comments
 (0)