@bearmenu/ui 0.8.3 → 0.8.6

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.
Files changed (194) hide show
  1. package/dist/chunk-2WDJR6K7.js +76 -0
  2. package/dist/chunk-637L7XQ3.js +85 -0
  3. package/dist/chunk-6XMYEZVR.js +26 -0
  4. package/dist/chunk-DNSXMHYX.js +8 -0
  5. package/dist/chunk-FDITZ2VM.js +16 -0
  6. package/dist/chunk-GPH6L4DX.js +55 -0
  7. package/dist/{chunk-N2KNBPAQ.js → chunk-QXHMPJ35.js} +1 -0
  8. package/dist/chunk-TT3WFS6S.js +379 -0
  9. package/dist/chunk-UXWAOISG.js +170 -0
  10. package/dist/{chunk-UZ3PCIE5.js → chunk-XWMRABZW.js} +2 -2
  11. package/dist/{chunk-VBA45GLP.js → chunk-ZDAHKI3A.js} +12 -2
  12. package/dist/components/alert.d.ts +2 -2
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/card.d.ts +2 -2
  16. package/dist/components/card.js +4 -127
  17. package/dist/components/category-deck.d.ts +50 -0
  18. package/dist/components/category-deck.js +128 -0
  19. package/dist/components/command.d.ts +15 -15
  20. package/dist/components/concept-card.d.ts +43 -0
  21. package/dist/components/concept-card.js +81 -0
  22. package/dist/components/coverflow.d.ts +123 -0
  23. package/dist/components/coverflow.js +300 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/day-rail-chip.d.ts +82 -0
  26. package/dist/components/day-rail-chip.js +105 -0
  27. package/dist/components/drift-rail.d.ts +28 -0
  28. package/dist/components/drift-rail.js +3 -0
  29. package/dist/components/event-card.d.ts +97 -0
  30. package/dist/components/event-card.js +161 -0
  31. package/dist/components/event-compact-row.d.ts +72 -0
  32. package/dist/components/event-compact-row.js +133 -0
  33. package/dist/components/event-day-header.d.ts +65 -0
  34. package/dist/components/event-day-header.js +95 -0
  35. package/dist/components/event-day-separator.d.ts +46 -0
  36. package/dist/components/event-day-separator.js +50 -0
  37. package/dist/components/event-detail-panel.d.ts +80 -0
  38. package/dist/components/event-detail-panel.js +251 -0
  39. package/dist/components/event-hub-hero.d.ts +34 -0
  40. package/dist/components/event-hub-hero.js +57 -0
  41. package/dist/components/event-index-row.d.ts +68 -0
  42. package/dist/components/event-index-row.js +84 -0
  43. package/dist/components/event-list-row-wide.d.ts +101 -0
  44. package/dist/components/event-list-row-wide.js +218 -0
  45. package/dist/components/event-list-row.d.ts +82 -0
  46. package/dist/components/event-list-row.js +173 -0
  47. package/dist/components/event-poster-fallback.d.ts +20 -0
  48. package/dist/components/event-poster-fallback.js +3 -0
  49. package/dist/components/event-types.d.ts +118 -0
  50. package/dist/components/event-types.js +2 -0
  51. package/dist/components/events-empty-state.d.ts +44 -0
  52. package/dist/components/events-empty-state.js +73 -0
  53. package/dist/components/facet-group.d.ts +93 -0
  54. package/dist/components/facet-group.js +222 -0
  55. package/dist/components/featured-rail-item.d.ts +42 -0
  56. package/dist/components/featured-rail-item.js +88 -0
  57. package/dist/components/hour-band.d.ts +68 -0
  58. package/dist/components/hour-band.js +53 -0
  59. package/dist/components/hour-group.d.ts +67 -0
  60. package/dist/components/hour-group.js +59 -0
  61. package/dist/components/icon.d.ts +18 -0
  62. package/dist/components/icon.js +31 -0
  63. package/dist/components/item.d.ts +2 -2
  64. package/dist/components/motion-icon-controls.d.ts +26 -0
  65. package/dist/components/motion-icon-controls.js +4 -0
  66. package/dist/components/motion-icon-set.d.ts +27 -0
  67. package/dist/components/motion-icon-set.js +139 -0
  68. package/dist/components/motion-icon.d.ts +44 -0
  69. package/dist/components/motion-icon.js +3 -0
  70. package/dist/components/multi-select-combobox.js +1 -1
  71. package/dist/components/place-about-tab.js +3 -3
  72. package/dist/components/place-details-mobile.js +3 -3
  73. package/dist/components/poster-stack.d.ts +30 -0
  74. package/dist/components/poster-stack.js +104 -0
  75. package/dist/components/poster-tape.d.ts +36 -0
  76. package/dist/components/poster-tape.js +64 -0
  77. package/dist/components/poster-wall.d.ts +25 -0
  78. package/dist/components/poster-wall.js +183 -0
  79. package/dist/components/quiet-stretch.d.ts +43 -0
  80. package/dist/components/quiet-stretch.js +54 -0
  81. package/dist/components/section-header.d.ts +53 -0
  82. package/dist/components/section-header.js +63 -0
  83. package/dist/components/segmented-nav.d.ts +62 -0
  84. package/dist/components/segmented-nav.js +54 -0
  85. package/dist/components/skeleton-card.js +1 -1
  86. package/dist/components/skeleton.d.ts +17 -0
  87. package/dist/components/skeleton.js +1 -1
  88. package/dist/components/spinner.d.ts +2 -2
  89. package/dist/components/tag-group.js +3 -26
  90. package/dist/components/tag.d.ts +2 -2
  91. package/dist/components/tag.js +1 -1
  92. package/dist/components/text.d.ts +3 -3
  93. package/dist/components/timeline-band.d.ts +46 -0
  94. package/dist/components/timeline-band.js +5 -0
  95. package/dist/components/venue-events-block.d.ts +143 -0
  96. package/dist/components/venue-events-block.js +222 -0
  97. package/dist/components/weekend-panel.d.ts +44 -0
  98. package/dist/components/weekend-panel.js +231 -0
  99. package/dist/lib/icon-registry.d.ts +58 -0
  100. package/dist/lib/icon-registry.js +39 -0
  101. package/package.json +2 -1
  102. package/src/components/accordion.stories.tsx +1 -1
  103. package/src/components/card.tsx +44 -1
  104. package/src/components/category-deck.stories.tsx +96 -0
  105. package/src/components/category-deck.tsx +227 -0
  106. package/src/components/combobox.stories.tsx +1 -1
  107. package/src/components/concept-card.stories.tsx +80 -0
  108. package/src/components/concept-card.tsx +143 -0
  109. package/src/components/coverflow.stories.tsx +240 -0
  110. package/src/components/coverflow.test.tsx +193 -0
  111. package/src/components/coverflow.tsx +550 -0
  112. package/src/components/currency-input.stories.tsx +3 -3
  113. package/src/components/day-rail-chip.stories.tsx +145 -0
  114. package/src/components/day-rail-chip.tsx +201 -0
  115. package/src/components/drift-rail.stories.tsx +92 -0
  116. package/src/components/drift-rail.tsx +86 -0
  117. package/src/components/empty-state.stories.tsx +3 -3
  118. package/src/components/event-card.stories.tsx +248 -0
  119. package/src/components/event-card.test.tsx +177 -0
  120. package/src/components/event-card.tsx +321 -0
  121. package/src/components/event-compact-row.stories.tsx +221 -0
  122. package/src/components/event-compact-row.tsx +268 -0
  123. package/src/components/event-day-header.stories.tsx +124 -0
  124. package/src/components/event-day-header.tsx +180 -0
  125. package/src/components/event-day-separator.stories.tsx +70 -0
  126. package/src/components/event-day-separator.tsx +95 -0
  127. package/src/components/event-detail-panel.stories.tsx +191 -0
  128. package/src/components/event-detail-panel.tsx +360 -0
  129. package/src/components/event-hub-hero.stories.tsx +66 -0
  130. package/src/components/event-hub-hero.tsx +110 -0
  131. package/src/components/event-index-row.stories.tsx +116 -0
  132. package/src/components/event-index-row.tsx +181 -0
  133. package/src/components/event-list-row-wide.stories.tsx +204 -0
  134. package/src/components/event-list-row-wide.tsx +386 -0
  135. package/src/components/event-list-row.stories.tsx +159 -0
  136. package/src/components/event-list-row.tsx +335 -0
  137. package/src/components/event-poster-fallback.stories.tsx +52 -0
  138. package/src/components/event-poster-fallback.tsx +31 -0
  139. package/src/components/event-types.ts +133 -0
  140. package/src/components/events-empty-state.stories.tsx +59 -0
  141. package/src/components/events-empty-state.tsx +131 -0
  142. package/src/components/facet-group.stories.tsx +247 -0
  143. package/src/components/facet-group.test.tsx +225 -0
  144. package/src/components/facet-group.tsx +352 -0
  145. package/src/components/featured-rail-item.stories.tsx +86 -0
  146. package/src/components/featured-rail-item.tsx +153 -0
  147. package/src/components/form.stories.tsx +1 -1
  148. package/src/components/hour-band.stories.tsx +159 -0
  149. package/src/components/hour-band.tsx +126 -0
  150. package/src/components/hour-group.stories.tsx +100 -0
  151. package/src/components/hour-group.tsx +124 -0
  152. package/src/components/icon.tsx +57 -0
  153. package/src/components/input-otp.stories.tsx +1 -1
  154. package/src/components/markdown-renderer.stories.tsx +1 -1
  155. package/src/components/motion-icon-controls.test.tsx +78 -0
  156. package/src/components/motion-icon-controls.tsx +552 -0
  157. package/src/components/motion-icon-set.stories.tsx +185 -0
  158. package/src/components/motion-icon-set.tsx +219 -0
  159. package/src/components/motion-icon.test.tsx +159 -0
  160. package/src/components/motion-icon.tsx +163 -0
  161. package/src/components/multi-select-combobox.stories.tsx +1 -1
  162. package/src/components/phone-frame.stories.tsx +1 -1
  163. package/src/components/poster-stack.stories.tsx +68 -0
  164. package/src/components/poster-stack.tsx +155 -0
  165. package/src/components/poster-tape.stories.tsx +70 -0
  166. package/src/components/poster-tape.tsx +122 -0
  167. package/src/components/poster-wall.stories.tsx +95 -0
  168. package/src/components/poster-wall.tsx +220 -0
  169. package/src/components/quiet-stretch.stories.tsx +62 -0
  170. package/src/components/quiet-stretch.tsx +115 -0
  171. package/src/components/rating.stories.tsx +3 -3
  172. package/src/components/searchable-select.stories.tsx +1 -1
  173. package/src/components/section-header.stories.tsx +70 -0
  174. package/src/components/section-header.tsx +134 -0
  175. package/src/components/segmented-nav.stories.tsx +118 -0
  176. package/src/components/segmented-nav.tsx +113 -0
  177. package/src/components/selection-bar.stories.tsx +1 -1
  178. package/src/components/skeleton-card.stories.tsx +3 -3
  179. package/src/components/skeleton.tsx +18 -0
  180. package/src/components/sliding-panels.stories.tsx +1 -1
  181. package/src/components/stepper.stories.tsx +1 -1
  182. package/src/components/sticky-container.stories.tsx +3 -3
  183. package/src/components/tag.tsx +11 -1
  184. package/src/components/timeline-band.stories.tsx +206 -0
  185. package/src/components/timeline-band.tsx +207 -0
  186. package/src/components/toggle-group.stories.tsx +1 -1
  187. package/src/components/tooltip.stories.tsx +2 -2
  188. package/src/components/venue-events-block.stories.tsx +216 -0
  189. package/src/components/venue-events-block.tsx +408 -0
  190. package/src/components/weekend-panel.stories.tsx +92 -0
  191. package/src/components/weekend-panel.tsx +322 -0
  192. package/src/globals.css +19 -0
  193. package/src/lib/icon-registry.ts +139 -0
  194. package/src/test/setup.ts +5 -1
@@ -0,0 +1,248 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Flame, Music } from "lucide-react";
3
+ import { EventCard, EventCardSkeleton } from "./event-card";
4
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
5
+
6
+ const labels: EventCardLabels = {
7
+ free: "Free",
8
+ liveNow: "Live now",
9
+ };
10
+
11
+ const formatters: EventFormatters = {
12
+ time: (iso) =>
13
+ new Date(iso).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" }),
14
+ day: (iso) =>
15
+ new Date(iso).toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" }),
16
+ price: (amount) => `${amount} RON`,
17
+ };
18
+
19
+ const event: EventPreview = {
20
+ id: "e1",
21
+ title: "Psihotrop — From the Beginning to Infinity",
22
+ location: "Atelier Café",
23
+ place_name: "Atelier Café",
24
+ place_slug: "atelier-cafe",
25
+ starts_at: "2026-09-05T20:00:00",
26
+ entry_fee: 50,
27
+ };
28
+
29
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
30
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
31
+ // name portably (TS2742).
32
+ const meta: Meta<typeof EventCard> = {
33
+ title: "Events/EventCard",
34
+ component: EventCard,
35
+ tags: ["autodocs"],
36
+ args: { event, labels, formatters },
37
+ decorators: [
38
+ (Story) => (
39
+ <div className="w-[220px]">
40
+ <Story />
41
+ </div>
42
+ ),
43
+ ],
44
+ parameters: {
45
+ docs: {
46
+ description: {
47
+ component:
48
+ "Pass `href` **and** `onClick` in production: the anchor keeps the card crawlable and middle-clickable, while `onClick` (calling `preventDefault()`) opens the detail sheet. A card rendered as a bare `<button>` is what stripped every event link out of the hub's rendered DOM.",
49
+ },
50
+ },
51
+ },
52
+ };
53
+
54
+ export default meta;
55
+ type Story = StoryObj<typeof EventCard>;
56
+
57
+ export const Default: Story = {};
58
+
59
+ /** `entry_fee: 0` is explicitly free and earns the green pill. */
60
+ export const FreeEntry: Story = {
61
+ args: { event: { ...event, entry_fee: 0 } },
62
+ };
63
+
64
+ /** An absent `entry_fee` is *unknown*, not free — no pill, no price. */
65
+ export const UnknownEntryFee: Story = {
66
+ args: { event: { ...event, entry_fee: undefined } },
67
+ };
68
+
69
+ /** Overlay pills: hype badge top-left, category tag bottom-left. */
70
+ export const WithBadgeAndTag: Story = {
71
+ args: {
72
+ event: { ...event, entry_fee: 0 },
73
+ badge: (
74
+ <>
75
+ <Flame className="h-3 w-3" aria-hidden />
76
+ Busy
77
+ </>
78
+ ),
79
+ tagLabel: (
80
+ <>
81
+ <Music className="h-3 w-3" aria-hidden />
82
+ Live music
83
+ </>
84
+ ),
85
+ },
86
+ };
87
+
88
+ /**
89
+ * `tags` — display labels rendered beneath the venue/meta line, distinct from
90
+ * `tagLabel`'s single overlay pill on the poster.
91
+ */
92
+ export const WithTags: Story = {
93
+ args: { tags: ["Live Music", "Rock", "18+"] },
94
+ };
95
+
96
+ /** The live line is composed by the caller — clause order differs per locale. */
97
+ export const LiveNow: Story = {
98
+ args: { liveStatus: "Live now · ends around 23:00" },
99
+ };
100
+
101
+ /** The featured slot: larger title. Still uncut. */
102
+ export const Emphasis: Story = {
103
+ args: { emphasis: true },
104
+ decorators: [
105
+ (Story) => (
106
+ <div className="w-[320px]">
107
+ <Story />
108
+ </div>
109
+ ),
110
+ ],
111
+ };
112
+
113
+ const LONG_TITLE_EVENT: EventPreview = {
114
+ ...event,
115
+ title: "Movie Night: Futni må ikke være så vanskelig — an outdoor screening with live score",
116
+ };
117
+
118
+ /**
119
+ * THE TITLE IS NEVER TRUNCATED, in any variant. Real listings run long
120
+ * ("Movie Night: Futni m…", "Juventus vs AC Milan…" were the reported bug),
121
+ * and the title is the one thing the card exists to say. It wraps to as many
122
+ * lines as it needs and the card grows downward.
123
+ */
124
+ export const LongTitle: Story = {
125
+ args: { event: LONG_TITLE_EVENT },
126
+ };
127
+
128
+ /** The same long title across every variant, so no combination quietly clips it. */
129
+ export const LongTitleEveryVariant: Story = {
130
+ render: () => (
131
+ <div className="flex items-start gap-4">
132
+ <div className="w-[150px]">
133
+ <EventCard
134
+ event={LONG_TITLE_EVENT}
135
+ labels={labels}
136
+ formatters={formatters}
137
+ posterAspect="4/3"
138
+ />
139
+ </div>
140
+ <div className="w-[150px]">
141
+ <EventCard
142
+ event={LONG_TITLE_EVENT}
143
+ labels={labels}
144
+ formatters={formatters}
145
+ posterAspect="3/4"
146
+ />
147
+ </div>
148
+ <div className="w-[150px]">
149
+ <EventCard
150
+ event={LONG_TITLE_EVENT}
151
+ labels={labels}
152
+ formatters={formatters}
153
+ posterAspect="4/5"
154
+ />
155
+ </div>
156
+ <div className="w-[220px]">
157
+ <EventCard event={LONG_TITLE_EVENT} labels={labels} formatters={formatters} emphasis />
158
+ </div>
159
+ </div>
160
+ ),
161
+ decorators: [
162
+ (Story) => (
163
+ <div className="w-[760px]">
164
+ <Story />
165
+ </div>
166
+ ),
167
+ ],
168
+ parameters: {
169
+ docs: {
170
+ description: {
171
+ story:
172
+ "`items-start` on the row is what keeps a three-line title from stretching its neighbours — the pattern any grid of these cards should copy.",
173
+ },
174
+ },
175
+ },
176
+ };
177
+
178
+ /** 3/4 poster — the hub's original portrait deck/rail/tape ratio. */
179
+ export const PortraitPoster: Story = {
180
+ args: { posterAspect: "3/4", showPrice: true },
181
+ };
182
+
183
+ /**
184
+ * 4:3 (the house shape, shared with dish/drink/place) beside 4:5 (the
185
+ * Instagram source ratio, used by the hub's editorial poster surfaces —
186
+ * `CategoryDeck`, `PosterTape`, `Coverflow`). Side by side so the crop
187
+ * difference is visible rather than guessed at.
188
+ */
189
+ export const HouseVsInstagramAspect: Story = {
190
+ render: () => (
191
+ <div className="flex gap-4">
192
+ <div className="w-[150px]">
193
+ <EventCard event={event} labels={labels} formatters={formatters} posterAspect="4/3" />
194
+ </div>
195
+ <div className="w-[150px]">
196
+ <EventCard event={event} labels={labels} formatters={formatters} posterAspect="4/5" />
197
+ </div>
198
+ </div>
199
+ ),
200
+ decorators: [
201
+ (Story) => (
202
+ <div className="w-[340px]">
203
+ <Story />
204
+ </div>
205
+ ),
206
+ ],
207
+ };
208
+
209
+ /** The timeline supplies its own hour heading, so the card drops the meta line. */
210
+ export const InsideAnHourGroup: Story = {
211
+ args: { hideWhen: true },
212
+ };
213
+
214
+ /** Loading state — identical geometry to `Default`, so nothing shifts on data arrival. */
215
+ export const Loading: Story = {
216
+ render: () => <EventCardSkeleton />,
217
+ };
218
+
219
+ /** Skeleton beside the real card, so any geometry drift is visible at a glance. */
220
+ export const LoadingVsLoaded: Story = {
221
+ render: () => (
222
+ <div className="flex gap-4">
223
+ <EventCardSkeleton />
224
+ <EventCard event={event} labels={labels} formatters={formatters} />
225
+ </div>
226
+ ),
227
+ decorators: [
228
+ (Story) => (
229
+ <div className="w-[460px]">
230
+ <Story />
231
+ </div>
232
+ ),
233
+ ],
234
+ };
235
+
236
+ /** With a resolved poster. The caller composes the CDN URL; the DS never does. */
237
+ export const WithPoster: Story = {
238
+ args: {
239
+ event: {
240
+ ...event,
241
+ poster_url:
242
+ "data:image/svg+xml;utf8," +
243
+ encodeURIComponent(
244
+ `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="180"><rect width="240" height="180" fill="#c8b8a8"/><text x="50%" y="50%" font-family="monospace" font-size="12" fill="#5a4a3a" text-anchor="middle">poster</text></svg>`
245
+ ),
246
+ },
247
+ },
248
+ };
@@ -0,0 +1,177 @@
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 { EventCard } from "./event-card";
5
+ import type { EventCardLabels, EventFormatters, EventPreview } from "./event-types";
6
+
7
+ const labels: EventCardLabels = {
8
+ free: "Free",
9
+ liveNow: "Live now",
10
+ };
11
+
12
+ const formatters: EventFormatters = {
13
+ time: () => "20:00",
14
+ day: () => "Sat, Sep 5",
15
+ price: (amount) => `${amount} RON`,
16
+ };
17
+
18
+ const baseEvent: EventPreview = {
19
+ id: "e1",
20
+ title: "Psihotrop Live",
21
+ location: "Atelier Cafe",
22
+ starts_at: "2026-09-05T20:00:00Z",
23
+ place_name: "Atelier Cafe",
24
+ };
25
+
26
+ const renderCard = (props: Partial<React.ComponentProps<typeof EventCard>> = {}) =>
27
+ render(<EventCard event={baseEvent} labels={labels} formatters={formatters} {...props} />);
28
+
29
+ describe("EventCard", () => {
30
+ describe("entry fee", () => {
31
+ // The tri-state is the whole point: an absent fee is unknown, NOT free.
32
+ // Rendering "Free" for an unpublished price would be a factual claim the
33
+ // API never made.
34
+ it("shows the free pill only when entry_fee is explicitly 0", () => {
35
+ renderCard({ event: { ...baseEvent, entry_fee: 0 } });
36
+ expect(screen.getByText("Free")).toBeInTheDocument();
37
+ });
38
+
39
+ it("does not show the free pill when entry_fee is absent", () => {
40
+ renderCard();
41
+ expect(screen.queryByText("Free")).not.toBeInTheDocument();
42
+ });
43
+
44
+ it("does not show the free pill when entry_fee is a real price", () => {
45
+ renderCard({ event: { ...baseEvent, entry_fee: 50 } });
46
+ expect(screen.queryByText("Free")).not.toBeInTheDocument();
47
+ });
48
+
49
+ it("appends the formatted price to the meta line only when asked", () => {
50
+ const { rerender } = renderCard({ event: { ...baseEvent, entry_fee: 50 } });
51
+ expect(screen.queryByText("50 RON")).not.toBeInTheDocument();
52
+
53
+ rerender(
54
+ <EventCard
55
+ event={{ ...baseEvent, entry_fee: 50 }}
56
+ labels={labels}
57
+ formatters={formatters}
58
+ showPrice
59
+ />
60
+ );
61
+ expect(screen.getByText("50 RON")).toBeInTheDocument();
62
+ });
63
+ });
64
+
65
+ describe("root element", () => {
66
+ // Regression guard for the measured SEO defect: hydrated event cards used
67
+ // to render as <button onClick>, so the rendered DOM carried no links into
68
+ // the events cluster at all.
69
+ it("renders an anchor when href is given", () => {
70
+ renderCard({ href: "/en/cluj-napoca/events/e/psihotrop-live-e1" });
71
+ expect(screen.getByRole("link", { name: /Psihotrop Live/ })).toHaveAttribute(
72
+ "href",
73
+ "/en/cluj-napoca/events/e/psihotrop-live-e1"
74
+ );
75
+ });
76
+
77
+ it("still fires onClick from the anchor, so the sheet can intercept it", async () => {
78
+ const onClick = vi.fn();
79
+ renderCard({ href: "/events/e/e1", onClick });
80
+
81
+ await userEvent.click(screen.getByRole("link", { name: /Psihotrop Live/ }));
82
+ expect(onClick).toHaveBeenCalledWith(baseEvent, expect.anything());
83
+ });
84
+
85
+ it("falls back to a button when only onClick is given", () => {
86
+ renderCard({ onClick: vi.fn() });
87
+ expect(screen.getByRole("button", { name: /Psihotrop Live/ })).toBeInTheDocument();
88
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
89
+ });
90
+
91
+ it("renders a non-interactive element when neither href nor onClick is given", () => {
92
+ renderCard();
93
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
94
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
95
+ });
96
+ });
97
+
98
+ describe("meta line", () => {
99
+ it("uses the injected formatters rather than formatting dates itself", () => {
100
+ renderCard();
101
+ expect(screen.getByText("Sat, Sep 5")).toBeInTheDocument();
102
+ expect(screen.getByText("20:00")).toBeInTheDocument();
103
+ });
104
+
105
+ it("prefers an explicit dayLabel over the formatted start date", () => {
106
+ renderCard({ dayLabel: "Tomorrow" });
107
+ expect(screen.getByText("Tomorrow")).toBeInTheDocument();
108
+ expect(screen.queryByText("Sat, Sep 5")).not.toBeInTheDocument();
109
+ });
110
+
111
+ it("hides the whole meta line when hideWhen is set", () => {
112
+ renderCard({ hideWhen: true });
113
+ expect(screen.queryByText("20:00")).not.toBeInTheDocument();
114
+ });
115
+
116
+ it("falls back to location when the event carries no place_name", () => {
117
+ renderCard({ event: { ...baseEvent, place_name: undefined, location: "Piata Unirii" } });
118
+ expect(screen.getByText("Piata Unirii")).toBeInTheDocument();
119
+ });
120
+ });
121
+
122
+ describe("poster", () => {
123
+ it("renders no img when the event has no resolved poster", () => {
124
+ const { container } = renderCard();
125
+ expect(container.querySelector("img")).toBeNull();
126
+ });
127
+
128
+ it("renders the poster the caller resolved", () => {
129
+ renderCard({ event: { ...baseEvent, poster_url: "https://cdn.example/p.avif" } });
130
+ expect(screen.getByAltText("Psihotrop Live")).toHaveAttribute(
131
+ "src",
132
+ "https://cdn.example/p.avif"
133
+ );
134
+ });
135
+ });
136
+
137
+ // Truncation is CSS-only, so jsdom cannot see the visual result — the class
138
+ // list is the only observable. Asserting on it is worth the coupling here:
139
+ // the reported bug ("Movie Night: Futni m…") was invisible to every other
140
+ // kind of test, and a clamp added back in any variant is exactly what this
141
+ // needs to catch.
142
+ describe("title", () => {
143
+ const longTitle =
144
+ "Movie Night: Futni må ikke være så vanskelig — an outdoor screening with live score";
145
+
146
+ it("renders the whole title, never a shortened copy of it", () => {
147
+ renderCard({ event: { ...baseEvent, title: longTitle } });
148
+ expect(screen.getByRole("heading", { name: longTitle })).toBeInTheDocument();
149
+ });
150
+
151
+ it("is not clamped in the base variant", () => {
152
+ renderCard({ event: { ...baseEvent, title: longTitle } });
153
+ expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
154
+ /line-clamp|truncate/
155
+ );
156
+ });
157
+
158
+ it("is not clamped in the emphasis variant either", () => {
159
+ renderCard({ event: { ...baseEvent, title: longTitle }, emphasis: true });
160
+ expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
161
+ /line-clamp|truncate/
162
+ );
163
+ });
164
+
165
+ it.each(["4/3", "3/4", "4/5"] as const)("is not clamped at posterAspect %s", (posterAspect) => {
166
+ renderCard({ event: { ...baseEvent, title: longTitle }, posterAspect });
167
+ expect(screen.getByRole("heading", { name: longTitle }).className).not.toMatch(
168
+ /line-clamp|truncate/
169
+ );
170
+ });
171
+ });
172
+
173
+ it("renders the live line the caller composed, not one of its own", () => {
174
+ renderCard({ liveStatus: "Live now · ends around 23:00" });
175
+ expect(screen.getByText("Live now · ends around 23:00")).toBeInTheDocument();
176
+ });
177
+ });