@bearmenu/ui 0.8.52 → 0.8.53
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-GIJGVFGC.js +177 -0
- package/dist/chunk-RKNPPYRU.js +86 -0
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/banner.d.ts +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/card.d.ts +2 -2
- package/dist/components/command.d.ts +15 -15
- package/dist/components/empty-state.d.ts +2 -2
- 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/place-card.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 +2 -2
- package/dist/components/toggle.d.ts +1 -1
- package/package.json +28 -222
- 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
|
@@ -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
|
+
}
|