Skip to content

Commit 9ee53e5

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

File tree

20 files changed

+790
-661
lines changed

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy