@bearmenu/ui 0.8.4 → 0.8.7
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-637L7XQ3.js +85 -0
- package/dist/chunk-6XMYEZVR.js +26 -0
- package/dist/chunk-DNSXMHYX.js +8 -0
- package/dist/chunk-FDITZ2VM.js +16 -0
- package/dist/chunk-GPH6L4DX.js +55 -0
- package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
- package/dist/chunk-TT3WFS6S.js +379 -0
- package/dist/chunk-UXWAOISG.js +170 -0
- package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
- package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
- package/dist/components/card.d.ts +2 -2
- package/dist/components/card.js +4 -127
- package/dist/components/category-deck.d.ts +50 -0
- package/dist/components/category-deck.js +128 -0
- package/dist/components/concept-card.d.ts +43 -0
- package/dist/components/concept-card.js +81 -0
- package/dist/components/coverflow.d.ts +123 -0
- package/dist/components/coverflow.js +300 -0
- package/dist/components/day-rail-chip.d.ts +82 -0
- package/dist/components/day-rail-chip.js +105 -0
- package/dist/components/drift-rail.d.ts +28 -0
- package/dist/components/drift-rail.js +3 -0
- package/dist/components/event-card.d.ts +97 -0
- package/dist/components/event-card.js +164 -0
- package/dist/components/event-compact-row.d.ts +73 -0
- package/dist/components/event-compact-row.js +134 -0
- package/dist/components/event-day-header.d.ts +65 -0
- package/dist/components/event-day-header.js +95 -0
- package/dist/components/event-day-separator.d.ts +46 -0
- package/dist/components/event-day-separator.js +50 -0
- package/dist/components/event-detail-panel.d.ts +80 -0
- package/dist/components/event-detail-panel.js +251 -0
- package/dist/components/event-hub-hero.d.ts +34 -0
- package/dist/components/event-hub-hero.js +57 -0
- package/dist/components/event-index-row.d.ts +68 -0
- package/dist/components/event-index-row.js +84 -0
- package/dist/components/event-list-row-wide.d.ts +101 -0
- package/dist/components/event-list-row-wide.js +218 -0
- package/dist/components/event-list-row.d.ts +82 -0
- package/dist/components/event-list-row.js +173 -0
- package/dist/components/event-poster-fallback.d.ts +20 -0
- package/dist/components/event-poster-fallback.js +3 -0
- package/dist/components/event-types.d.ts +122 -0
- package/dist/components/event-types.js +2 -0
- package/dist/components/events-empty-state.d.ts +44 -0
- package/dist/components/events-empty-state.js +73 -0
- package/dist/components/facet-group.d.ts +93 -0
- package/dist/components/facet-group.js +222 -0
- package/dist/components/featured-rail-item.d.ts +42 -0
- package/dist/components/featured-rail-item.js +88 -0
- package/dist/components/hour-band.d.ts +68 -0
- package/dist/components/hour-band.js +53 -0
- package/dist/components/hour-group.d.ts +67 -0
- package/dist/components/hour-group.js +59 -0
- package/dist/components/motion-icon-controls.js +3 -379
- package/dist/components/multi-select-combobox.js +1 -1
- package/dist/components/place-about-tab.js +3 -3
- package/dist/components/place-details-mobile.js +3 -3
- package/dist/components/poster-stack.d.ts +30 -0
- package/dist/components/poster-stack.js +104 -0
- package/dist/components/poster-tape.d.ts +36 -0
- package/dist/components/poster-tape.js +64 -0
- package/dist/components/poster-wall.d.ts +25 -0
- package/dist/components/poster-wall.js +183 -0
- package/dist/components/quiet-stretch.d.ts +43 -0
- package/dist/components/quiet-stretch.js +54 -0
- package/dist/components/section-header.d.ts +53 -0
- package/dist/components/section-header.js +63 -0
- package/dist/components/segmented-nav.d.ts +62 -0
- package/dist/components/segmented-nav.js +54 -0
- package/dist/components/skeleton-card.js +1 -1
- package/dist/components/skeleton.d.ts +17 -0
- package/dist/components/skeleton.js +1 -1
- package/dist/components/tag-group.js +3 -26
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/tag.js +1 -1
- package/dist/components/timeline-band.d.ts +46 -0
- package/dist/components/timeline-band.js +5 -0
- package/dist/components/venue-events-block.d.ts +143 -0
- package/dist/components/venue-events-block.js +222 -0
- package/dist/components/weekend-panel.d.ts +44 -0
- package/dist/components/weekend-panel.js +231 -0
- package/package.json +1 -1
- package/src/components/card.tsx +44 -1
- package/src/components/category-deck.stories.tsx +96 -0
- package/src/components/category-deck.tsx +227 -0
- package/src/components/concept-card.stories.tsx +80 -0
- package/src/components/concept-card.tsx +143 -0
- package/src/components/coverflow.stories.tsx +240 -0
- package/src/components/coverflow.test.tsx +193 -0
- package/src/components/coverflow.tsx +550 -0
- package/src/components/day-rail-chip.stories.tsx +145 -0
- package/src/components/day-rail-chip.tsx +201 -0
- package/src/components/drift-rail.stories.tsx +92 -0
- package/src/components/drift-rail.tsx +86 -0
- package/src/components/event-card.stories.tsx +248 -0
- package/src/components/event-card.test.tsx +188 -0
- package/src/components/event-card.tsx +329 -0
- package/src/components/event-compact-row.stories.tsx +221 -0
- package/src/components/event-compact-row.test.tsx +51 -0
- package/src/components/event-compact-row.tsx +270 -0
- package/src/components/event-day-header.stories.tsx +124 -0
- package/src/components/event-day-header.tsx +180 -0
- package/src/components/event-day-separator.stories.tsx +70 -0
- package/src/components/event-day-separator.tsx +95 -0
- package/src/components/event-detail-panel.stories.tsx +191 -0
- package/src/components/event-detail-panel.tsx +360 -0
- package/src/components/event-hub-hero.stories.tsx +66 -0
- package/src/components/event-hub-hero.tsx +110 -0
- package/src/components/event-index-row.stories.tsx +116 -0
- package/src/components/event-index-row.tsx +181 -0
- package/src/components/event-list-row-wide.stories.tsx +204 -0
- package/src/components/event-list-row-wide.tsx +386 -0
- package/src/components/event-list-row.stories.tsx +159 -0
- package/src/components/event-list-row.tsx +335 -0
- package/src/components/event-poster-fallback.stories.tsx +52 -0
- package/src/components/event-poster-fallback.tsx +31 -0
- package/src/components/event-types.ts +137 -0
- package/src/components/events-empty-state.stories.tsx +59 -0
- package/src/components/events-empty-state.tsx +131 -0
- package/src/components/facet-group.stories.tsx +247 -0
- package/src/components/facet-group.test.tsx +225 -0
- package/src/components/facet-group.tsx +352 -0
- package/src/components/featured-rail-item.stories.tsx +86 -0
- package/src/components/featured-rail-item.tsx +153 -0
- package/src/components/hour-band.stories.tsx +159 -0
- package/src/components/hour-band.tsx +126 -0
- package/src/components/hour-group.stories.tsx +100 -0
- package/src/components/hour-group.tsx +124 -0
- package/src/components/poster-stack.stories.tsx +68 -0
- package/src/components/poster-stack.tsx +155 -0
- package/src/components/poster-tape.stories.tsx +70 -0
- package/src/components/poster-tape.tsx +122 -0
- package/src/components/poster-wall.stories.tsx +95 -0
- package/src/components/poster-wall.tsx +220 -0
- package/src/components/quiet-stretch.stories.tsx +62 -0
- package/src/components/quiet-stretch.tsx +115 -0
- package/src/components/section-header.stories.tsx +70 -0
- package/src/components/section-header.tsx +134 -0
- package/src/components/segmented-nav.stories.tsx +118 -0
- package/src/components/segmented-nav.tsx +113 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/tag.tsx +11 -1
- package/src/components/timeline-band.stories.tsx +206 -0
- package/src/components/timeline-band.tsx +207 -0
- package/src/components/venue-events-block.stories.tsx +216 -0
- package/src/components/venue-events-block.tsx +408 -0
- package/src/components/weekend-panel.stories.tsx +92 -0
- package/src/components/weekend-panel.tsx +322 -0
- package/src/globals.css +19 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { CalendarOff } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* EventsEmptyState — "nothing on in this city".
|
|
9
|
+
*
|
|
10
|
+
* BUILT FRESH, NOT COMPOSED FROM `empty-state.tsx`: that component's actions
|
|
11
|
+
* only take `onClick` (no `href`, no per-action icon, no link-component
|
|
12
|
+
* injection), and its buttons render through the shared `<Button>` variants,
|
|
13
|
+
* none of which produce this design's `bg-foreground`/`text-background`
|
|
14
|
+
* primary chip. Both actions here need to render either an `<a>` (via the
|
|
15
|
+
* same injected `linkComponent` contract as the rest of `Events/*`) or a
|
|
16
|
+
* `<button>`, which `empty-state.tsx` has no path for. Reusing it would mean
|
|
17
|
+
* bolting a second, incompatible action API onto it — the "compose it if it
|
|
18
|
+
* fits" bar isn't met here.
|
|
19
|
+
*
|
|
20
|
+
* Illustration follows the DS image-fallback rule: a real photo when
|
|
21
|
+
* `illustrationUrl` is given, otherwise a tinted gradient circle with a
|
|
22
|
+
* drop-shadowed icon — never a bare icon on a flat background.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type EventsEmptyStateLinkProps = {
|
|
26
|
+
href: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
children: ReactNode;
|
|
29
|
+
onClick?: (mouseEvent: React.MouseEvent) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function DefaultLink({ href, ...props }: EventsEmptyStateLinkProps) {
|
|
33
|
+
return <a href={href} {...props} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type EventsEmptyStateAction = {
|
|
37
|
+
label: string;
|
|
38
|
+
icon?: ReactNode;
|
|
39
|
+
href?: string;
|
|
40
|
+
onClick?: () => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type EventsEmptyStateProps = {
|
|
44
|
+
title: string;
|
|
45
|
+
body: string;
|
|
46
|
+
illustrationUrl?: string;
|
|
47
|
+
primaryAction?: EventsEmptyStateAction;
|
|
48
|
+
secondaryAction?: EventsEmptyStateAction;
|
|
49
|
+
linkComponent?: React.ComponentType<EventsEmptyStateLinkProps>;
|
|
50
|
+
className?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const ACTION_BASE =
|
|
54
|
+
"inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl border px-5 text-sm font-semibold transition-colors duration-150 motion-reduce:transition-none cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 sm:w-auto";
|
|
55
|
+
|
|
56
|
+
const PRIMARY_ACTION_CLASSES = cn(
|
|
57
|
+
ACTION_BASE,
|
|
58
|
+
"border-foreground bg-foreground text-background hover:bg-foreground/90"
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const SECONDARY_ACTION_CLASSES = cn(
|
|
62
|
+
ACTION_BASE,
|
|
63
|
+
"border-border bg-background text-foreground hover:bg-muted"
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
function renderAction(
|
|
67
|
+
action: EventsEmptyStateAction | undefined,
|
|
68
|
+
classes: string,
|
|
69
|
+
LinkComponent: React.ComponentType<EventsEmptyStateLinkProps>
|
|
70
|
+
) {
|
|
71
|
+
if (!action) return null;
|
|
72
|
+
|
|
73
|
+
if (action.href) {
|
|
74
|
+
return (
|
|
75
|
+
<LinkComponent
|
|
76
|
+
href={action.href}
|
|
77
|
+
className={classes}
|
|
78
|
+
onClick={action.onClick ? () => action.onClick?.() : undefined}
|
|
79
|
+
>
|
|
80
|
+
{action.icon}
|
|
81
|
+
{action.label}
|
|
82
|
+
</LinkComponent>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<button type="button" className={classes} onClick={action.onClick}>
|
|
88
|
+
{action.icon}
|
|
89
|
+
{action.label}
|
|
90
|
+
</button>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function EventsEmptyState({
|
|
95
|
+
title,
|
|
96
|
+
body,
|
|
97
|
+
illustrationUrl,
|
|
98
|
+
primaryAction,
|
|
99
|
+
secondaryAction,
|
|
100
|
+
linkComponent: LinkComponent = DefaultLink,
|
|
101
|
+
className,
|
|
102
|
+
}: EventsEmptyStateProps) {
|
|
103
|
+
return (
|
|
104
|
+
<div
|
|
105
|
+
role="status"
|
|
106
|
+
className={cn("flex flex-col items-center gap-[14px] pt-[44px] pb-[30px] text-center", className)}
|
|
107
|
+
>
|
|
108
|
+
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full sm:h-[120px] sm:w-[120px]">
|
|
109
|
+
{illustrationUrl ? (
|
|
110
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
111
|
+
<img src={illustrationUrl} alt="" className="h-full w-full object-cover" />
|
|
112
|
+
) : (
|
|
113
|
+
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-primary/15 to-primary/5">
|
|
114
|
+
<CalendarOff className="h-8 w-8 text-primary drop-shadow-sm sm:h-10 sm:w-10" aria-hidden />
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<h3 className="text-[18px] font-extrabold tracking-[-0.02em] sm:text-[20px]">{title}</h3>
|
|
120
|
+
|
|
121
|
+
<p className="max-w-[460px] text-[13px] leading-[1.6] text-muted-foreground sm:text-[13.5px]">{body}</p>
|
|
122
|
+
|
|
123
|
+
{(primaryAction || secondaryAction) && (
|
|
124
|
+
<div className="flex w-full flex-col gap-[9px] pt-1 sm:w-auto sm:flex-row sm:gap-[10px]">
|
|
125
|
+
{renderAction(primaryAction, PRIMARY_ACTION_CLASSES, LinkComponent)}
|
|
126
|
+
{renderAction(secondaryAction, SECONDARY_ACTION_CLASSES, LinkComponent)}
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { Banknote, CalendarDays, MapPin, Tag } from "lucide-react";
|
|
4
|
+
import { FacetGroup, FacetGroupSkeleton } from "./facet-group";
|
|
5
|
+
import type { FacetOption } from "./facet-group";
|
|
6
|
+
|
|
7
|
+
const meta: Meta<typeof FacetGroup> = {
|
|
8
|
+
title: "Events/FacetGroup",
|
|
9
|
+
component: FacetGroup,
|
|
10
|
+
tags: ["autodocs"],
|
|
11
|
+
decorators: [
|
|
12
|
+
(Story) => (
|
|
13
|
+
<div className="w-[260px]">
|
|
14
|
+
<Story />
|
|
15
|
+
</div>
|
|
16
|
+
),
|
|
17
|
+
],
|
|
18
|
+
parameters: {
|
|
19
|
+
docs: {
|
|
20
|
+
description: {
|
|
21
|
+
component:
|
|
22
|
+
"One filter group in the events sidebar or the mobile filter sheet. Each row is a real, keyboard-operable input (a visually-hidden native `<input>` plus a styled decorative box) grouped under a `<fieldset>`/`<legend>`. `selection=\"single\"` swaps checkboxes for radios sharing one `name`; `size` swaps row density without moving the box or the count column.",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
type Story = StoryObj<typeof FacetGroup>;
|
|
30
|
+
|
|
31
|
+
const priceOptions: FacetOption[] = [
|
|
32
|
+
{ id: "free", label: "Free", count: 12 },
|
|
33
|
+
{ id: "under-50", label: "Under 50 RON", count: 28, checked: true },
|
|
34
|
+
{ id: "50-100", label: "50–100 RON", count: 19 },
|
|
35
|
+
{ id: "over-100", label: "Over 100 RON", count: 4 },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const venueOptions: FacetOption[] = [
|
|
39
|
+
{
|
|
40
|
+
id: "control",
|
|
41
|
+
label: "Club Control Bucharest — Str. Constantin Mille 4",
|
|
42
|
+
count: 1287,
|
|
43
|
+
checked: true,
|
|
44
|
+
},
|
|
45
|
+
{ id: "expirat", label: "Expirat Halele Carol", count: 96 },
|
|
46
|
+
{ id: "quantic", label: "Quantic Pub & Concert Venue, Bulevardul Iuliu Maniu 128", count: 42 },
|
|
47
|
+
{ id: "linea", label: "Linea", count: 7 },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const whenOptions: FacetOption[] = [
|
|
51
|
+
{ id: "today", label: "Today", count: 14 },
|
|
52
|
+
{ id: "tomorrow", label: "Tomorrow", count: 9 },
|
|
53
|
+
{ id: "weekend", label: "This weekend", count: 31, checked: true },
|
|
54
|
+
{ id: "all", label: "Next 14 days", count: 128 },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export const Default: Story = {
|
|
58
|
+
args: { title: "Price", options: priceOptions },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const WithShowAll: Story = {
|
|
62
|
+
args: {
|
|
63
|
+
title: "Category",
|
|
64
|
+
options: [
|
|
65
|
+
{ id: "music", label: "Live music", count: 34, checked: true },
|
|
66
|
+
{ id: "nightlife", label: "Nightlife", count: 22 },
|
|
67
|
+
{ id: "comedy", label: "Comedy", count: 9 },
|
|
68
|
+
{ id: "food", label: "Food & drink", count: 15 },
|
|
69
|
+
],
|
|
70
|
+
moreLabel: "Show all",
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Single-select — the events list's WHEN scope holds exactly one value, so the
|
|
76
|
+
* rows are radios sharing one `name` with a dot instead of a check glyph.
|
|
77
|
+
* The 16px box, the focus ring and the count column are unchanged, so a group
|
|
78
|
+
* can switch mode without anything moving.
|
|
79
|
+
*/
|
|
80
|
+
export const SingleSelect: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
title: "When",
|
|
83
|
+
icon: CalendarDays,
|
|
84
|
+
options: whenOptions,
|
|
85
|
+
selection: "single",
|
|
86
|
+
name: "when-scope",
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Interactive single-select — picking one option releases the previous one, natively. */
|
|
91
|
+
export const SingleSelectInteractive: Story = {
|
|
92
|
+
render: () => {
|
|
93
|
+
const [scope, setScope] = useState("weekend");
|
|
94
|
+
return (
|
|
95
|
+
<FacetGroup
|
|
96
|
+
title="When"
|
|
97
|
+
icon={CalendarDays}
|
|
98
|
+
selection="single"
|
|
99
|
+
name="when-scope-interactive"
|
|
100
|
+
options={whenOptions.map((o) => ({ ...o, checked: o.id === scope }))}
|
|
101
|
+
onToggle={(id) => setScope(id)}
|
|
102
|
+
/>
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Multi- and single-select side by side — the two row shapes must line up exactly. */
|
|
108
|
+
export const SelectionModesCompared: Story = {
|
|
109
|
+
render: () => (
|
|
110
|
+
<div>
|
|
111
|
+
<FacetGroup
|
|
112
|
+
title="When"
|
|
113
|
+
selection="single"
|
|
114
|
+
name="when-compare"
|
|
115
|
+
options={whenOptions}
|
|
116
|
+
className="pt-0 [&>[data-slot=separator]]:hidden"
|
|
117
|
+
/>
|
|
118
|
+
<FacetGroup title="Price" options={priceOptions} />
|
|
119
|
+
</div>
|
|
120
|
+
),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* `compact` — ~32px rows with a hover-lit hit area, for a dense pointer-driven
|
|
125
|
+
* desktop sidebar. Never use it in the mobile sheet: it is below the 44px
|
|
126
|
+
* WCAG 2.5.5 target floor that the default `comfortable` clears.
|
|
127
|
+
*/
|
|
128
|
+
export const Compact: Story = {
|
|
129
|
+
args: { title: "Price", options: priceOptions, size: "compact" },
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Both densities stacked, so the density difference is visible at a glance. */
|
|
133
|
+
export const CompactVsComfortable: Story = {
|
|
134
|
+
render: () => (
|
|
135
|
+
<div>
|
|
136
|
+
<FacetGroup
|
|
137
|
+
title="Compact"
|
|
138
|
+
options={priceOptions}
|
|
139
|
+
size="compact"
|
|
140
|
+
className="pt-0 [&>[data-slot=separator]]:hidden"
|
|
141
|
+
/>
|
|
142
|
+
<FacetGroup title="Comfortable (default)" options={priceOptions} />
|
|
143
|
+
</div>
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Legend icons — decorative only (`aria-hidden`); the title stays the accessible name. */
|
|
148
|
+
export const WithLegendIcons: Story = {
|
|
149
|
+
render: () => (
|
|
150
|
+
<div>
|
|
151
|
+
<FacetGroup
|
|
152
|
+
title="When"
|
|
153
|
+
icon={CalendarDays}
|
|
154
|
+
selection="single"
|
|
155
|
+
name="when-icons"
|
|
156
|
+
options={whenOptions}
|
|
157
|
+
className="pt-0 [&>[data-slot=separator]]:hidden"
|
|
158
|
+
/>
|
|
159
|
+
<FacetGroup title="Price" icon={Banknote} options={priceOptions} />
|
|
160
|
+
<FacetGroup
|
|
161
|
+
title="Category"
|
|
162
|
+
icon={Tag}
|
|
163
|
+
options={[
|
|
164
|
+
{ id: "music", label: "Live music", count: 34, checked: true },
|
|
165
|
+
{ id: "nightlife", label: "Nightlife", count: 22 },
|
|
166
|
+
{ id: "comedy", label: "Comedy", count: 9 },
|
|
167
|
+
]}
|
|
168
|
+
moreLabel="Show all"
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Long labels truncate with an ellipsis and keep their full text in a `title`
|
|
176
|
+
* tooltip — the count column never gets squeezed off the right edge and the
|
|
177
|
+
* row never wraps to two lines.
|
|
178
|
+
*/
|
|
179
|
+
export const LongLabels: Story = {
|
|
180
|
+
args: { title: "Venue", icon: MapPin, options: venueOptions },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** The same long labels in `compact`, where the row box is tighter still. */
|
|
184
|
+
export const LongLabelsCompact: Story = {
|
|
185
|
+
args: { title: "Venue", icon: MapPin, options: venueOptions, size: "compact" },
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Loading state — identical geometry to `Default`'s four rows. */
|
|
189
|
+
export const Loading: Story = {
|
|
190
|
+
render: () => <FacetGroupSkeleton optionCount={4} className="pt-0 [&>[data-slot=separator]]:hidden" />,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Reserves space for the trailing "Show all" placeholder too. */
|
|
194
|
+
export const LoadingWithShowAll: Story = {
|
|
195
|
+
render: () => (
|
|
196
|
+
<FacetGroupSkeleton optionCount={4} showMore className="pt-0 [&>[data-slot=separator]]:hidden" />
|
|
197
|
+
),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** Loading above loaded, so any geometry drift is visible at a glance. */
|
|
201
|
+
export const LoadingVsLoaded: Story = {
|
|
202
|
+
render: () => (
|
|
203
|
+
<div>
|
|
204
|
+
<FacetGroupSkeleton
|
|
205
|
+
optionCount={priceOptions.length}
|
|
206
|
+
className="pt-0 [&>[data-slot=separator]]:hidden"
|
|
207
|
+
/>
|
|
208
|
+
<FacetGroup title="Price" options={priceOptions} />
|
|
209
|
+
</div>
|
|
210
|
+
),
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** The skeleton takes `size` too, or rows jump when data lands in a compact sidebar. */
|
|
214
|
+
export const LoadingVsLoadedCompact: Story = {
|
|
215
|
+
render: () => (
|
|
216
|
+
<div>
|
|
217
|
+
<FacetGroupSkeleton
|
|
218
|
+
optionCount={priceOptions.length}
|
|
219
|
+
size="compact"
|
|
220
|
+
className="pt-0 [&>[data-slot=separator]]:hidden"
|
|
221
|
+
/>
|
|
222
|
+
<FacetGroup title="Price" options={priceOptions} size="compact" />
|
|
223
|
+
</div>
|
|
224
|
+
),
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** Two groups stacked, as the sidebar actually renders them. */
|
|
228
|
+
export const SidebarStack: Story = {
|
|
229
|
+
render: () => (
|
|
230
|
+
<div>
|
|
231
|
+
<FacetGroup
|
|
232
|
+
title="Price"
|
|
233
|
+
options={priceOptions}
|
|
234
|
+
className="pt-0 [&>[data-slot=separator]]:hidden"
|
|
235
|
+
/>
|
|
236
|
+
<FacetGroup
|
|
237
|
+
title="Category"
|
|
238
|
+
options={[
|
|
239
|
+
{ id: "music", label: "Live music", count: 34, checked: true },
|
|
240
|
+
{ id: "nightlife", label: "Nightlife", count: 22 },
|
|
241
|
+
{ id: "comedy", label: "Comedy", count: 9 },
|
|
242
|
+
]}
|
|
243
|
+
moreLabel="Show all"
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
),
|
|
247
|
+
};
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { CalendarDays } from "lucide-react";
|
|
5
|
+
import { FacetGroup } from "./facet-group";
|
|
6
|
+
import type { FacetOption } from "./facet-group";
|
|
7
|
+
|
|
8
|
+
const whenOptions: FacetOption[] = [
|
|
9
|
+
{ id: "today", label: "Today", count: 14 },
|
|
10
|
+
{ id: "tomorrow", label: "Tomorrow", count: 9 },
|
|
11
|
+
{ id: "weekend", label: "This weekend", count: 31, checked: true },
|
|
12
|
+
{ id: "all", label: "Next 14 days", count: 128 },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const priceOptions: FacetOption[] = [
|
|
16
|
+
{ id: "free", label: "Free", count: 12 },
|
|
17
|
+
{ id: "under-50", label: "Under 50 RON", count: 28, checked: true },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
describe("FacetGroup", () => {
|
|
21
|
+
describe("selection mode", () => {
|
|
22
|
+
it("renders checkboxes by default", () => {
|
|
23
|
+
render(<FacetGroup title="Price" options={priceOptions} />);
|
|
24
|
+
|
|
25
|
+
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
|
|
26
|
+
expect(screen.queryAllByRole("radio")).toHaveLength(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// A single-valued facet rendered as checkboxes lies twice over: ticking one
|
|
30
|
+
// silently unticks another, and the a11y tree reports N independent boxes.
|
|
31
|
+
it("renders radios sharing one name under selection=\"single\"", () => {
|
|
32
|
+
render(
|
|
33
|
+
<FacetGroup title="When" options={whenOptions} selection="single" name="when-scope" />
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const radios = screen.getAllByRole("radio");
|
|
37
|
+
expect(radios).toHaveLength(4);
|
|
38
|
+
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
|
|
39
|
+
for (const radio of radios) {
|
|
40
|
+
expect(radio).toHaveAttribute("name", "when-scope");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("marks exactly the checked option as selected", () => {
|
|
45
|
+
render(
|
|
46
|
+
<FacetGroup title="When" options={whenOptions} selection="single" name="when-scope" />
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(screen.getByRole("radio", { name: /This weekend/ })).toBeChecked();
|
|
50
|
+
expect(screen.getByRole("radio", { name: /Today/ })).not.toBeChecked();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Without a fallback every unnamed radio on the page joins one anonymous
|
|
54
|
+
// group, so picking in one facet would clear an unrelated one.
|
|
55
|
+
it("derives a per-instance name when none is passed", () => {
|
|
56
|
+
render(
|
|
57
|
+
<div>
|
|
58
|
+
<FacetGroup title="When" options={whenOptions} selection="single" />
|
|
59
|
+
<FacetGroup title="Also when" options={whenOptions} selection="single" />
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const names = screen.getAllByRole("radio").map((radio) => radio.getAttribute("name"));
|
|
64
|
+
expect(names.every((name) => !!name)).toBe(true);
|
|
65
|
+
expect(new Set(names).size).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports the newly picked radio to onToggle", async () => {
|
|
69
|
+
const onToggle = vi.fn();
|
|
70
|
+
render(
|
|
71
|
+
<FacetGroup
|
|
72
|
+
title="When"
|
|
73
|
+
options={whenOptions}
|
|
74
|
+
selection="single"
|
|
75
|
+
name="when-scope"
|
|
76
|
+
onToggle={onToggle}
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
await userEvent.click(screen.getByRole("radio", { name: /Today/ }));
|
|
81
|
+
|
|
82
|
+
expect(onToggle).toHaveBeenCalledWith("today", true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("reports both directions to onToggle for checkboxes", async () => {
|
|
86
|
+
const onToggle = vi.fn();
|
|
87
|
+
render(<FacetGroup title="Price" options={priceOptions} onToggle={onToggle} />);
|
|
88
|
+
|
|
89
|
+
await userEvent.click(screen.getByRole("checkbox", { name: /Free/ }));
|
|
90
|
+
expect(onToggle).toHaveBeenCalledWith("free", true);
|
|
91
|
+
|
|
92
|
+
await userEvent.click(screen.getByRole("checkbox", { name: /Under 50 RON/ }));
|
|
93
|
+
expect(onToggle).toHaveBeenCalledWith("under-50", false);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("row density", () => {
|
|
98
|
+
// jsdom does no layout, so this asserts the class that carries the height
|
|
99
|
+
// rather than a measured pixel box. min-h-11 = 2.75rem = 44px, the WCAG
|
|
100
|
+
// 2.5.5 target floor the mobile filter sheet has to clear.
|
|
101
|
+
it("defaults to comfortable rows that clear the 44px target floor", () => {
|
|
102
|
+
render(<FacetGroup title="Price" options={priceOptions} />);
|
|
103
|
+
|
|
104
|
+
for (const input of screen.getAllByRole("checkbox")) {
|
|
105
|
+
expect(input.closest("label")).toHaveClass("min-h-11");
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("keeps the 44px floor under selection=\"single\" too", () => {
|
|
110
|
+
render(
|
|
111
|
+
<FacetGroup title="When" options={whenOptions} selection="single" name="when-scope" />
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
for (const input of screen.getAllByRole("radio")) {
|
|
115
|
+
expect(input.closest("label")).toHaveClass("min-h-11");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("drops the min-height and pads instead under size=\"compact\"", () => {
|
|
120
|
+
render(<FacetGroup title="Price" options={priceOptions} size="compact" />);
|
|
121
|
+
|
|
122
|
+
for (const input of screen.getAllByRole("checkbox")) {
|
|
123
|
+
const row = input.closest("label");
|
|
124
|
+
expect(row).not.toHaveClass("min-h-11");
|
|
125
|
+
expect(row).toHaveClass("py-1");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("dividers", () => {
|
|
131
|
+
it("draws no rule of its own", () => {
|
|
132
|
+
// A `<legend>` sits in the fieldset's BORDER box, so an absolutely
|
|
133
|
+
// positioned `top-0` rule landed between a group's label and its own
|
|
134
|
+
// options — cutting the group in half rather than dividing it from the
|
|
135
|
+
// next, and giving the first group a rule above nothing. Dividers between
|
|
136
|
+
// siblings belong to whatever owns the order, which is the caller.
|
|
137
|
+
const { container } = render(<FacetGroup title="Price" options={priceOptions} />);
|
|
138
|
+
expect(container.querySelector('[data-slot="separator"]')).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("collapsible", () => {
|
|
143
|
+
it("is not collapsible by default — no toggle, options exposed", () => {
|
|
144
|
+
render(<FacetGroup title="Price" options={priceOptions} />);
|
|
145
|
+
expect(screen.queryByRole("button", { name: /price/i })).toBeNull();
|
|
146
|
+
expect(screen.getAllByRole("checkbox")).toHaveLength(priceOptions.length);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("toggles its options and reports state on the legend button", async () => {
|
|
150
|
+
const user = userEvent.setup();
|
|
151
|
+
render(<FacetGroup title="Price" options={priceOptions} collapsible />);
|
|
152
|
+
|
|
153
|
+
const toggle = screen.getByRole("button", { name: /price/i });
|
|
154
|
+
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
155
|
+
|
|
156
|
+
await user.click(toggle);
|
|
157
|
+
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
158
|
+
// `hidden` takes them out of the accessibility tree, which is the point —
|
|
159
|
+
// a collapsed group must not leave its controls reachable by keyboard.
|
|
160
|
+
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
|
|
161
|
+
|
|
162
|
+
await user.click(toggle);
|
|
163
|
+
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
164
|
+
expect(screen.getAllByRole("checkbox")).toHaveLength(priceOptions.length);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("honours defaultOpen={false}", () => {
|
|
168
|
+
render(<FacetGroup title="Price" options={priceOptions} collapsible defaultOpen={false} />);
|
|
169
|
+
expect(screen.getByRole("button", { name: /price/i })).toHaveAttribute(
|
|
170
|
+
"aria-expanded",
|
|
171
|
+
"false"
|
|
172
|
+
);
|
|
173
|
+
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("keeps the title as the group's accessible name when collapsible", () => {
|
|
177
|
+
render(<FacetGroup title="Price" options={priceOptions} collapsible />);
|
|
178
|
+
expect(screen.getByRole("group", { name: /price/i })).toBeInTheDocument();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("legend", () => {
|
|
183
|
+
it("keeps the title as the accessible group name", () => {
|
|
184
|
+
render(<FacetGroup title="When" icon={CalendarDays} options={whenOptions} />);
|
|
185
|
+
|
|
186
|
+
expect(screen.getByRole("group", { name: "When" })).toBeInTheDocument();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("renders the icon as decorative only", () => {
|
|
190
|
+
const { container } = render(
|
|
191
|
+
<FacetGroup title="When" icon={CalendarDays} options={whenOptions} />
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const icon = container.querySelector("legend svg");
|
|
195
|
+
expect(icon).not.toBeNull();
|
|
196
|
+
expect(icon).toHaveAttribute("aria-hidden", "true");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("labels and counts", () => {
|
|
201
|
+
it("truncates the label and exposes the full text in a title tooltip", () => {
|
|
202
|
+
const label = "Quantic Pub & Concert Venue, Bulevardul Iuliu Maniu 128";
|
|
203
|
+
render(<FacetGroup title="Venue" options={[{ id: "q", label, count: 42 }]} />);
|
|
204
|
+
|
|
205
|
+
const labelSpan = screen.getByText(label);
|
|
206
|
+
expect(labelSpan).toHaveClass("truncate", "min-w-0");
|
|
207
|
+
expect(labelSpan).toHaveAttribute("title", label);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps the count from shrinking", () => {
|
|
211
|
+
render(<FacetGroup title="Price" options={priceOptions} />);
|
|
212
|
+
|
|
213
|
+
expect(screen.getByText("12")).toHaveClass("shrink-0");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("spreads rest props onto the fieldset", () => {
|
|
218
|
+
render(<FacetGroup title="When" options={whenOptions} data-facet-group="when" />);
|
|
219
|
+
|
|
220
|
+
expect(screen.getByRole("group", { name: "When" })).toHaveAttribute(
|
|
221
|
+
"data-facet-group",
|
|
222
|
+
"when"
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|