@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
|
@@ -19,6 +19,7 @@ import { cn } from "../lib/utils";
|
|
|
19
19
|
import { GradeAtom, type GradeAtomLabels } from "./grade-atom";
|
|
20
20
|
import type { PlaceGrade } from "./place-types";
|
|
21
21
|
import { Button } from "./button";
|
|
22
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
22
23
|
import { EventPosterFallback } from "./event-poster-fallback";
|
|
23
24
|
import { Separator } from "./separator";
|
|
24
25
|
import { Skeleton } from "./skeleton";
|
|
@@ -144,8 +145,15 @@ export type EventDetailPanelProps = {
|
|
|
144
145
|
/** The hype tier, rendered as given (the app's own badge) in the chip row. */
|
|
145
146
|
hype?: React.ReactNode;
|
|
146
147
|
venue?: EventDetailVenue;
|
|
147
|
-
/** Activity tags
|
|
148
|
-
tags?: string[];
|
|
148
|
+
/** Activity tags. Pass a destination to make a tag a browse action. */
|
|
149
|
+
tags?: (string | { label: string; href: string; onClick?: () => void })[];
|
|
150
|
+
/** Entry information opened by the price/free-entry control. */
|
|
151
|
+
entryDetails?: {
|
|
152
|
+
label: string;
|
|
153
|
+
description: string;
|
|
154
|
+
action?: React.ReactNode;
|
|
155
|
+
onOpenChange?: (open: boolean) => void;
|
|
156
|
+
};
|
|
149
157
|
/** Performer names, in the order the API gave them. */
|
|
150
158
|
performers?: string[];
|
|
151
159
|
/** How many performers to show before the "show all" chip. Default 5. */
|
|
@@ -197,6 +205,7 @@ export function EventDetailPanel({
|
|
|
197
205
|
hype,
|
|
198
206
|
venue,
|
|
199
207
|
tags,
|
|
208
|
+
entryDetails,
|
|
200
209
|
performers,
|
|
201
210
|
performersPreview = 5,
|
|
202
211
|
showAllPerformersLabel,
|
|
@@ -289,6 +298,8 @@ export function EventDetailPanel({
|
|
|
289
298
|
soonLabel={soonLabel}
|
|
290
299
|
hype={hype}
|
|
291
300
|
tags={tags}
|
|
301
|
+
entryDetails={entryDetails}
|
|
302
|
+
linkComponent={LinkComponent}
|
|
292
303
|
/>
|
|
293
304
|
|
|
294
305
|
{venue && (
|
|
@@ -675,7 +686,14 @@ export function EventDetailPanelSkeleton({
|
|
|
675
686
|
|
|
676
687
|
export type EventDetailChipRowProps = Pick<
|
|
677
688
|
EventDetailPanelProps,
|
|
678
|
-
|
|
689
|
+
| "event"
|
|
690
|
+
| "formatters"
|
|
691
|
+
| "status"
|
|
692
|
+
| "soonLabel"
|
|
693
|
+
| "hype"
|
|
694
|
+
| "tags"
|
|
695
|
+
| "entryDetails"
|
|
696
|
+
| "linkComponent"
|
|
679
697
|
> & {
|
|
680
698
|
/** Only the chip strings — a page rendering the row alone need not name the section headings. */
|
|
681
699
|
labels: Pick<EventDetailPanelLabels, "free" | "liveNow" | "ended">;
|
|
@@ -683,7 +701,7 @@ export type EventDetailChipRowProps = Pick<
|
|
|
683
701
|
};
|
|
684
702
|
|
|
685
703
|
/**
|
|
686
|
-
* One wrapping row
|
|
704
|
+
* One wrapping row: metadata, entry information, then activity links. Exported
|
|
687
705
|
* so the page renders the same row above its title — it is the panel's row,
|
|
688
706
|
* not a second one.
|
|
689
707
|
*/
|
|
@@ -695,11 +713,25 @@ export function EventDetailChipRow({
|
|
|
695
713
|
soonLabel,
|
|
696
714
|
hype,
|
|
697
715
|
tags,
|
|
716
|
+
entryDetails,
|
|
717
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
698
718
|
className,
|
|
699
719
|
}: EventDetailChipRowProps) {
|
|
700
720
|
const fee = resolveEventEntryFee(event);
|
|
721
|
+
const priceLabel =
|
|
722
|
+
fee.kind === "free"
|
|
723
|
+
? labels.free
|
|
724
|
+
: fee.kind === "paid"
|
|
725
|
+
? formatters.price(fee.amountRon)
|
|
726
|
+
: null;
|
|
727
|
+
const priceIcon =
|
|
728
|
+
fee.kind === "free" ? (
|
|
729
|
+
<Gift className="h-3.5 w-3.5" aria-hidden />
|
|
730
|
+
) : (
|
|
731
|
+
<Ticket className="h-3.5 w-3.5" aria-hidden />
|
|
732
|
+
);
|
|
701
733
|
return (
|
|
702
|
-
<div className={cn("flex flex-wrap gap-[7px]", className)}>
|
|
734
|
+
<div className={cn("flex flex-wrap items-center gap-[7px]", className)}>
|
|
703
735
|
{status === "live" && (
|
|
704
736
|
<span
|
|
705
737
|
className={cn(
|
|
@@ -732,7 +764,33 @@ export function EventDetailChipRow({
|
|
|
732
764
|
{labels.ended}
|
|
733
765
|
</span>
|
|
734
766
|
)}
|
|
735
|
-
{
|
|
767
|
+
{priceLabel && entryDetails ? (
|
|
768
|
+
<Popover onOpenChange={entryDetails.onOpenChange}>
|
|
769
|
+
<PopoverTrigger asChild>
|
|
770
|
+
<Button
|
|
771
|
+
variant="outline"
|
|
772
|
+
size="sm"
|
|
773
|
+
className="min-h-11 rounded-full"
|
|
774
|
+
aria-label={entryDetails.label}
|
|
775
|
+
>
|
|
776
|
+
{priceIcon}
|
|
777
|
+
{priceLabel}
|
|
778
|
+
<ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
|
779
|
+
</Button>
|
|
780
|
+
</PopoverTrigger>
|
|
781
|
+
<PopoverContent
|
|
782
|
+
align="start"
|
|
783
|
+
aria-label={entryDetails.label}
|
|
784
|
+
className="flex max-w-[calc(100vw-2rem)] flex-col gap-3"
|
|
785
|
+
>
|
|
786
|
+
<p className="text-sm font-semibold">{priceLabel}</p>
|
|
787
|
+
<p className="text-sm text-muted-foreground">
|
|
788
|
+
{entryDetails.description}
|
|
789
|
+
</p>
|
|
790
|
+
{entryDetails.action}
|
|
791
|
+
</PopoverContent>
|
|
792
|
+
</Popover>
|
|
793
|
+
) : fee.kind === "free" ? (
|
|
736
794
|
<span
|
|
737
795
|
className={cn(
|
|
738
796
|
CHIP,
|
|
@@ -742,19 +800,33 @@ export function EventDetailChipRow({
|
|
|
742
800
|
<Gift className="h-3 w-3" aria-hidden />
|
|
743
801
|
{labels.free}
|
|
744
802
|
</span>
|
|
745
|
-
)
|
|
746
|
-
{fee.kind === "paid" && (
|
|
803
|
+
) : fee.kind === "paid" ? (
|
|
747
804
|
<span className={cn(CHIP, "text-foreground")}>
|
|
748
805
|
<Ticket className="h-3 w-3" aria-hidden />
|
|
749
806
|
{formatters.price(fee.amountRon)}
|
|
750
807
|
</span>
|
|
751
|
-
)}
|
|
808
|
+
) : null}
|
|
752
809
|
{hype}
|
|
753
|
-
{tags?.map((tag) =>
|
|
754
|
-
|
|
755
|
-
{tag}
|
|
756
|
-
|
|
757
|
-
|
|
810
|
+
{tags?.map((tag) =>
|
|
811
|
+
typeof tag === "string" ? (
|
|
812
|
+
<span key={tag} className="text-xs text-muted-foreground">
|
|
813
|
+
{tag}
|
|
814
|
+
</span>
|
|
815
|
+
) : (
|
|
816
|
+
<Button
|
|
817
|
+
key={tag.href}
|
|
818
|
+
asChild
|
|
819
|
+
variant="outline"
|
|
820
|
+
size="sm"
|
|
821
|
+
className="min-h-11 rounded-full"
|
|
822
|
+
>
|
|
823
|
+
<LinkComponent href={tag.href} onClick={tag.onClick}>
|
|
824
|
+
{tag.label}
|
|
825
|
+
<ChevronRight className="h-3.5 w-3.5" aria-hidden />
|
|
826
|
+
</LinkComponent>
|
|
827
|
+
</Button>
|
|
828
|
+
),
|
|
829
|
+
)}
|
|
758
830
|
</div>
|
|
759
831
|
);
|
|
760
832
|
}
|
|
@@ -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
|
+
}
|