@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.
@@ -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
+ }