@bearmenu/ui 0.8.52 → 0.8.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-4WW3HPIW.js → chunk-6Z6MBILC.js} +8 -4
- package/dist/chunk-GIJGVFGC.js +177 -0
- package/dist/chunk-RKNPPYRU.js +86 -0
- package/dist/{chunk-2ADCZXZJ.js → chunk-RXXD3UYP.js} +3 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/combobox.js +1 -1
- package/dist/components/event-detail-panel.d.ts +17 -6
- package/dist/components/event-detail-panel.js +58 -8
- package/dist/components/facet-group.d.ts +9 -2
- package/dist/components/facet-group.js +108 -10
- package/dist/components/filter-option-chip.d.ts +15 -0
- package/dist/components/filter-option-chip.js +3 -0
- package/dist/components/filter-option-group.d.ts +37 -0
- package/dist/components/filter-option-group.js +10 -0
- package/dist/components/filter-panel-footer.d.ts +15 -0
- package/dist/components/filter-panel-footer.js +63 -0
- package/dist/components/menu-item.d.ts +24 -3
- package/dist/components/menu-item.js +1 -1
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/popover.js +1 -1
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/top-ranking-entry.js +1 -1
- package/package.json +11 -8
- package/src/components/event-detail-panel.stories.tsx +16 -0
- package/src/components/event-detail-panel.test.tsx +36 -0
- package/src/components/event-detail-panel.tsx +86 -14
- package/src/components/facet-group.tsx +153 -54
- package/src/components/filter-option-chip.test.tsx +50 -0
- package/src/components/filter-option-chip.tsx +81 -0
- package/src/components/filter-option-group.stories.tsx +250 -0
- package/src/components/filter-option-group.test.tsx +100 -0
- package/src/components/filter-option-group.tsx +231 -0
- package/src/components/filter-panel-footer.tsx +63 -0
- package/src/components/menu-item.stories.tsx +82 -0
- package/src/components/menu-item.test.tsx +55 -1
- package/src/components/menu-item.tsx +31 -4
- package/src/components/popover.tsx +3 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { ArrowLeft, X } from "lucide-react";
|
|
4
|
+
import { FilterOptionGroup } from "./filter-option-group";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { FilterPanelFooter } from "./filter-panel-footer";
|
|
7
|
+
|
|
8
|
+
const meta: Meta<typeof FilterOptionGroup> = {
|
|
9
|
+
title: "Places/Filters redesign",
|
|
10
|
+
component: FilterOptionGroup,
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: "fullscreen",
|
|
13
|
+
docs: {
|
|
14
|
+
description: {
|
|
15
|
+
component:
|
|
16
|
+
"Issue #255 design review. Native wrapping options with immediate selection, persistent reset/results actions and selected summaries. Demo counts are fixtures, not live results. App URL, search flush and map navigation are verified during integration.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
function FiltersPreview({
|
|
25
|
+
width = 390,
|
|
26
|
+
english = false,
|
|
27
|
+
pending = false,
|
|
28
|
+
zero = false,
|
|
29
|
+
largeText = false,
|
|
30
|
+
initiallyOpenNow = false,
|
|
31
|
+
}: {
|
|
32
|
+
width?: number;
|
|
33
|
+
english?: boolean;
|
|
34
|
+
pending?: boolean;
|
|
35
|
+
zero?: boolean;
|
|
36
|
+
largeText?: boolean;
|
|
37
|
+
initiallyOpenNow?: boolean;
|
|
38
|
+
}) {
|
|
39
|
+
const [values, setValues] = useState<Record<string, string[]>>({
|
|
40
|
+
type: ["Restaurant"],
|
|
41
|
+
service: [english ? "Table service" : "La masă"],
|
|
42
|
+
openNow: initiallyOpenNow ? [english ? "Open now" : "Deschis acum"] : [],
|
|
43
|
+
});
|
|
44
|
+
const [results, setResults] = useState(false);
|
|
45
|
+
const groups = [
|
|
46
|
+
{
|
|
47
|
+
key: "type",
|
|
48
|
+
title: english ? "Place type" : "Tipul localului",
|
|
49
|
+
open: true,
|
|
50
|
+
options: english
|
|
51
|
+
? ["Restaurant", "Bistro", "Café", "Pub", "Bar", "Bakery"]
|
|
52
|
+
: ["Restaurant", "Bistro", "Cafenea", "Pub", "Bar", "Brutărie"],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "food",
|
|
56
|
+
title: english ? "Cuisine" : "Bucătărie",
|
|
57
|
+
open: true,
|
|
58
|
+
options: english
|
|
59
|
+
? [
|
|
60
|
+
"Romanian",
|
|
61
|
+
"Italian",
|
|
62
|
+
"Mediterranean",
|
|
63
|
+
"Japanese",
|
|
64
|
+
"International",
|
|
65
|
+
"French",
|
|
66
|
+
"Greek",
|
|
67
|
+
"Turkish",
|
|
68
|
+
"Mexican",
|
|
69
|
+
]
|
|
70
|
+
: [
|
|
71
|
+
"Românească",
|
|
72
|
+
"Italiană",
|
|
73
|
+
"Mediteraneană",
|
|
74
|
+
"Japoneză",
|
|
75
|
+
"Internațională",
|
|
76
|
+
"Franceză",
|
|
77
|
+
"Grecească",
|
|
78
|
+
"Turcească",
|
|
79
|
+
"Mexicană",
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
key: "service",
|
|
84
|
+
title: english ? "Service" : "Servire",
|
|
85
|
+
options: english
|
|
86
|
+
? ["Table service", "Self service", "Takeaway"]
|
|
87
|
+
: ["La masă", "Autoservire", "La pachet"],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
key: "openNow",
|
|
91
|
+
title: english ? "Open now" : "Deschis acum",
|
|
92
|
+
open: true,
|
|
93
|
+
options: [english ? "Open now" : "Deschis acum"],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: "dietary",
|
|
97
|
+
title: english ? "Dietary preferences" : "Preferințe alimentare",
|
|
98
|
+
options: english
|
|
99
|
+
? [
|
|
100
|
+
"Vegetarian options",
|
|
101
|
+
"Vegan options",
|
|
102
|
+
"Gluten-free options available on request",
|
|
103
|
+
]
|
|
104
|
+
: [
|
|
105
|
+
"Opțiuni vegetariene",
|
|
106
|
+
"Opțiuni vegane",
|
|
107
|
+
"Opțiuni fără gluten disponibile la cerere",
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
key: "vibe",
|
|
112
|
+
title: english ? "Atmosphere" : "Atmosferă",
|
|
113
|
+
options: english
|
|
114
|
+
? ["Cozy", "Romantic", "Family friendly", "Lively", "Quiet"]
|
|
115
|
+
: [
|
|
116
|
+
"Primitoare",
|
|
117
|
+
"Romantică",
|
|
118
|
+
"Potrivit pentru familii",
|
|
119
|
+
"Animată",
|
|
120
|
+
"Liniștită",
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: "amenities",
|
|
125
|
+
title: english ? "Amenities" : "Facilități",
|
|
126
|
+
options: english
|
|
127
|
+
? ["Terrace", "Wi-Fi", "Parking", "Step-free wheelchair access"]
|
|
128
|
+
: [
|
|
129
|
+
"Terasă",
|
|
130
|
+
"Wi-Fi",
|
|
131
|
+
"Parcare",
|
|
132
|
+
"Acces fără trepte pentru scaun rulant",
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
const canReset = Object.values(values).some((value) => value.length > 0);
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
style={{ width, maxWidth: "100%" }}
|
|
140
|
+
className="mx-auto flex h-[760px] max-h-dvh flex-col overflow-hidden rounded-t-3xl border border-border bg-background text-foreground shadow-lg"
|
|
141
|
+
>
|
|
142
|
+
{largeText && <style>{"html { font-size: 20px; }"}</style>}
|
|
143
|
+
<div
|
|
144
|
+
aria-hidden
|
|
145
|
+
className="mx-auto mt-3 h-1 w-10 shrink-0 rounded-full bg-muted-foreground/30"
|
|
146
|
+
/>
|
|
147
|
+
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border/60 px-4 py-2">
|
|
148
|
+
<Button
|
|
149
|
+
variant="ghost"
|
|
150
|
+
size="icon"
|
|
151
|
+
aria-label={english ? "Back to results" : "Înapoi la rezultate"}
|
|
152
|
+
onClick={() => setResults(true)}
|
|
153
|
+
>
|
|
154
|
+
<ArrowLeft aria-hidden />
|
|
155
|
+
</Button>
|
|
156
|
+
<h1 className="text-lg font-semibold">
|
|
157
|
+
{english ? "Filters" : "Filtre"}
|
|
158
|
+
</h1>
|
|
159
|
+
<Button
|
|
160
|
+
variant="ghost"
|
|
161
|
+
size="icon"
|
|
162
|
+
aria-label={english ? "Close filters" : "Închide filtrele"}
|
|
163
|
+
onClick={() => setResults(true)}
|
|
164
|
+
>
|
|
165
|
+
<X aria-hidden />
|
|
166
|
+
</Button>
|
|
167
|
+
</header>
|
|
168
|
+
{results ? (
|
|
169
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-5">
|
|
170
|
+
<p>
|
|
171
|
+
{english
|
|
172
|
+
? "Your selections are kept."
|
|
173
|
+
: "Selecțiile tale sunt păstrate."}
|
|
174
|
+
</p>
|
|
175
|
+
<Button onClick={() => setResults(false)}>
|
|
176
|
+
{english ? "Reopen filters" : "Redeschide filtrele"}
|
|
177
|
+
</Button>
|
|
178
|
+
</div>
|
|
179
|
+
) : (
|
|
180
|
+
<>
|
|
181
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4">
|
|
182
|
+
{groups.map((group) => (
|
|
183
|
+
<FilterOptionGroup
|
|
184
|
+
key={group.key}
|
|
185
|
+
title={group.title}
|
|
186
|
+
defaultOpen={group.open}
|
|
187
|
+
options={group.options.map((label, index) => ({
|
|
188
|
+
value: label,
|
|
189
|
+
label,
|
|
190
|
+
count:
|
|
191
|
+
pending || group.key === "openNow"
|
|
192
|
+
? null
|
|
193
|
+
: zero
|
|
194
|
+
? 0
|
|
195
|
+
: [159, 42, 26, 18, 12, 7, 6, 4, 2][index],
|
|
196
|
+
}))}
|
|
197
|
+
value={values[group.key] ?? []}
|
|
198
|
+
onValueChange={(value) =>
|
|
199
|
+
setValues((previous) => ({
|
|
200
|
+
...previous,
|
|
201
|
+
[group.key]: value.map(String),
|
|
202
|
+
}))
|
|
203
|
+
}
|
|
204
|
+
showAllLabel={
|
|
205
|
+
english
|
|
206
|
+
? `Show all (${group.options.length})`
|
|
207
|
+
: `Arată toate (${group.options.length})`
|
|
208
|
+
}
|
|
209
|
+
showLessLabel={english ? "Show fewer" : "Arată mai puține"}
|
|
210
|
+
/>
|
|
211
|
+
))}
|
|
212
|
+
</div>
|
|
213
|
+
<FilterPanelFooter
|
|
214
|
+
resetLabel={english ? "Reset" : "Resetează"}
|
|
215
|
+
resultsLabel={`${english ? "View results" : "Vezi rezultatele"}${pending ? "" : ` (${zero ? 0 : 159})`}`}
|
|
216
|
+
canReset={canReset}
|
|
217
|
+
onReset={() => setValues({})}
|
|
218
|
+
onResults={() => setResults(true)}
|
|
219
|
+
resetHint={
|
|
220
|
+
english
|
|
221
|
+
? "Reset clears all filters, including place type and service."
|
|
222
|
+
: "Resetarea șterge toate filtrele, inclusiv tipul localului și servirea."
|
|
223
|
+
}
|
|
224
|
+
/>
|
|
225
|
+
</>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const Mobile390: Story = { render: () => <FiltersPreview /> };
|
|
232
|
+
export const OpenNowSelected: Story = {
|
|
233
|
+
render: () => <FiltersPreview initiallyOpenNow />,
|
|
234
|
+
};
|
|
235
|
+
export const Narrow360: Story = {
|
|
236
|
+
render: () => <FiltersPreview width={360} />,
|
|
237
|
+
};
|
|
238
|
+
export const Wide430: Story = { render: () => <FiltersPreview width={430} /> };
|
|
239
|
+
export const English: Story = { render: () => <FiltersPreview english /> };
|
|
240
|
+
export const PendingCounts: Story = {
|
|
241
|
+
render: () => <FiltersPreview pending />,
|
|
242
|
+
};
|
|
243
|
+
export const ZeroResults: Story = { render: () => <FiltersPreview zero /> };
|
|
244
|
+
export const LargerText: Story = {
|
|
245
|
+
render: () => <FiltersPreview width={360} largeText />,
|
|
246
|
+
};
|
|
247
|
+
export const Dark: Story = {
|
|
248
|
+
globals: { theme: "dark" },
|
|
249
|
+
render: () => <FiltersPreview />,
|
|
250
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { FilterOptionGroup } from "./filter-option-group";
|
|
6
|
+
import { FacetGroup } from "./facet-group";
|
|
7
|
+
|
|
8
|
+
describe("FilterOptionGroup", () => {
|
|
9
|
+
it("keeps selected options outside the cap reachable and summarizes them on collapse", async () => {
|
|
10
|
+
const user = userEvent.setup();
|
|
11
|
+
const options = Array.from({ length: 9 }, (_, i) => ({
|
|
12
|
+
value: String(i),
|
|
13
|
+
label: `Cuisine ${i}`,
|
|
14
|
+
}));
|
|
15
|
+
render(
|
|
16
|
+
<FilterOptionGroup
|
|
17
|
+
title="Cuisine"
|
|
18
|
+
options={options}
|
|
19
|
+
value={["8"]}
|
|
20
|
+
onValueChange={() => {}}
|
|
21
|
+
defaultOpen
|
|
22
|
+
showAllLabel="Show all"
|
|
23
|
+
showLessLabel="Show less"
|
|
24
|
+
/>,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByRole("checkbox", { name: "Cuisine 8" })).toBeChecked();
|
|
27
|
+
expect(
|
|
28
|
+
screen.queryByRole("checkbox", { name: "Cuisine 7" }),
|
|
29
|
+
).not.toBeInTheDocument();
|
|
30
|
+
await user.click(screen.getByRole("button", { name: "Show all" }));
|
|
31
|
+
expect(screen.getByRole("checkbox", { name: "Cuisine 7" })).toBeVisible();
|
|
32
|
+
await user.click(screen.getByRole("button", { name: /^Cuisine/ }));
|
|
33
|
+
expect(screen.getByRole("button", { name: /Cuisine 8/ })).toBeVisible();
|
|
34
|
+
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("leaves the group open after the last selection is removed", async () => {
|
|
38
|
+
function Example() {
|
|
39
|
+
const [value, setValue] = useState<(string | number)[]>(["open"]);
|
|
40
|
+
return (
|
|
41
|
+
<FilterOptionGroup
|
|
42
|
+
title="Opening hours"
|
|
43
|
+
options={[{ value: "open", label: "Open now" }]}
|
|
44
|
+
value={value}
|
|
45
|
+
onValueChange={setValue}
|
|
46
|
+
defaultOpen
|
|
47
|
+
/>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
render(<Example />);
|
|
51
|
+
await userEvent.click(screen.getByRole("checkbox", { name: "Open now" }));
|
|
52
|
+
expect(
|
|
53
|
+
screen.getByRole("checkbox", { name: "Open now" }),
|
|
54
|
+
).not.toBeChecked();
|
|
55
|
+
expect(
|
|
56
|
+
screen.getByRole("button", { name: "Opening hours" }),
|
|
57
|
+
).toHaveAttribute("aria-expanded", "true");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("caps each vocabulary independently", () => {
|
|
61
|
+
const options = ["Dishes", "Cuisines"].flatMap((section) =>
|
|
62
|
+
Array.from({ length: 8 }, (_, i) => ({
|
|
63
|
+
value: `${section}-${i}`,
|
|
64
|
+
label: `${section} ${i}`,
|
|
65
|
+
section,
|
|
66
|
+
})),
|
|
67
|
+
);
|
|
68
|
+
render(
|
|
69
|
+
<FilterOptionGroup
|
|
70
|
+
title="Food"
|
|
71
|
+
options={options}
|
|
72
|
+
value={[]}
|
|
73
|
+
onValueChange={() => {}}
|
|
74
|
+
defaultOpen
|
|
75
|
+
showAllLabel="All"
|
|
76
|
+
showLessLabel="Less"
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
expect(screen.getAllByRole("checkbox")).toHaveLength(12);
|
|
80
|
+
expect(screen.getAllByRole("button", { name: "All" })).toHaveLength(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("adapts the existing FacetGroup single selection contract", async () => {
|
|
84
|
+
const onToggle = vi.fn();
|
|
85
|
+
render(
|
|
86
|
+
<FacetGroup
|
|
87
|
+
layout="chips"
|
|
88
|
+
title="When"
|
|
89
|
+
selection="single"
|
|
90
|
+
options={[
|
|
91
|
+
{ id: "today", label: "Today", count: 1, checked: true },
|
|
92
|
+
{ id: "tomorrow", label: "Tomorrow", count: 2 },
|
|
93
|
+
]}
|
|
94
|
+
onToggle={onToggle}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
await userEvent.click(screen.getByRole("radio", { name: "Tomorrow" }));
|
|
98
|
+
expect(onToggle).toHaveBeenCalledExactlyOnceWith("tomorrow", true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useId, useState } from "react";
|
|
4
|
+
import { ChevronDown } from "lucide-react";
|
|
5
|
+
import { FilterOptionChip } from "./filter-option-chip";
|
|
6
|
+
import { Badge } from "./badge";
|
|
7
|
+
import { Button } from "./button";
|
|
8
|
+
import { Separator } from "./separator";
|
|
9
|
+
import { Text } from "./text";
|
|
10
|
+
import { cn } from "../lib/utils";
|
|
11
|
+
|
|
12
|
+
export type FilterOptionValue = string | number;
|
|
13
|
+
export type FilterOptionGroupProps = {
|
|
14
|
+
title: string;
|
|
15
|
+
options: {
|
|
16
|
+
value: FilterOptionValue;
|
|
17
|
+
label: string;
|
|
18
|
+
count?: number | null;
|
|
19
|
+
icon?: ReactNode;
|
|
20
|
+
section?: string;
|
|
21
|
+
}[];
|
|
22
|
+
value: FilterOptionValue[];
|
|
23
|
+
onValueChange: (value: FilterOptionValue[]) => void;
|
|
24
|
+
selection?: "multiple" | "single";
|
|
25
|
+
name?: string;
|
|
26
|
+
defaultOpen?: boolean;
|
|
27
|
+
collapsible?: boolean;
|
|
28
|
+
visibleLimit?: number;
|
|
29
|
+
showAllLabel?: string | ((count: number) => string);
|
|
30
|
+
showLessLabel?: string;
|
|
31
|
+
/** Extra controls or a selection explanation, before the options. */
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
footer?: ReactNode;
|
|
34
|
+
clearLabel?: string;
|
|
35
|
+
onClear?: () => void;
|
|
36
|
+
/** Omit neutral choices from the active summary without changing radio selection. */
|
|
37
|
+
summaryValues?: FilterOptionValue[];
|
|
38
|
+
className?: string;
|
|
39
|
+
divider?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Presentation only: callers own filter rules, URL state, counts and translations. */
|
|
44
|
+
export function FilterOptionGroup({
|
|
45
|
+
title,
|
|
46
|
+
options,
|
|
47
|
+
value,
|
|
48
|
+
onValueChange,
|
|
49
|
+
selection = "multiple",
|
|
50
|
+
name,
|
|
51
|
+
defaultOpen = false,
|
|
52
|
+
collapsible = true,
|
|
53
|
+
visibleLimit = 6,
|
|
54
|
+
showAllLabel,
|
|
55
|
+
showLessLabel,
|
|
56
|
+
children,
|
|
57
|
+
footer,
|
|
58
|
+
clearLabel,
|
|
59
|
+
onClear,
|
|
60
|
+
summaryValues = value,
|
|
61
|
+
className,
|
|
62
|
+
divider = true,
|
|
63
|
+
disabled,
|
|
64
|
+
}: FilterOptionGroupProps) {
|
|
65
|
+
const id = useId();
|
|
66
|
+
const [isOpen, setOpen] = useState(defaultOpen);
|
|
67
|
+
const open = !collapsible || isOpen;
|
|
68
|
+
const [expanded, setExpanded] = useState<string[]>([]);
|
|
69
|
+
const selected = options.filter((option) =>
|
|
70
|
+
summaryValues.includes(option.value),
|
|
71
|
+
);
|
|
72
|
+
const sections: { label?: string; options: typeof options }[] = [];
|
|
73
|
+
for (const option of options) {
|
|
74
|
+
const last = sections[sections.length - 1];
|
|
75
|
+
if (!last || last.label !== option.section)
|
|
76
|
+
sections.push({ label: option.section, options: [option] });
|
|
77
|
+
else last.options.push(option);
|
|
78
|
+
}
|
|
79
|
+
const heading = (
|
|
80
|
+
<>
|
|
81
|
+
<span className="min-w-0 flex-1">
|
|
82
|
+
<Text
|
|
83
|
+
as="span"
|
|
84
|
+
id={`${id}-title`}
|
|
85
|
+
className="block"
|
|
86
|
+
variant="body-sm"
|
|
87
|
+
weight="semibold"
|
|
88
|
+
>
|
|
89
|
+
{title}
|
|
90
|
+
</Text>
|
|
91
|
+
{!open && selected.length > 0 && (
|
|
92
|
+
<Text
|
|
93
|
+
as="span"
|
|
94
|
+
variant="caption"
|
|
95
|
+
color="muted"
|
|
96
|
+
className="mt-1 block leading-relaxed [overflow-wrap:anywhere]"
|
|
97
|
+
>
|
|
98
|
+
{selected.map((option) => option.label).join(" · ")}
|
|
99
|
+
</Text>
|
|
100
|
+
)}
|
|
101
|
+
</span>
|
|
102
|
+
{selected.length > 0 && (
|
|
103
|
+
<Badge variant="secondary" className="tabular-nums">
|
|
104
|
+
{selected.length}
|
|
105
|
+
</Badge>
|
|
106
|
+
)}
|
|
107
|
+
{collapsible && (
|
|
108
|
+
<ChevronDown
|
|
109
|
+
aria-hidden
|
|
110
|
+
className={cn(
|
|
111
|
+
"size-4 shrink-0 text-muted-foreground transition-transform motion-reduce:transition-none",
|
|
112
|
+
open && "rotate-180",
|
|
113
|
+
)}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
</>
|
|
117
|
+
);
|
|
118
|
+
return (
|
|
119
|
+
<section className={cn("min-w-0", className)}>
|
|
120
|
+
<div className="py-2">
|
|
121
|
+
<div className="flex items-center gap-2">
|
|
122
|
+
{collapsible ? (
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => setOpen((previous) => !previous)}
|
|
126
|
+
aria-expanded={open}
|
|
127
|
+
aria-controls={id}
|
|
128
|
+
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-lg py-2 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
129
|
+
>
|
|
130
|
+
{heading}
|
|
131
|
+
</button>
|
|
132
|
+
) : (
|
|
133
|
+
<div className="flex min-h-11 min-w-0 flex-1 items-center gap-3 py-2">
|
|
134
|
+
{heading}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{onClear && clearLabel && summaryValues.length > 0 && (
|
|
138
|
+
<Button
|
|
139
|
+
type="button"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
className="min-h-11 shrink-0 px-2 text-xs"
|
|
142
|
+
onClick={onClear}
|
|
143
|
+
>
|
|
144
|
+
{clearLabel}
|
|
145
|
+
</Button>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
<div id={id} hidden={!open}>
|
|
149
|
+
{children}
|
|
150
|
+
{sections.map((section, index) => {
|
|
151
|
+
const sectionId = `${id}-section-${index}`;
|
|
152
|
+
const key = section.label ?? "";
|
|
153
|
+
const isExpanded = expanded.includes(key);
|
|
154
|
+
const limit = showAllLabel ? visibleLimit : Infinity;
|
|
155
|
+
const visible = isExpanded
|
|
156
|
+
? section.options
|
|
157
|
+
: section.options.filter(
|
|
158
|
+
(option, i) => i < limit || value.includes(option.value),
|
|
159
|
+
);
|
|
160
|
+
return (
|
|
161
|
+
<div key={key}>
|
|
162
|
+
{section.label && (
|
|
163
|
+
<Text
|
|
164
|
+
as="h4"
|
|
165
|
+
id={sectionId}
|
|
166
|
+
variant="caption"
|
|
167
|
+
weight="semibold"
|
|
168
|
+
color="muted"
|
|
169
|
+
className="mt-3 mb-1"
|
|
170
|
+
>
|
|
171
|
+
{section.label}
|
|
172
|
+
</Text>
|
|
173
|
+
)}
|
|
174
|
+
<fieldset
|
|
175
|
+
aria-labelledby={`${id}-title${section.label ? ` ${sectionId}` : ""}`}
|
|
176
|
+
disabled={disabled}
|
|
177
|
+
className="m-0 flex min-w-0 flex-wrap gap-2 border-0 px-0 pt-2 pb-3"
|
|
178
|
+
>
|
|
179
|
+
{visible.map((option) => (
|
|
180
|
+
<FilterOptionChip
|
|
181
|
+
key={option.value}
|
|
182
|
+
label={option.label}
|
|
183
|
+
count={option.count}
|
|
184
|
+
icon={option.icon}
|
|
185
|
+
type={selection === "single" ? "radio" : "checkbox"}
|
|
186
|
+
name={selection === "single" ? (name ?? id) : undefined}
|
|
187
|
+
value={option.value}
|
|
188
|
+
checked={value.includes(option.value)}
|
|
189
|
+
onChange={(event) =>
|
|
190
|
+
onValueChange(
|
|
191
|
+
selection === "single"
|
|
192
|
+
? [option.value]
|
|
193
|
+
: event.target.checked
|
|
194
|
+
? [...value, option.value]
|
|
195
|
+
: value.filter((item) => item !== option.value),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
/>
|
|
199
|
+
))}
|
|
200
|
+
</fieldset>
|
|
201
|
+
{section.options.length > limit && (
|
|
202
|
+
<Button
|
|
203
|
+
type="button"
|
|
204
|
+
variant="link"
|
|
205
|
+
aria-expanded={isExpanded}
|
|
206
|
+
onClick={() =>
|
|
207
|
+
setExpanded((previous) =>
|
|
208
|
+
isExpanded
|
|
209
|
+
? previous.filter((item) => item !== key)
|
|
210
|
+
: [...previous, key],
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
className="mb-1 min-h-11 px-0"
|
|
214
|
+
>
|
|
215
|
+
{isExpanded
|
|
216
|
+
? showLessLabel
|
|
217
|
+
: typeof showAllLabel === "function"
|
|
218
|
+
? showAllLabel(section.options.length)
|
|
219
|
+
: showAllLabel}
|
|
220
|
+
</Button>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
})}
|
|
225
|
+
{footer}
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
{divider && <Separator />}
|
|
229
|
+
</section>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
|
|
6
|
+
export type FilterPanelFooterProps = {
|
|
7
|
+
resetLabel: string;
|
|
8
|
+
resultsLabel: string;
|
|
9
|
+
onReset: () => void;
|
|
10
|
+
onResults: () => void;
|
|
11
|
+
canReset: boolean;
|
|
12
|
+
pending?: boolean;
|
|
13
|
+
resetHint?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Persistent actions belong outside the panel's scroll container. */
|
|
17
|
+
export function FilterPanelFooter({
|
|
18
|
+
resetLabel,
|
|
19
|
+
resultsLabel,
|
|
20
|
+
onReset,
|
|
21
|
+
onResults,
|
|
22
|
+
canReset,
|
|
23
|
+
pending,
|
|
24
|
+
resetHint,
|
|
25
|
+
}: FilterPanelFooterProps) {
|
|
26
|
+
return (
|
|
27
|
+
<footer
|
|
28
|
+
className="shrink-0 border-t border-border bg-card px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
|
29
|
+
data-slot="filter-panel-footer"
|
|
30
|
+
>
|
|
31
|
+
<div className="flex flex-wrap gap-2">
|
|
32
|
+
<Button
|
|
33
|
+
type="button"
|
|
34
|
+
variant="outline"
|
|
35
|
+
className="min-h-11 h-auto flex-1 whitespace-normal py-2"
|
|
36
|
+
disabled={!canReset}
|
|
37
|
+
onClick={onReset}
|
|
38
|
+
>
|
|
39
|
+
{resetLabel}
|
|
40
|
+
</Button>
|
|
41
|
+
<Button
|
|
42
|
+
type="button"
|
|
43
|
+
className="min-h-11 h-auto flex-[2] whitespace-normal py-2"
|
|
44
|
+
disabled={pending}
|
|
45
|
+
aria-busy={pending || undefined}
|
|
46
|
+
onClick={onResults}
|
|
47
|
+
>
|
|
48
|
+
{resultsLabel}
|
|
49
|
+
</Button>
|
|
50
|
+
</div>
|
|
51
|
+
{resetHint && (
|
|
52
|
+
<Text
|
|
53
|
+
as="p"
|
|
54
|
+
variant="caption"
|
|
55
|
+
color="muted"
|
|
56
|
+
className="mt-2 leading-relaxed"
|
|
57
|
+
>
|
|
58
|
+
{resetHint}
|
|
59
|
+
</Text>
|
|
60
|
+
)}
|
|
61
|
+
</footer>
|
|
62
|
+
);
|
|
63
|
+
}
|