@bearmenu/ui 0.8.7 → 0.8.9
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 → chunk-3TNPSN4D.js} +28 -9
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AHL45DD2.js +145 -0
- package/dist/chunk-AXV5O2IZ.js +58 -0
- package/dist/chunk-IYDH5IU6.js +66 -0
- package/dist/chunk-OPVKL4VS.js +18 -0
- package/dist/chunk-Q6QH52LJ.js +38 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-THOVPZNN.js +37 -0
- package/dist/chunk-YS6BHKZO.js +92 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/category-deck.js +10 -1
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-compare.d.ts +73 -0
- package/dist/components/concept-compare.js +162 -0
- package/dist/components/coverflow.d.ts +11 -3
- package/dist/components/coverflow.js +34 -51
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/drift-rail.d.ts +24 -10
- package/dist/components/drift-rail.js +1 -1
- package/dist/components/event-card.d.ts +8 -0
- package/dist/components/event-card.js +64 -34
- package/dist/components/event-compact-row.d.ts +33 -6
- package/dist/components/event-compact-row.js +120 -39
- package/dist/components/event-day-header.js +3 -1
- package/dist/components/event-day-separator.d.ts +7 -1
- package/dist/components/event-day-separator.js +3 -2
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +471 -174
- package/dist/components/event-hub-hero.d.ts +25 -4
- package/dist/components/event-hub-hero.js +89 -19
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.d.ts +19 -6
- package/dist/components/event-list-row.js +79 -35
- package/dist/components/hour-band.d.ts +38 -40
- package/dist/components/hour-band.js +55 -27
- package/dist/components/hour-group.js +1 -1
- package/dist/components/hub-surface.d.ts +27 -0
- package/dist/components/hub-surface.js +20 -0
- package/dist/components/menu-family-card.d.ts +59 -0
- package/dist/components/menu-family-card.js +121 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +3 -0
- package/dist/components/menu-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -0
- package/dist/components/menu-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -0
- package/dist/components/menu-mocks.d.ts +55 -0
- package/dist/components/menu-mocks.js +379 -0
- package/dist/components/menu-types.d.ts +245 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-set.js +15 -3
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- package/dist/components/place-types.d.ts +112 -1
- package/dist/components/poster-tape.js +1 -1
- package/dist/components/poster-wall.d.ts +3 -3
- package/dist/components/poster-wall.js +73 -17
- package/dist/components/ranked-venue-list.d.ts +49 -0
- package/dist/components/ranked-venue-list.js +87 -0
- package/dist/components/section-header.d.ts +16 -1
- package/dist/components/section-header.js +26 -7
- package/dist/components/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/signature-line-list.d.ts +45 -0
- package/dist/components/signature-line-list.js +99 -0
- package/dist/components/snap-rail.d.ts +54 -0
- package/dist/components/snap-rail.js +109 -0
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-band.d.ts +20 -2
- package/dist/components/timeline-band.js +1 -1
- package/dist/components/timeline-playhead.d.ts +26 -0
- package/dist/components/timeline-playhead.js +25 -0
- package/dist/components/venue-busyness-strip.d.ts +35 -0
- package/dist/components/venue-busyness-strip.js +37 -0
- package/dist/components/venue-grade-dimensions.d.ts +36 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-signature-block.d.ts +39 -0
- package/dist/components/venue-signature-block.js +6 -0
- package/dist/components/venue-spotlight.d.ts +74 -0
- package/dist/components/venue-spotlight.js +138 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.d.ts +5 -4
- package/dist/components/weekend-panel.js +85 -71
- package/dist/hooks/use-drag-swipe.d.ts +44 -0
- package/dist/hooks/use-drag-swipe.js +2 -0
- package/dist/hooks/use-media-query.d.ts +17 -0
- package/dist/hooks/use-media-query.js +2 -0
- package/package.json +1 -1
- package/src/components/category-deck.tsx +19 -8
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare.stories.tsx +104 -0
- package/src/components/concept-compare.tsx +276 -0
- package/src/components/coverflow.test.tsx +28 -1
- package/src/components/coverflow.tsx +37 -43
- package/src/components/drift-rail.tsx +167 -42
- package/src/components/event-card.tsx +55 -10
- package/src/components/event-compact-row.stories.tsx +30 -0
- package/src/components/event-compact-row.test.tsx +49 -0
- package/src/components/event-compact-row.tsx +162 -38
- package/src/components/event-day-header.tsx +3 -1
- package/src/components/event-day-separator.tsx +8 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- package/src/components/event-hub-hero.stories.tsx +30 -0
- package/src/components/event-hub-hero.tsx +102 -30
- package/src/components/event-list-row.stories.tsx +34 -0
- package/src/components/event-list-row.test.tsx +53 -0
- package/src/components/event-list-row.tsx +135 -87
- package/src/components/hour-band.stories.tsx +28 -5
- package/src/components/hour-band.tsx +90 -70
- package/src/components/hub-surface.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +40 -0
- package/src/components/menu-family-card.stories.tsx +112 -0
- package/src/components/menu-family-card.tsx +211 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- package/src/components/menu-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -0
- package/src/components/menu-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -0
- package/src/components/menu-mocks.ts +417 -0
- package/src/components/menu-types.ts +259 -0
- package/src/components/motion-icon-set.tsx +30 -3
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/poster-wall.test.tsx +28 -0
- package/src/components/poster-wall.tsx +96 -23
- package/src/components/ranked-venue-list.stories.tsx +80 -0
- package/src/components/ranked-venue-list.tsx +177 -0
- package/src/components/section-header.stories.tsx +24 -0
- package/src/components/section-header.test.tsx +28 -0
- package/src/components/section-header.tsx +45 -7
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/signature-line-list.stories.tsx +85 -0
- package/src/components/signature-line-list.tsx +136 -0
- package/src/components/snap-rail.stories.tsx +79 -0
- package/src/components/snap-rail.test.tsx +18 -0
- package/src/components/snap-rail.tsx +148 -0
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- package/src/components/timeline-band.tsx +52 -12
- package/src/components/timeline-playhead.stories.tsx +30 -0
- package/src/components/timeline-playhead.test.tsx +11 -0
- package/src/components/timeline-playhead.tsx +46 -0
- package/src/components/venue-busyness-strip.stories.tsx +66 -0
- package/src/components/venue-busyness-strip.tsx +64 -0
- package/src/components/venue-grade-dimensions.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-signature-block.stories.tsx +68 -0
- package/src/components/venue-signature-block.tsx +71 -0
- package/src/components/venue-spotlight.stories.tsx +141 -0
- package/src/components/venue-spotlight.tsx +236 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- package/src/components/weekend-panel.test.tsx +43 -0
- package/src/components/weekend-panel.tsx +142 -108
- package/src/hooks/use-drag-swipe.test.tsx +66 -0
- package/src/hooks/use-drag-swipe.ts +105 -0
- package/src/hooks/use-media-query.ts +31 -0
- package/dist/chunk-GPH6L4DX.js +0 -55
|
@@ -19,6 +19,7 @@ const meta: Meta<typeof EventHubHero> = {
|
|
|
19
19
|
component: EventHubHero,
|
|
20
20
|
tags: ["autodocs"],
|
|
21
21
|
args: {
|
|
22
|
+
statsLabel: "Key figures",
|
|
22
23
|
eyebrow: "Updated today",
|
|
23
24
|
title: "Everything happening in Bucharest",
|
|
24
25
|
intro:
|
|
@@ -64,3 +65,32 @@ export const NoAside: Story = {
|
|
|
64
65
|
export const Skeleton: Story = {
|
|
65
66
|
render: () => <EventHubHeroSkeleton aside={<div className="aspect-[3/2] rounded-xl bg-muted" />} />,
|
|
66
67
|
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Below `lg` the hero is a masthead: headline, one sub-line, and the figures
|
|
71
|
+
* as a chip rail bleeding to the screen edge. The `live` figure takes the
|
|
72
|
+
* success border and value. View at 390px.
|
|
73
|
+
*/
|
|
74
|
+
export const Masthead: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
statsLabel: "Key figures",
|
|
77
|
+
eyebrow: "Updated today · next 14 days",
|
|
78
|
+
title: "Events in Cluj-Napoca",
|
|
79
|
+
intro:
|
|
80
|
+
"Everything on across the city for the next two weeks, from tonight's quizzes to the weekend's big rooms.",
|
|
81
|
+
stats: [
|
|
82
|
+
{ value: "3", label: "on right now", tone: "live" },
|
|
83
|
+
{ value: "76", label: "this fortnight" },
|
|
84
|
+
{ value: "34", label: "venues" },
|
|
85
|
+
{ value: "12", label: "free entry" },
|
|
86
|
+
{ value: "18", label: "past midnight" },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
decorators: [
|
|
90
|
+
(Story) => (
|
|
91
|
+
<div className="w-[390px] bg-surface-2 px-4 pt-4">
|
|
92
|
+
<Story />
|
|
93
|
+
</div>
|
|
94
|
+
),
|
|
95
|
+
],
|
|
96
|
+
};
|
|
@@ -5,9 +5,20 @@ import { cn } from "../lib/utils";
|
|
|
5
5
|
import { Skeleton } from "./skeleton";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* EventHubHero — the hub's opening block: an eyebrow, a big
|
|
9
|
-
* intro
|
|
10
|
-
* stack, in the source design) on the right.
|
|
8
|
+
* EventHubHero — the hub's opening block. On desktop: an eyebrow, a big
|
|
9
|
+
* headline, an intro paragraph and a stats row on the left; an arbitrary slot
|
|
10
|
+
* (the poster stack, in the source design) on the right.
|
|
11
|
+
*
|
|
12
|
+
* BELOW `lg` IT IS A MASTHEAD, NOT A HERO. One headline line, the eyebrow
|
|
13
|
+
* demoted to a sub-line beneath it, no intro, and the figures as a swipeable
|
|
14
|
+
* rail of chips that bleeds to the screen edge. The first real content lands
|
|
15
|
+
* roughly 250px sooner than under the web-shaped hero — on a phone the page
|
|
16
|
+
* should show, not narrate. Same markup at every width; only the classes
|
|
17
|
+
* change, so the copy is authored once.
|
|
18
|
+
*
|
|
19
|
+
* A stat with `tone: "live"` is the one that is happening right now: its
|
|
20
|
+
* chip takes a `--success` border and value, the same live colour every
|
|
21
|
+
* events surface uses.
|
|
11
22
|
*
|
|
12
23
|
* All copy and stat values arrive pre-formatted — the DS holds no `Intl`
|
|
13
24
|
* locale and does not format numbers.
|
|
@@ -16,6 +27,8 @@ import { Skeleton } from "./skeleton";
|
|
|
16
27
|
export type EventHubHeroStat = {
|
|
17
28
|
value: string;
|
|
18
29
|
label: string;
|
|
30
|
+
/** `live` marks the figure that is happening right now. */
|
|
31
|
+
tone?: "default" | "live";
|
|
19
32
|
};
|
|
20
33
|
|
|
21
34
|
export type EventHubHeroProps = {
|
|
@@ -24,12 +37,28 @@ export type EventHubHeroProps = {
|
|
|
24
37
|
title: string;
|
|
25
38
|
intro: string;
|
|
26
39
|
stats: EventHubHeroStat[];
|
|
40
|
+
/**
|
|
41
|
+
* Accessible name for the stats rail, e.g. "Key figures". When given, the
|
|
42
|
+
* rail is a named, focusable region so a keyboard can reach chips clipped
|
|
43
|
+
* by the phone's edge. Optional so existing consumers keep compiling — but
|
|
44
|
+
* pass it: without a name the rail cannot be focusable, and a phone reader
|
|
45
|
+
* on a keyboard cannot reach the last figures.
|
|
46
|
+
*/
|
|
47
|
+
statsLabel?: string;
|
|
27
48
|
/** The right-column slot — the poster stack in the source design. */
|
|
28
49
|
aside?: React.ReactNode;
|
|
29
50
|
className?: string;
|
|
30
51
|
};
|
|
31
52
|
|
|
32
|
-
export function EventHubHero({
|
|
53
|
+
export function EventHubHero({
|
|
54
|
+
eyebrow,
|
|
55
|
+
title,
|
|
56
|
+
intro,
|
|
57
|
+
stats,
|
|
58
|
+
statsLabel,
|
|
59
|
+
aside,
|
|
60
|
+
className,
|
|
61
|
+
}: EventHubHeroProps) {
|
|
33
62
|
return (
|
|
34
63
|
<div
|
|
35
64
|
className={cn(
|
|
@@ -37,30 +66,70 @@ export function EventHubHero({ eyebrow, title, intro, stats, aside, className }:
|
|
|
37
66
|
className
|
|
38
67
|
)}
|
|
39
68
|
>
|
|
40
|
-
<div className="flex flex-col gap-4">
|
|
41
|
-
|
|
69
|
+
<div className="flex flex-col gap-1.5 lg:gap-4">
|
|
70
|
+
{/* Eyebrow above the title on desktop; the sub-line beneath it on a
|
|
71
|
+
phone. `order` swaps the position, the classes swap the voice. */}
|
|
72
|
+
<p
|
|
73
|
+
className={cn(
|
|
74
|
+
"order-2 text-[13px] text-muted-foreground",
|
|
75
|
+
"lg:order-none lg:text-[11.5px] lg:font-extrabold lg:tracking-[0.12em]"
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
42
78
|
{eyebrow}
|
|
43
79
|
</p>
|
|
44
80
|
|
|
45
|
-
<h1
|
|
81
|
+
<h1
|
|
82
|
+
className={cn(
|
|
83
|
+
"order-1 text-[26px] font-black leading-[1.1] tracking-[-0.035em] text-foreground",
|
|
84
|
+
"lg:order-none lg:text-[42px] lg:leading-[1.05] lg:tracking-[-0.04em]"
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
46
87
|
{title}
|
|
47
88
|
</h1>
|
|
48
89
|
|
|
49
|
-
<p className="max-w-[520px] text-[
|
|
90
|
+
<p className="hidden max-w-[520px] text-[15px] leading-[1.65] text-muted-foreground lg:order-none lg:block">
|
|
50
91
|
{intro}
|
|
51
92
|
</p>
|
|
52
93
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
{/* Figures. A chip rail below `lg` — `-mx-4 px-4` runs it under the
|
|
95
|
+
page gutter so the last chip is cut by the screen edge, which is
|
|
96
|
+
what says "swipe"; inline figures from `lg`. */}
|
|
97
|
+
<div
|
|
98
|
+
role={statsLabel ? "region" : undefined}
|
|
99
|
+
aria-label={statsLabel}
|
|
100
|
+
tabIndex={statsLabel ? 0 : undefined}
|
|
101
|
+
className={cn(
|
|
102
|
+
"order-3 -mx-4 flex gap-2 overflow-x-auto px-4 pt-2 scrollbar-hide",
|
|
103
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
104
|
+
"lg:order-none lg:mx-0 lg:gap-[30px] lg:overflow-visible lg:px-0 lg:pt-1.5"
|
|
105
|
+
)}
|
|
106
|
+
>
|
|
107
|
+
{stats.map((stat) => {
|
|
108
|
+
const isLive = stat.tone === "live";
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
key={stat.label}
|
|
112
|
+
className={cn(
|
|
113
|
+
"flex min-w-[106px] shrink-0 flex-col rounded-[14px] border bg-card px-[13px] py-2.5",
|
|
114
|
+
isLive ? "border-success/40" : "border-border",
|
|
115
|
+
"lg:min-w-0 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0"
|
|
116
|
+
)}
|
|
117
|
+
>
|
|
118
|
+
<span
|
|
119
|
+
className={cn(
|
|
120
|
+
"text-[19px] font-black leading-none tracking-[-0.02em] tabular-nums",
|
|
121
|
+
isLive ? "text-success" : "text-foreground",
|
|
122
|
+
"lg:text-[30px] lg:tracking-[-0.03em]"
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{stat.value}
|
|
126
|
+
</span>
|
|
127
|
+
<span className="mt-0.5 text-[11px] text-muted-foreground lg:mt-[5px] lg:text-[12.5px]">
|
|
128
|
+
{stat.label}
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
64
133
|
</div>
|
|
65
134
|
</div>
|
|
66
135
|
|
|
@@ -84,21 +153,24 @@ export function EventHubHeroSkeleton({ aside, className }: EventHubHeroSkeletonP
|
|
|
84
153
|
className
|
|
85
154
|
)}
|
|
86
155
|
>
|
|
87
|
-
<div className="flex flex-col gap-4">
|
|
88
|
-
<Skeleton className="h-[11.5px] w-28" />
|
|
156
|
+
<div className="flex flex-col gap-1.5 lg:gap-4">
|
|
157
|
+
<Skeleton className="order-2 h-[13px] w-40 lg:order-none lg:h-[11.5px] lg:w-28" />
|
|
89
158
|
|
|
90
|
-
<Skeleton className="h-[
|
|
159
|
+
<Skeleton className="order-1 h-[26px] w-full max-w-[420px] lg:order-none lg:h-[42px]" />
|
|
91
160
|
|
|
92
|
-
<div className="
|
|
93
|
-
<Skeleton className="h-[
|
|
94
|
-
<Skeleton className="h-[
|
|
161
|
+
<div className="hidden max-w-[520px] flex-col gap-1.5 lg:flex">
|
|
162
|
+
<Skeleton className="h-[15px] w-full" />
|
|
163
|
+
<Skeleton className="h-[15px] w-4/5" />
|
|
95
164
|
</div>
|
|
96
165
|
|
|
97
|
-
<div className="flex gap-
|
|
98
|
-
{Array.from({ length:
|
|
99
|
-
<div
|
|
100
|
-
|
|
101
|
-
|
|
166
|
+
<div className="order-3 -mx-4 flex gap-2 overflow-hidden px-4 pt-2 lg:order-none lg:mx-0 lg:gap-[30px] lg:px-0 lg:pt-1.5">
|
|
167
|
+
{Array.from({ length: 4 }, (_, index) => (
|
|
168
|
+
<div
|
|
169
|
+
key={index}
|
|
170
|
+
className="flex min-w-[106px] shrink-0 flex-col rounded-[14px] border border-border bg-card px-[13px] py-2.5 lg:min-w-0 lg:rounded-none lg:border-0 lg:bg-transparent lg:p-0"
|
|
171
|
+
>
|
|
172
|
+
<Skeleton className="h-[19px] w-8 lg:h-[30px] lg:w-10" />
|
|
173
|
+
<Skeleton className="mt-0.5 h-[11px] w-16 lg:mt-[5px] lg:h-[12.5px]" />
|
|
102
174
|
</div>
|
|
103
175
|
))}
|
|
104
176
|
</div>
|
|
@@ -157,3 +157,37 @@ export const ADay: Story = {
|
|
|
157
157
|
</div>
|
|
158
158
|
),
|
|
159
159
|
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Inside a dated group the heading names the day, so the row drops its badge
|
|
163
|
+
* and leads with the poster. This is the list view's shipped shape.
|
|
164
|
+
*/
|
|
165
|
+
export const InDatedGroup: Story = {
|
|
166
|
+
render: () => (
|
|
167
|
+
<div className="flex flex-col">
|
|
168
|
+
<EventListRow
|
|
169
|
+
event={event("g1", "Hat Design Workshop with Matira Hats", "Del Mars", "2026-09-07T17:00:00", 795)}
|
|
170
|
+
labels={labels}
|
|
171
|
+
formatters={formatters}
|
|
172
|
+
tags={["Workshop"]}
|
|
173
|
+
hideDate
|
|
174
|
+
hideRule
|
|
175
|
+
/>
|
|
176
|
+
<EventListRow
|
|
177
|
+
event={event("g2", "International Quiz at Yolka", "Yolka", "2026-09-07T19:00:00", 0)}
|
|
178
|
+
labels={labels}
|
|
179
|
+
formatters={formatters}
|
|
180
|
+
tags={["Drinks", "Games"]}
|
|
181
|
+
isLive
|
|
182
|
+
hideDate
|
|
183
|
+
/>
|
|
184
|
+
<EventListRow
|
|
185
|
+
event={event("g3", "Klausen Quiz by GOtrivia", "Klausen Pubhouse", "2026-09-07T20:00:00", 20)}
|
|
186
|
+
labels={labels}
|
|
187
|
+
formatters={formatters}
|
|
188
|
+
tags={["Drinks", "Games"]}
|
|
189
|
+
hideDate
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
),
|
|
193
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { EventListRow } from "./event-list-row";
|
|
4
|
+
import type { EventDateBadgeFormatters, EventListRowLabels, EventPreview } from "./event-types";
|
|
5
|
+
|
|
6
|
+
const labels: EventListRowLabels = { free: "Free", liveNow: "Live now", soldOut: "Sold out" };
|
|
7
|
+
|
|
8
|
+
const formatters: EventDateBadgeFormatters = {
|
|
9
|
+
time: () => "20:00",
|
|
10
|
+
day: () => "Mon, Sep 7",
|
|
11
|
+
price: (amount) => `${amount} RON`,
|
|
12
|
+
dayNumber: () => "07",
|
|
13
|
+
monthShort: () => "SEP",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const event: EventPreview = {
|
|
17
|
+
id: "e1",
|
|
18
|
+
title: "Klausen Quiz by GOtrivia",
|
|
19
|
+
location: "Klausen Pubhouse",
|
|
20
|
+
starts_at: "2026-09-07T20:00:00Z",
|
|
21
|
+
place_name: "Klausen Pubhouse",
|
|
22
|
+
entry_fee: 20,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe("EventListRow", () => {
|
|
26
|
+
it("renders the date badge by default and drops it with hideDate", () => {
|
|
27
|
+
const { rerender } = render(<EventListRow event={event} labels={labels} formatters={formatters} />);
|
|
28
|
+
expect(screen.getByText("07")).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByText("SEP")).toBeInTheDocument();
|
|
30
|
+
|
|
31
|
+
rerender(<EventListRow event={event} labels={labels} formatters={formatters} hideDate />);
|
|
32
|
+
expect(screen.queryByText("07")).not.toBeInTheDocument();
|
|
33
|
+
expect(screen.queryByText("SEP")).not.toBeInTheDocument();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("keeps the fee, the time and the venue with the badge gone", () => {
|
|
37
|
+
render(<EventListRow event={event} labels={labels} formatters={formatters} hideDate />);
|
|
38
|
+
expect(screen.getByText("20 RON")).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText("20:00")).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText("Klausen Pubhouse")).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("replaces the fee with the sold-out chip", () => {
|
|
44
|
+
render(<EventListRow event={event} labels={labels} formatters={formatters} isSoldOut />);
|
|
45
|
+
expect(screen.getByText("Sold out")).toBeInTheDocument();
|
|
46
|
+
expect(screen.queryByText("20 RON")).not.toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("shows the live chip when isLive", () => {
|
|
50
|
+
render(<EventListRow event={event} labels={labels} formatters={formatters} isLive hideDate />);
|
|
51
|
+
expect(screen.getByText("Live now")).toBeInTheDocument();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -34,11 +34,17 @@ import {
|
|
|
34
34
|
* has the width for one and its row root is a container rather than a single
|
|
35
35
|
* anchor. If a CTA comes back here, it has to be a real one, not a span.
|
|
36
36
|
*
|
|
37
|
-
* NOTE ON WEIGHT:
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* NOTE ON WEIGHT: the title and the fee are the only bold things in this row.
|
|
38
|
+
* Eight of these stack per screen, and at that density hierarchy comes from
|
|
39
|
+
* size and ink tier as much as weight — so everything below the title line is
|
|
40
|
+
* regular, muted, and small. The fee is bold because it is the row's one
|
|
41
|
+
* inducement, and it sits on the TITLE line, right-aligned, where a price is
|
|
42
|
+
* read against the thing it buys rather than lost among the meta.
|
|
43
|
+
*
|
|
44
|
+
* NOTE ON THE DATE BADGE: `hideDate` drops it. Inside a dated group — the list
|
|
45
|
+
* view's "Today · Monday 7 September" separators — every row's badge repeats
|
|
46
|
+
* the heading eight times per screen; the heading carries the date and the
|
|
47
|
+
* rows carry the time. The badge stays available for undated lists.
|
|
42
48
|
*
|
|
43
49
|
* Same contract as the rest: DS-owned view model, injected labels and
|
|
44
50
|
* formatters, plain `<img>`, anchor whenever `href` is given.
|
|
@@ -86,6 +92,11 @@ export type EventListRowProps = {
|
|
|
86
92
|
* line. Same prop and same reason as `EventListRowWide` and `EventIndexRow`.
|
|
87
93
|
*/
|
|
88
94
|
hideRule?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Drops the leading date badge. Pass it when the rows sit under a dated
|
|
97
|
+
* heading that already names the day; the row then leads with its poster.
|
|
98
|
+
*/
|
|
99
|
+
hideDate?: boolean;
|
|
89
100
|
};
|
|
90
101
|
|
|
91
102
|
export const EventListRow = memo(function EventListRow({
|
|
@@ -102,6 +113,7 @@ export const EventListRow = memo(function EventListRow({
|
|
|
102
113
|
tags,
|
|
103
114
|
fallback = <EventPosterFallback />,
|
|
104
115
|
hideRule,
|
|
116
|
+
hideDate,
|
|
105
117
|
}: EventListRowProps) {
|
|
106
118
|
const [imgError, setImgError] = useState(false);
|
|
107
119
|
|
|
@@ -132,28 +144,35 @@ export const EventListRow = memo(function EventListRow({
|
|
|
132
144
|
`isLive` alone, so the only filled badge on screen is the live one.
|
|
133
145
|
The geometry is identical either way, so nothing shifts when the
|
|
134
146
|
tint appears. */}
|
|
135
|
-
|
|
136
|
-
className={cn("w-[42px] shrink-0 rounded-[9px] py-1.5 text-center", isLive && "bg-success/10")}
|
|
137
|
-
>
|
|
138
|
-
<div
|
|
139
|
-
className={cn(
|
|
140
|
-
"text-sm font-semibold leading-none tabular-nums",
|
|
141
|
-
isLive ? "text-success" : "text-foreground"
|
|
142
|
-
)}
|
|
143
|
-
>
|
|
144
|
-
{formatters.dayNumber(event.starts_at)}
|
|
145
|
-
</div>
|
|
147
|
+
{!hideDate && (
|
|
146
148
|
<div
|
|
147
149
|
className={cn(
|
|
148
|
-
"
|
|
149
|
-
isLive
|
|
150
|
+
"w-[42px] shrink-0 rounded-[9px] py-1.5 text-center",
|
|
151
|
+
isLive && "bg-success/10",
|
|
150
152
|
)}
|
|
151
153
|
>
|
|
152
|
-
|
|
154
|
+
<div
|
|
155
|
+
className={cn(
|
|
156
|
+
"text-sm font-semibold leading-none tabular-nums",
|
|
157
|
+
isLive ? "text-success" : "text-foreground",
|
|
158
|
+
)}
|
|
159
|
+
>
|
|
160
|
+
{formatters.dayNumber(event.starts_at)}
|
|
161
|
+
</div>
|
|
162
|
+
<div
|
|
163
|
+
className={cn(
|
|
164
|
+
"mt-[3px] text-[10px] font-semibold tracking-[0.06em]",
|
|
165
|
+
isLive ? "text-success" : "text-muted-foreground",
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
{formatters.monthShort(event.starts_at)}
|
|
169
|
+
</div>
|
|
153
170
|
</div>
|
|
154
|
-
|
|
171
|
+
)}
|
|
155
172
|
|
|
156
|
-
|
|
173
|
+
{/* Portrait, for the same reason `EventCompactRow` gives: most posters
|
|
174
|
+
are 4:5 flyers and a square crop throws away the act and the date. */}
|
|
175
|
+
<div className="aspect-[4/5] w-[58px] shrink-0 overflow-hidden rounded-[10px] bg-muted">
|
|
157
176
|
{showPoster ? (
|
|
158
177
|
// eslint-disable-next-line @next/next/no-img-element
|
|
159
178
|
<img
|
|
@@ -170,41 +189,30 @@ export const EventListRow = memo(function EventListRow({
|
|
|
170
189
|
</div>
|
|
171
190
|
|
|
172
191
|
<div className="min-w-0 flex-1">
|
|
173
|
-
{/*
|
|
174
|
-
|
|
175
|
-
anything.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
184
|
-
<span className="shrink-0 tabular-nums text-foreground">
|
|
185
|
-
{formatters.time(event.starts_at)}
|
|
186
|
-
</span>
|
|
187
|
-
|
|
188
|
-
<span className="flex min-w-0 flex-1 items-center gap-1">
|
|
189
|
-
<MapPin className="h-3 w-3 shrink-0" aria-hidden />
|
|
190
|
-
<span className="truncate">{venue}</span>
|
|
191
|
-
</span>
|
|
192
|
-
|
|
193
|
-
{/* A sold-out event's price is not a fact anyone needs, so the chip
|
|
194
|
-
takes the fee's slot rather than sitting beside it. The fee stays
|
|
195
|
-
on THIS line rather than joining the chips: it is the row's only
|
|
196
|
-
inducement, and among decorative chips it would either become one
|
|
197
|
-
or duplicate the live chip's green with a second meaning. */}
|
|
192
|
+
{/* TITLE with the fee pinned right. Full title, never clamped: the
|
|
193
|
+
row is `items-start`, so extra lines grow the row downward past
|
|
194
|
+
the poster instead of squeezing anything. The fee is the one bold
|
|
195
|
+
thing beside it; a sold-out event's price is not a fact anyone
|
|
196
|
+
needs, so the chip takes the fee's slot rather than sitting
|
|
197
|
+
beside it. */}
|
|
198
|
+
<div className="flex items-start gap-2.5">
|
|
199
|
+
<div className="min-w-0 flex-1 text-[14px] font-bold leading-[1.3] tracking-[-0.01em]">
|
|
200
|
+
{event.title}
|
|
201
|
+
</div>
|
|
198
202
|
{isSoldOut ? (
|
|
199
|
-
<Tag
|
|
203
|
+
<Tag
|
|
204
|
+
size="sm"
|
|
205
|
+
variant="quiet"
|
|
206
|
+
className="shrink-0 whitespace-nowrap"
|
|
207
|
+
>
|
|
200
208
|
{labels.soldOut}
|
|
201
209
|
</Tag>
|
|
202
210
|
) : (
|
|
203
211
|
priceText && (
|
|
204
212
|
<span
|
|
205
213
|
className={cn(
|
|
206
|
-
"shrink-0 tabular-nums",
|
|
207
|
-
fee.kind === "free"
|
|
214
|
+
"shrink-0 text-[13.5px] font-extrabold tabular-nums",
|
|
215
|
+
fee.kind === "free" ? "text-success" : "text-foreground",
|
|
208
216
|
)}
|
|
209
217
|
>
|
|
210
218
|
{priceText}
|
|
@@ -213,32 +221,56 @@ export const EventListRow = memo(function EventListRow({
|
|
|
213
221
|
)}
|
|
214
222
|
</div>
|
|
215
223
|
|
|
216
|
-
{/*
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
the
|
|
224
|
+
{/* WHEN and WHERE, then the live chip. Time leads because a
|
|
225
|
+
time-sorted day group scans down that column. The `MapPin` is the
|
|
226
|
+
venue's field marker, so the dot before it is the only separator.
|
|
227
|
+
Live is the only FILLED chip on the surface — under deuteranopia
|
|
228
|
+
the green/grey hues converge, so the fill and the `Radio` glyph
|
|
229
|
+
are what actually separate it, not the hue. */}
|
|
230
|
+
<div className="mt-1 flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
|
|
231
|
+
<span className="shrink-0 font-mono tabular-nums">
|
|
232
|
+
{formatters.time(event.starts_at)}
|
|
233
|
+
</span>
|
|
234
|
+
<span aria-hidden className="text-border">
|
|
235
|
+
·
|
|
236
|
+
</span>
|
|
237
|
+
<span className="flex min-w-0 items-center gap-1">
|
|
238
|
+
<MapPin className="h-3 w-3 shrink-0" aria-hidden />
|
|
239
|
+
<span className="truncate">{venue}</span>
|
|
240
|
+
</span>
|
|
241
|
+
{isLive && (
|
|
242
|
+
<Tag
|
|
243
|
+
size="sm"
|
|
244
|
+
variant="success"
|
|
245
|
+
className="shrink-0 whitespace-nowrap font-extrabold"
|
|
246
|
+
>
|
|
247
|
+
<Radio className="h-2.5 w-2.5" aria-hidden />
|
|
248
|
+
{labels.liveNow}
|
|
249
|
+
</Tag>
|
|
250
|
+
)}
|
|
251
|
+
</div>
|
|
220
252
|
|
|
221
|
-
|
|
222
|
-
already wraps, so a chip that does not fit moves to a
|
|
223
|
-
that is clipped away, and it disappears WHOLE. No
|
|
224
|
-
ellipsis inside a pill, and it is width-safe for
|
|
225
|
-
any length — which a fixed count is not.
|
|
226
|
-
counter: nothing in this row is tappable, so
|
|
227
|
-
promise a disclosure that does not exist, eight
|
|
253
|
+
{/* WHAT. `h-[22px] overflow-hidden` is the overflow mechanism:
|
|
254
|
+
`TagGroup` already wraps, so a chip that does not fit moves to a
|
|
255
|
+
second line that is clipped away, and it disappears WHOLE. No
|
|
256
|
+
half-chips, no ellipsis inside a pill, and it is width-safe for
|
|
257
|
+
caller strings of any length — which a fixed count is not.
|
|
258
|
+
Deliberately no "+2" counter: nothing in this row is tappable, so
|
|
259
|
+
a counter would promise a disclosure that does not exist, eight
|
|
260
|
+
times per screen.
|
|
228
261
|
|
|
229
262
|
The line collapses when there is nothing to say. A "Tags" filler
|
|
230
263
|
would be the same mistake as "Price TBA" — the row's spare slot
|
|
231
264
|
announcing an absence. */}
|
|
232
|
-
{
|
|
265
|
+
{tags && tags.length > 0 && (
|
|
233
266
|
<TagGroup className="mt-1.5 h-[22px] overflow-hidden">
|
|
234
|
-
{
|
|
235
|
-
<Tag
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
<Tag key={tag} size="sm" variant="quiet" className="shrink-0 whitespace-nowrap">
|
|
267
|
+
{tags.map((tag) => (
|
|
268
|
+
<Tag
|
|
269
|
+
key={tag}
|
|
270
|
+
size="sm"
|
|
271
|
+
variant="quiet"
|
|
272
|
+
className="shrink-0 whitespace-nowrap"
|
|
273
|
+
>
|
|
242
274
|
{tag}
|
|
243
275
|
</Tag>
|
|
244
276
|
))}
|
|
@@ -249,14 +281,14 @@ export const EventListRow = memo(function EventListRow({
|
|
|
249
281
|
);
|
|
250
282
|
|
|
251
283
|
const rootClasses = cn(
|
|
252
|
-
// `py-
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
"relative flex w-full items-start gap-3 py-
|
|
284
|
+
// `py-[11px]`: the poster is 72px tall and a one-line title, meta and a
|
|
285
|
+
// chip line come to ~70px, so the row is poster-driven at ~94px — about
|
|
286
|
+
// eight per screen, which is the list view's reason to exist.
|
|
287
|
+
"relative flex w-full items-start gap-3 py-[11px] text-left",
|
|
256
288
|
isSoldOut && "opacity-60",
|
|
257
289
|
(href || onClick) &&
|
|
258
290
|
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
259
|
-
className
|
|
291
|
+
className,
|
|
260
292
|
);
|
|
261
293
|
|
|
262
294
|
if (href) {
|
|
@@ -264,7 +296,9 @@ export const EventListRow = memo(function EventListRow({
|
|
|
264
296
|
<LinkComponent
|
|
265
297
|
href={href}
|
|
266
298
|
className={rootClasses}
|
|
267
|
-
onClick={
|
|
299
|
+
onClick={
|
|
300
|
+
onClick ? (mouseEvent) => onClick(event, mouseEvent) : undefined
|
|
301
|
+
}
|
|
268
302
|
>
|
|
269
303
|
{body}
|
|
270
304
|
</LinkComponent>
|
|
@@ -273,7 +307,11 @@ export const EventListRow = memo(function EventListRow({
|
|
|
273
307
|
|
|
274
308
|
if (onClick) {
|
|
275
309
|
return (
|
|
276
|
-
<button
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
className={rootClasses}
|
|
313
|
+
onClick={(e) => onClick(event, e)}
|
|
314
|
+
>
|
|
277
315
|
{body}
|
|
278
316
|
</button>
|
|
279
317
|
);
|
|
@@ -285,6 +323,8 @@ export const EventListRow = memo(function EventListRow({
|
|
|
285
323
|
export type EventListRowSkeletonProps = {
|
|
286
324
|
/** Matches `EventListRowProps["hideRule"]` — drop the top rule on the first row of a day group. */
|
|
287
325
|
hideRule?: boolean;
|
|
326
|
+
/** Matches `EventListRowProps["hideDate"]`. */
|
|
327
|
+
hideDate?: boolean;
|
|
288
328
|
/** Reserve the chip line. Matches the real row, where it collapses on events with no live status and no tags. */
|
|
289
329
|
showTags?: boolean;
|
|
290
330
|
className?: string;
|
|
@@ -293,34 +333,42 @@ export type EventListRowSkeletonProps = {
|
|
|
293
333
|
/** Mirrors `<EventListRow>`'s geometry exactly so nothing shifts when data lands. */
|
|
294
334
|
export function EventListRowSkeleton({
|
|
295
335
|
hideRule,
|
|
336
|
+
hideDate,
|
|
296
337
|
showTags = true,
|
|
297
338
|
className,
|
|
298
339
|
}: EventListRowSkeletonProps) {
|
|
299
340
|
return (
|
|
300
341
|
<div
|
|
301
342
|
aria-hidden="true"
|
|
302
|
-
className={cn(
|
|
343
|
+
className={cn(
|
|
344
|
+
"relative flex w-full items-start gap-3 py-[11px] text-left",
|
|
345
|
+
className,
|
|
346
|
+
)}
|
|
303
347
|
>
|
|
304
348
|
{!hideRule && <Separator className="absolute inset-x-0 top-0" />}
|
|
305
349
|
|
|
306
|
-
|
|
307
|
-
<
|
|
308
|
-
|
|
309
|
-
|
|
350
|
+
{!hideDate && (
|
|
351
|
+
<div className="w-[42px] shrink-0 rounded-[9px] py-1.5 text-center">
|
|
352
|
+
<Skeleton className="mx-auto h-4 w-5" />
|
|
353
|
+
<Skeleton className="mx-auto mt-[3px] h-[10px] w-6" />
|
|
354
|
+
</div>
|
|
355
|
+
)}
|
|
310
356
|
|
|
311
|
-
<Skeleton className="aspect-
|
|
357
|
+
<Skeleton className="aspect-[4/5] w-[58px] shrink-0 rounded-[10px]" />
|
|
312
358
|
|
|
313
359
|
<div className="min-w-0 flex-1">
|
|
314
|
-
<div className="
|
|
315
|
-
<
|
|
316
|
-
|
|
360
|
+
<div className="flex items-start gap-2.5">
|
|
361
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
362
|
+
<Skeleton className="h-[14px] w-full" />
|
|
363
|
+
<Skeleton className="h-[14px] w-2/3" />
|
|
364
|
+
</div>
|
|
365
|
+
<Skeleton className="h-[13.5px] w-12 shrink-0" />
|
|
317
366
|
</div>
|
|
318
367
|
|
|
319
|
-
<div className="mt-1 flex items-center gap-
|
|
368
|
+
<div className="mt-1 flex items-center gap-1.5">
|
|
320
369
|
<Skeleton className="h-3 w-10" />
|
|
321
370
|
<Skeleton className="h-3 w-3 shrink-0 rounded-full" />
|
|
322
371
|
<Skeleton className="h-3 w-24" />
|
|
323
|
-
<Skeleton className="ml-auto h-3 w-12" />
|
|
324
372
|
</div>
|
|
325
373
|
|
|
326
374
|
{showTags && (
|