@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
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { type ComponentPropsWithoutRef, useId, useState } from "react";
|
|
4
4
|
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
|
|
5
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { FilterOptionGroup } from "./filter-option-group";
|
|
7
|
+
import { Button } from "./button";
|
|
6
8
|
import { Skeleton } from "./skeleton";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -101,6 +103,12 @@ export type FacetGroupProps = Omit<
|
|
|
101
103
|
*/
|
|
102
104
|
name?: string;
|
|
103
105
|
size?: FacetGroupSize;
|
|
106
|
+
/** Wrapping native-input chips for filter panels. Rows remain the default. */
|
|
107
|
+
layout?: "rows" | "chips";
|
|
108
|
+
/** Optional bounded chip list; selected options always stay visible. */
|
|
109
|
+
visibleLimit?: number;
|
|
110
|
+
showAllLabel?: string | ((count: number) => string);
|
|
111
|
+
showLessLabel?: string;
|
|
104
112
|
/**
|
|
105
113
|
* Turns the legend into a disclosure toggle for this group's options.
|
|
106
114
|
*
|
|
@@ -135,6 +143,10 @@ export function FacetGroup({
|
|
|
135
143
|
selection = "multiple",
|
|
136
144
|
name,
|
|
137
145
|
size = "comfortable",
|
|
146
|
+
layout = "rows",
|
|
147
|
+
visibleLimit,
|
|
148
|
+
showAllLabel,
|
|
149
|
+
showLessLabel,
|
|
138
150
|
collapsible = false,
|
|
139
151
|
defaultOpen = true,
|
|
140
152
|
onToggle,
|
|
@@ -155,6 +167,52 @@ export function FacetGroup({
|
|
|
155
167
|
const fallbackName = useId();
|
|
156
168
|
const groupName = name ?? fallbackName;
|
|
157
169
|
|
|
170
|
+
if (layout === "chips") {
|
|
171
|
+
const value = options
|
|
172
|
+
.filter((option) => option.checked)
|
|
173
|
+
.map((option) => option.id);
|
|
174
|
+
return (
|
|
175
|
+
<FilterOptionGroup
|
|
176
|
+
title={title}
|
|
177
|
+
options={options.map((option) => ({ ...option, value: option.id }))}
|
|
178
|
+
value={value}
|
|
179
|
+
selection={selection}
|
|
180
|
+
name={groupName}
|
|
181
|
+
defaultOpen={defaultOpen}
|
|
182
|
+
collapsible={collapsible}
|
|
183
|
+
divider={false}
|
|
184
|
+
visibleLimit={visibleLimit}
|
|
185
|
+
showAllLabel={showAllLabel}
|
|
186
|
+
showLessLabel={showLessLabel}
|
|
187
|
+
disabled={rest.disabled}
|
|
188
|
+
className={className}
|
|
189
|
+
onValueChange={(next) => {
|
|
190
|
+
if (selection === "single") {
|
|
191
|
+
if (next[0] != null) onToggle?.(String(next[0]), true);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
for (const option of options) {
|
|
195
|
+
const checked = next.includes(option.id);
|
|
196
|
+
if (checked !== !!option.checked) onToggle?.(option.id, checked);
|
|
197
|
+
}
|
|
198
|
+
}}
|
|
199
|
+
footer={
|
|
200
|
+
moreLabel && (
|
|
201
|
+
<Button
|
|
202
|
+
type="button"
|
|
203
|
+
variant="link"
|
|
204
|
+
className="min-h-11 px-0"
|
|
205
|
+
onClick={onMore}
|
|
206
|
+
aria-expanded={moreExpanded}
|
|
207
|
+
>
|
|
208
|
+
{moreLabel}
|
|
209
|
+
</Button>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
/>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
158
216
|
return (
|
|
159
217
|
// NO SEPARATOR OF ITS OWN. This drew `<Separator className="absolute
|
|
160
218
|
// inset-x-0 top-0" />`, which looked like a rule above each group and was
|
|
@@ -172,7 +230,7 @@ export function FacetGroup({
|
|
|
172
230
|
<legend
|
|
173
231
|
className={cn(
|
|
174
232
|
"block w-full p-0 text-[11.5px] font-extrabold tracking-[0.1em] text-muted-foreground uppercase",
|
|
175
|
-
collapsible ? "mb-0" : "mb-[11px]"
|
|
233
|
+
collapsible ? "mb-0" : "mb-[11px]",
|
|
176
234
|
)}
|
|
177
235
|
>
|
|
178
236
|
{collapsible ? (
|
|
@@ -197,22 +255,34 @@ export function FacetGroup({
|
|
|
197
255
|
// groups looked like the rules were mis-centred — they were
|
|
198
256
|
// centred, the labels were not. Transitioned alongside the panel
|
|
199
257
|
// so the two move as one gesture rather than the margin snapping.
|
|
200
|
-
open ? "mb-[11px]" : "mb-0"
|
|
258
|
+
open ? "mb-[11px]" : "mb-0",
|
|
201
259
|
)}
|
|
202
260
|
>
|
|
203
|
-
{Icon &&
|
|
261
|
+
{Icon && (
|
|
262
|
+
<Icon
|
|
263
|
+
aria-hidden
|
|
264
|
+
className="size-3.5 shrink-0"
|
|
265
|
+
strokeWidth={2.25}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
204
268
|
{title}
|
|
205
269
|
<ChevronDown
|
|
206
270
|
aria-hidden
|
|
207
271
|
className={cn(
|
|
208
272
|
"ml-auto size-3.5 shrink-0 transition-transform duration-150 motion-reduce:transition-none",
|
|
209
|
-
open ? "rotate-180" : "rotate-0"
|
|
273
|
+
open ? "rotate-180" : "rotate-0",
|
|
210
274
|
)}
|
|
211
275
|
/>
|
|
212
276
|
</button>
|
|
213
277
|
) : (
|
|
214
278
|
<span className="flex items-center gap-1.5">
|
|
215
|
-
{Icon &&
|
|
279
|
+
{Icon && (
|
|
280
|
+
<Icon
|
|
281
|
+
aria-hidden
|
|
282
|
+
className="size-3.5 shrink-0"
|
|
283
|
+
strokeWidth={2.25}
|
|
284
|
+
/>
|
|
285
|
+
)}
|
|
216
286
|
{title}
|
|
217
287
|
</span>
|
|
218
288
|
)}
|
|
@@ -244,7 +314,7 @@ export function FacetGroup({
|
|
|
244
314
|
<div
|
|
245
315
|
className={cn(
|
|
246
316
|
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
|
|
247
|
-
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
|
317
|
+
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
|
248
318
|
)}
|
|
249
319
|
>
|
|
250
320
|
<div className="overflow-hidden">
|
|
@@ -254,54 +324,63 @@ export function FacetGroup({
|
|
|
254
324
|
aria-hidden={!open || undefined}
|
|
255
325
|
className={cn("flex flex-col", ROW_GAP[size])}
|
|
256
326
|
>
|
|
257
|
-
|
|
258
|
-
|
|
327
|
+
{options.map((option) => {
|
|
328
|
+
const checked = !!option.checked;
|
|
259
329
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
330
|
+
return (
|
|
331
|
+
<label
|
|
332
|
+
key={option.id}
|
|
333
|
+
className={cn(
|
|
334
|
+
"flex cursor-pointer items-center gap-[9px] text-[13.5px] select-none",
|
|
335
|
+
"transition-colors duration-150 hover:bg-muted/60 motion-reduce:transition-none",
|
|
336
|
+
ROW_SIZE[size],
|
|
337
|
+
)}
|
|
338
|
+
>
|
|
339
|
+
<input
|
|
340
|
+
type={isSingle ? "radio" : "checkbox"}
|
|
341
|
+
name={isSingle ? groupName : undefined}
|
|
342
|
+
className="peer sr-only"
|
|
343
|
+
checked={checked}
|
|
344
|
+
onChange={(event) =>
|
|
345
|
+
onToggle?.(option.id, event.target.checked)
|
|
346
|
+
}
|
|
347
|
+
/>
|
|
348
|
+
<span
|
|
349
|
+
aria-hidden
|
|
350
|
+
className={cn(
|
|
351
|
+
"flex h-4 w-4 shrink-0 items-center justify-center border-[1.5px] transition-colors duration-150 motion-reduce:transition-none",
|
|
352
|
+
isSingle ? "rounded-full" : "rounded-[5px]",
|
|
353
|
+
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
|
|
354
|
+
checked
|
|
355
|
+
? "border-primary bg-primary"
|
|
356
|
+
: "border-border bg-background",
|
|
357
|
+
)}
|
|
358
|
+
>
|
|
359
|
+
{checked &&
|
|
360
|
+
(isSingle ? (
|
|
361
|
+
<span className="size-[7px] rounded-full bg-primary-foreground" />
|
|
362
|
+
) : (
|
|
363
|
+
<Check
|
|
364
|
+
className="h-[11px] w-[11px] text-primary-foreground"
|
|
365
|
+
strokeWidth={3}
|
|
366
|
+
/>
|
|
367
|
+
))}
|
|
368
|
+
</span>
|
|
369
|
+
<span
|
|
370
|
+
title={option.label}
|
|
371
|
+
className={cn(
|
|
372
|
+
"min-w-0 truncate",
|
|
373
|
+
checked
|
|
374
|
+
? "font-bold text-primary"
|
|
375
|
+
: "font-normal text-foreground",
|
|
376
|
+
)}
|
|
377
|
+
>
|
|
378
|
+
{option.label}
|
|
379
|
+
</span>
|
|
380
|
+
<span className="ml-auto shrink-0 font-mono text-[11.5px] tabular-nums text-muted-foreground">
|
|
381
|
+
{option.count}
|
|
382
|
+
</span>
|
|
383
|
+
</label>
|
|
305
384
|
);
|
|
306
385
|
})}
|
|
307
386
|
</div>
|
|
@@ -329,6 +408,7 @@ export type FacetGroupSkeletonProps = {
|
|
|
329
408
|
showMore?: boolean;
|
|
330
409
|
/** Must match the `<FacetGroup>` it stands in for, or rows shift when data lands. */
|
|
331
410
|
size?: FacetGroupSize;
|
|
411
|
+
layout?: "rows" | "chips";
|
|
332
412
|
className?: string;
|
|
333
413
|
};
|
|
334
414
|
|
|
@@ -338,7 +418,23 @@ export function FacetGroupSkeleton({
|
|
|
338
418
|
showMore,
|
|
339
419
|
size = "comfortable",
|
|
340
420
|
className,
|
|
421
|
+
layout = "rows",
|
|
341
422
|
}: FacetGroupSkeletonProps) {
|
|
423
|
+
if (layout === "chips")
|
|
424
|
+
return (
|
|
425
|
+
<div aria-hidden="true" className={cn("py-3", className)}>
|
|
426
|
+
<Skeleton className="mb-4 h-4 w-24" />
|
|
427
|
+
<div className="flex flex-wrap gap-2">
|
|
428
|
+
{Array.from({ length: optionCount }, (_, index) => (
|
|
429
|
+
<Skeleton
|
|
430
|
+
key={index}
|
|
431
|
+
className={cn("h-11 rounded-2xl", index % 2 ? "w-28" : "w-24")}
|
|
432
|
+
/>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
{showMore && <Skeleton className="mt-3 h-4 w-20" />}
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
342
438
|
return (
|
|
343
439
|
// Matches the live group: no rule of its own, `py-3`, and the row gap keyed
|
|
344
440
|
// to the same density — or the panel shifts on load by the difference.
|
|
@@ -347,7 +443,10 @@ export function FacetGroupSkeleton({
|
|
|
347
443
|
|
|
348
444
|
<div className={cn("flex flex-col", ROW_GAP[size])}>
|
|
349
445
|
{Array.from({ length: optionCount }).map((_, index) => (
|
|
350
|
-
<div
|
|
446
|
+
<div
|
|
447
|
+
key={index}
|
|
448
|
+
className={cn("flex items-center gap-[9px]", ROW_SIZE[size])}
|
|
449
|
+
>
|
|
351
450
|
<Skeleton className="h-4 w-4 rounded-[5px]" />
|
|
352
451
|
<Skeleton className="h-3.5 w-24" />
|
|
353
452
|
<Skeleton className="ml-auto h-3 w-5" />
|
|
@@ -0,0 +1,50 @@
|
|
|
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 } from "vitest";
|
|
5
|
+
import { FilterOptionChip } from "./filter-option-chip";
|
|
6
|
+
|
|
7
|
+
describe("FilterOptionChip", () => {
|
|
8
|
+
it("keeps zero-count selections operable by keyboard", async () => {
|
|
9
|
+
function Example() {
|
|
10
|
+
const [checked, setChecked] = useState(true);
|
|
11
|
+
return (
|
|
12
|
+
<FilterOptionChip
|
|
13
|
+
label="Italiană"
|
|
14
|
+
count={0}
|
|
15
|
+
checked={checked}
|
|
16
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
const user = userEvent.setup();
|
|
21
|
+
render(<Example />);
|
|
22
|
+
const checkbox = screen.getByRole("checkbox", { name: "Italiană" });
|
|
23
|
+
expect(checkbox).toBeChecked();
|
|
24
|
+
await user.tab();
|
|
25
|
+
expect(checkbox).toHaveFocus();
|
|
26
|
+
await user.keyboard(" ");
|
|
27
|
+
expect(checkbox).not.toBeChecked();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps native single selection for radio groups", async () => {
|
|
31
|
+
const user = userEvent.setup();
|
|
32
|
+
render(
|
|
33
|
+
<fieldset>
|
|
34
|
+
<legend>Budget</legend>
|
|
35
|
+
<FilterOptionChip
|
|
36
|
+
type="radio"
|
|
37
|
+
name="budget"
|
|
38
|
+
label="Budget friendly"
|
|
39
|
+
defaultChecked
|
|
40
|
+
/>
|
|
41
|
+
<FilterOptionChip type="radio" name="budget" label="Premium" />
|
|
42
|
+
</fieldset>,
|
|
43
|
+
);
|
|
44
|
+
await user.click(screen.getByRole("radio", { name: "Premium" }));
|
|
45
|
+
expect(screen.getByRole("radio", { name: "Premium" })).toBeChecked();
|
|
46
|
+
expect(
|
|
47
|
+
screen.getByRole("radio", { name: "Budget friendly" }),
|
|
48
|
+
).not.toBeChecked();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ComponentPropsWithoutRef, type ReactNode, useId } from "react";
|
|
4
|
+
import { Check } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
export type FilterOptionChipProps = Omit<
|
|
8
|
+
ComponentPropsWithoutRef<"input">,
|
|
9
|
+
"type" | "children" | "size"
|
|
10
|
+
> & {
|
|
11
|
+
label: string;
|
|
12
|
+
/** Radios in one group must share a name. */
|
|
13
|
+
type?: "checkbox" | "radio";
|
|
14
|
+
/** Omit while counts are unresolved. Zero is a valid, selectable count. */
|
|
15
|
+
count?: number | null;
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** A wrapping filter option with native selection and keyboard semantics. */
|
|
20
|
+
export function FilterOptionChip({
|
|
21
|
+
label,
|
|
22
|
+
type = "checkbox",
|
|
23
|
+
count,
|
|
24
|
+
icon,
|
|
25
|
+
className,
|
|
26
|
+
id,
|
|
27
|
+
disabled,
|
|
28
|
+
...input
|
|
29
|
+
}: FilterOptionChipProps) {
|
|
30
|
+
const generatedId = useId();
|
|
31
|
+
const inputId = id ?? generatedId;
|
|
32
|
+
return (
|
|
33
|
+
<label
|
|
34
|
+
htmlFor={inputId}
|
|
35
|
+
className={cn(
|
|
36
|
+
"relative inline-flex max-w-full min-w-0",
|
|
37
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
38
|
+
className,
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
<input
|
|
42
|
+
{...input}
|
|
43
|
+
id={inputId}
|
|
44
|
+
type={type}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
aria-label={input["aria-label"] ?? label}
|
|
47
|
+
className="peer sr-only"
|
|
48
|
+
/>
|
|
49
|
+
<span
|
|
50
|
+
className={cn(
|
|
51
|
+
"flex min-h-11 min-w-0 max-w-full cursor-pointer items-center gap-2 rounded-2xl border border-border bg-background px-3 py-2 text-sm text-foreground",
|
|
52
|
+
"transition-colors hover:bg-muted motion-reduce:transition-none",
|
|
53
|
+
"peer-checked:border-primary peer-checked:bg-primary/10 peer-checked:shadow-[inset_0_0_0_1px_var(--primary)] peer-checked:text-primary",
|
|
54
|
+
"peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-ring",
|
|
55
|
+
"peer-disabled:pointer-events-none peer-disabled:cursor-not-allowed",
|
|
56
|
+
"[&_.filter-check]:hidden peer-checked:[&_.filter-check]:block peer-checked:[&_.filter-icon]:hidden",
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<Check
|
|
60
|
+
aria-hidden
|
|
61
|
+
className="filter-check size-4 shrink-0"
|
|
62
|
+
strokeWidth={2.5}
|
|
63
|
+
/>
|
|
64
|
+
{icon && (
|
|
65
|
+
<span aria-hidden className="filter-icon shrink-0 [&>svg]:size-4">
|
|
66
|
+
{icon}
|
|
67
|
+
</span>
|
|
68
|
+
)}
|
|
69
|
+
<span className="min-w-0 [overflow-wrap:anywhere]">{label}</span>
|
|
70
|
+
{count != null && (
|
|
71
|
+
<span
|
|
72
|
+
aria-hidden
|
|
73
|
+
className="shrink-0 text-xs tabular-nums text-muted-foreground"
|
|
74
|
+
>
|
|
75
|
+
{count}
|
|
76
|
+
</span>
|
|
77
|
+
)}
|
|
78
|
+
</span>
|
|
79
|
+
</label>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -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
|
+
};
|