@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,227 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { ChevronRight } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Skeleton } from "./skeleton";
7
+
8
+ /**
9
+ * CategoryDeck — the hub's category doorway: a fanned stack of four posters
10
+ * that spreads open on hover or focus.
11
+ *
12
+ * The whole deck is ONE link (or button, or static div — same href/onClick
13
+ * contract as `EventCard`). The four posters are decorative dressing for that
14
+ * single destination, not four separate targets, so they're `aria-hidden` and
15
+ * the link's accessible name comes from the visible `name`/`meta` text next
16
+ * to it.
17
+ *
18
+ * The fan must open for keyboard users too, not just mouse hover — the spread
19
+ * transform is driven by `group-hover` AND `group-focus-visible` on the same
20
+ * link. Every poster's `transform` is set as a single arbitrary CSS property
21
+ * rather than composed Tailwind `translate-x`/`rotate` utilities, because
22
+ * those utilities share one `--tw-translate-x` variable and would silently
23
+ * overwrite each other.
24
+ *
25
+ * Poster offsets are the same at every breakpoint — the design only specifies
26
+ * a smaller card size and deck height for mobile, not a rescaled fan.
27
+ */
28
+
29
+ const DECK_POSTER_COUNT = 4;
30
+
31
+ const DECK_TRANSITION =
32
+ "transition-transform duration-[400ms] ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none";
33
+
34
+ const DECK_CARD_TRANSFORMS: ReadonlyArray<{ rest: string; spread: string; z: string }> = [
35
+ {
36
+ rest: "[transform:translateX(-50%)_rotate(-9deg)_translateX(-26px)]",
37
+ spread:
38
+ "group-hover:[transform:translateX(-50%)_rotate(-14deg)_translateX(-64px)_translateY(-6px)] group-focus-visible:[transform:translateX(-50%)_rotate(-14deg)_translateX(-64px)_translateY(-6px)]",
39
+ z: "z-0",
40
+ },
41
+ {
42
+ rest: "[transform:translateX(-50%)_rotate(-3deg)_translateX(-9px)]",
43
+ spread:
44
+ "group-hover:[transform:translateX(-50%)_rotate(-5deg)_translateX(-22px)_translateY(-10px)] group-focus-visible:[transform:translateX(-50%)_rotate(-5deg)_translateX(-22px)_translateY(-10px)]",
45
+ z: "z-[1]",
46
+ },
47
+ {
48
+ rest: "[transform:translateX(-50%)_rotate(3deg)_translateX(9px)]",
49
+ spread:
50
+ "group-hover:[transform:translateX(-50%)_rotate(5deg)_translateX(22px)_translateY(-10px)] group-focus-visible:[transform:translateX(-50%)_rotate(5deg)_translateX(22px)_translateY(-10px)]",
51
+ z: "z-[1]",
52
+ },
53
+ {
54
+ rest: "[transform:translateX(-50%)_rotate(9deg)_translateX(26px)]",
55
+ spread:
56
+ "group-hover:[transform:translateX(-50%)_rotate(14deg)_translateX(64px)_translateY(-6px)] group-focus-visible:[transform:translateX(-50%)_rotate(14deg)_translateX(64px)_translateY(-6px)]",
57
+ z: "z-[2]",
58
+ },
59
+ ];
60
+
61
+ export type CategoryDeckPoster = {
62
+ src?: string;
63
+ alt?: string;
64
+ /**
65
+ * Rendered in the card instead of an `<img>`. Wins over `src`.
66
+ *
67
+ * The deck was built for event posters, where every card is a photograph.
68
+ * The menu hub fans the same deck over dish categories, and that app renders
69
+ * no menu-item photography on card surfaces — it draws a tinted colour field
70
+ * carrying the item's name. Without a slot, those decks fanned four empty
71
+ * `bg-muted` blocks.
72
+ *
73
+ * Same escape hatch, and the same name-and-precedence rule, as
74
+ * `FeaturedRailItemProps.fallback` and `PosterTapeItem.fallback` — except
75
+ * those two are fallbacks shown when a poster is ABSENT, while this one is a
76
+ * deliberate replacement. Hence `content`, not `fallback`: a caller passing
77
+ * it is choosing what the card is, not what it degrades to.
78
+ */
79
+ content?: React.ReactNode;
80
+ };
81
+
82
+ export type CategoryDeckLinkProps = {
83
+ href: string;
84
+ className?: string;
85
+ children: React.ReactNode;
86
+ onClick?: (mouseEvent: React.MouseEvent) => void;
87
+ };
88
+
89
+ function DefaultLink({ href, ...props }: CategoryDeckLinkProps) {
90
+ return <a href={href} {...props} />;
91
+ }
92
+
93
+ export type CategoryDeckProps = {
94
+ name: string;
95
+ /** Caller-composed, e.g. "32 events · /petreceri". */
96
+ meta: string;
97
+ /** Up to four posters, fanned back to front. A poster with neither `content`
98
+ * nor `src` renders a placeholder block. */
99
+ posters: CategoryDeckPoster[];
100
+ href?: string;
101
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
102
+ linkComponent?: React.ComponentType<CategoryDeckLinkProps>;
103
+ onClick?: (mouseEvent: React.MouseEvent) => void;
104
+ className?: string;
105
+ };
106
+
107
+ export function CategoryDeck({
108
+ name,
109
+ meta,
110
+ posters,
111
+ href,
112
+ linkComponent: LinkComponent = DefaultLink,
113
+ onClick,
114
+ className,
115
+ }: CategoryDeckProps) {
116
+ const slots = Array.from(
117
+ { length: DECK_POSTER_COUNT },
118
+ (_, index): CategoryDeckPoster => posters[index] ?? {}
119
+ );
120
+
121
+ const body = (
122
+ <>
123
+ <div className="relative h-[150px] sm:h-[186px]" aria-hidden>
124
+
125
+ {slots.map((poster, index) => {
126
+ const transform = DECK_CARD_TRANSFORMS[index];
127
+ return (
128
+ <div
129
+ key={index}
130
+ className={cn(
131
+ "absolute left-1/2 top-0 aspect-[4/5] w-24 overflow-hidden rounded-[10px] bg-muted",
132
+ "shadow-[0_10px_26px_color-mix(in_oklch,var(--foreground)_18%,transparent)]",
133
+ "sm:w-[120px]",
134
+ transform.rest,
135
+ transform.spread,
136
+ transform.z,
137
+ DECK_TRANSITION
138
+ )}
139
+ >
140
+ {poster.content ? (
141
+ poster.content
142
+ ) : poster.src ? (
143
+ // eslint-disable-next-line @next/next/no-img-element
144
+ <img
145
+ src={poster.src}
146
+ alt={poster.alt ?? ""}
147
+ loading="lazy"
148
+ decoding="async"
149
+ className="h-full w-full object-cover"
150
+ />
151
+ ) : (
152
+ <div className="h-full w-full bg-muted" />
153
+ )}
154
+ </div>
155
+ );
156
+ })}
157
+ </div>
158
+
159
+ <div className="flex items-center gap-[9px] pt-3.5">
160
+ <div className="min-w-0">
161
+ <div className="truncate text-[14.5px] font-extrabold tracking-[-0.015em] sm:text-base">
162
+ {name}
163
+ </div>
164
+ <div className="truncate font-mono text-[11.5px] text-muted-foreground">{meta}</div>
165
+ </div>
166
+ <ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
167
+ </div>
168
+ </>
169
+ );
170
+
171
+ const rootClasses = cn(
172
+ "group flex min-w-0 flex-col text-left",
173
+ (href || onClick) &&
174
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
175
+ className
176
+ );
177
+
178
+ if (href) {
179
+ return (
180
+ <LinkComponent href={href} className={rootClasses} onClick={onClick}>
181
+ {body}
182
+ </LinkComponent>
183
+ );
184
+ }
185
+
186
+ if (onClick) {
187
+ return (
188
+ <button type="button" className={rootClasses} onClick={onClick}>
189
+ {body}
190
+ </button>
191
+ );
192
+ }
193
+
194
+ return <div className={rootClasses}>{body}</div>;
195
+ }
196
+
197
+ export type CategoryDeckSkeletonProps = {
198
+ className?: string;
199
+ };
200
+
201
+ /** The deck's resting layout — the fan sits at its rest transform, never spread. */
202
+ export function CategoryDeckSkeleton({ className }: CategoryDeckSkeletonProps) {
203
+ return (
204
+ <div aria-hidden="true" className={cn("flex min-w-0 flex-col", className)}>
205
+ <div className="relative h-[150px] sm:h-[186px]">
206
+ {DECK_CARD_TRANSFORMS.map((transform, index) => (
207
+ <Skeleton
208
+ key={index}
209
+ className={cn(
210
+ "absolute left-1/2 top-0 aspect-[4/5] w-24 rounded-[10px] sm:w-[120px]",
211
+ transform.rest,
212
+ transform.z
213
+ )}
214
+ />
215
+ ))}
216
+ </div>
217
+
218
+ <div className="flex items-center gap-[9px] pt-3.5">
219
+ <div className="min-w-0 flex-1">
220
+ <Skeleton className="h-[14.5px] w-24 sm:h-4 sm:w-28" />
221
+ <Skeleton className="mt-1.5 h-[11.5px] w-32" />
222
+ </div>
223
+ <Skeleton className="ml-auto h-4 w-4 shrink-0 rounded-full" />
224
+ </div>
225
+ </div>
226
+ );
227
+ }
@@ -9,7 +9,7 @@ const meta = {
9
9
  } satisfies Meta<typeof Combobox>;
10
10
 
11
11
  export default meta;
12
- type Story = StoryObj<typeof meta>;
12
+ type Story = StoryObj<typeof Combobox>;
13
13
 
14
14
  const frameworks: ComboboxOption[] = [
15
15
  { value: "next", label: "Next.js", description: "React framework" },
@@ -0,0 +1,80 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { PartyPopper, Music } from "lucide-react";
3
+ import { ConceptCard, ConceptCardSkeleton } from "./concept-card";
4
+
5
+ const kind = (
6
+ <>
7
+ <PartyPopper className="h-3 w-3" aria-hidden />
8
+ Party
9
+ </>
10
+ );
11
+
12
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
13
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
14
+ // name portably (TS2742).
15
+ const meta: Meta<typeof ConceptCard> = {
16
+ title: "Events/ConceptCard",
17
+ component: ConceptCard,
18
+ tags: ["autodocs"],
19
+ args: {
20
+ title: "Flying Circus",
21
+ meta: "Every Saturday · Flying Circus · 4 dates",
22
+ kind,
23
+ },
24
+ decorators: [
25
+ (Story) => (
26
+ <div className="w-[320px]">
27
+ <Story />
28
+ </div>
29
+ ),
30
+ ],
31
+ parameters: {
32
+ docs: {
33
+ description: {
34
+ component:
35
+ "`meta` is composed by the caller — the design's variants (\"Every Saturday · Flying Circus · 4 dates\") don't decompose into parts without hardcoding clause order.",
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ export default meta;
42
+ type Story = StoryObj<typeof ConceptCard>;
43
+
44
+ export const Default: Story = {};
45
+
46
+ /** Absent `coverUrl` falls back to a flat muted block, same as `EventCard`. */
47
+ export const NoCover: Story = {
48
+ args: { coverUrl: undefined },
49
+ };
50
+
51
+ export const WithCover: Story = {
52
+ args: {
53
+ coverUrl:
54
+ "data:image/svg+xml;utf8," +
55
+ encodeURIComponent(
56
+ `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="320"><rect width="640" height="320" fill="#3a2c46"/><text x="50%" y="50%" font-family="monospace" font-size="18" fill="#fff" text-anchor="middle">cover</text></svg>`
57
+ ),
58
+ },
59
+ };
60
+
61
+ export const LiveMusicKind: Story = {
62
+ args: {
63
+ title: "Jazz at the Basement",
64
+ meta: "Every Thursday · The Basement · 12 dates",
65
+ kind: (
66
+ <>
67
+ <Music className="h-3 w-3" aria-hidden />
68
+ Live music
69
+ </>
70
+ ),
71
+ },
72
+ };
73
+
74
+ export const Clickable: Story = {
75
+ args: { href: "/events/flying-circus" },
76
+ };
77
+
78
+ export const Skeleton: Story = {
79
+ render: () => <ConceptCardSkeleton />,
80
+ };
@@ -0,0 +1,143 @@
1
+ "use client";
2
+
3
+ import { memo, useState } from "react";
4
+ import { Calendar } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Skeleton } from "./skeleton";
7
+
8
+ /**
9
+ * ConceptCard — the recurring-series cover card (e.g. a weekly party
10
+ * concept), a wide 2:1 image with a bottom scrim carrying a kind pill,
11
+ * a composed date/venue/count line and the title.
12
+ *
13
+ * Same contract as `EventCard`: DS-owned props, plain `<img>`, anchor
14
+ * whenever `href` is given. Absent `coverUrl` falls back to a flat
15
+ * `bg-muted` block, same treatment `EventCard` gives a missing poster.
16
+ */
17
+
18
+ export type ConceptCardLinkProps = {
19
+ href: string;
20
+ className?: string;
21
+ children: React.ReactNode;
22
+ onClick?: (mouseEvent: React.MouseEvent) => void;
23
+ };
24
+
25
+ function DefaultLink({ href, ...props }: ConceptCardLinkProps) {
26
+ return <a href={href} {...props} />;
27
+ }
28
+
29
+ export type ConceptCardProps = {
30
+ title: string;
31
+ /**
32
+ * The whole date/venue/count line, composed by the caller, e.g. "Every
33
+ * Saturday · Flying Circus · 4 dates" — clause order is locale-dependent.
34
+ */
35
+ meta: string;
36
+ /** Label plus its icon, e.g. `<><PartyPopper .../> Party</>`. */
37
+ kind: React.ReactNode;
38
+ coverUrl?: string;
39
+ href?: string;
40
+ /** Defaults to a plain `<a>`. Pass `next/link` in a Next app. */
41
+ linkComponent?: React.ComponentType<ConceptCardLinkProps>;
42
+ onClick?: (mouseEvent: React.MouseEvent) => void;
43
+ className?: string;
44
+ /** Forwarded to the cover's `loading` attribute (`"eager"` when true). */
45
+ priority?: boolean;
46
+ };
47
+
48
+ export const ConceptCard = memo(function ConceptCard({
49
+ title,
50
+ meta,
51
+ kind,
52
+ coverUrl,
53
+ href,
54
+ linkComponent: LinkComponent = DefaultLink,
55
+ onClick,
56
+ className,
57
+ priority,
58
+ }: ConceptCardProps) {
59
+ const [imgError, setImgError] = useState(false);
60
+ const showCover = !!coverUrl && !imgError;
61
+
62
+ const body = (
63
+ <>
64
+ <div className="absolute inset-0 bg-muted">
65
+ {showCover && (
66
+ // eslint-disable-next-line @next/next/no-img-element
67
+ <img
68
+ src={coverUrl}
69
+ alt={title}
70
+ title={title}
71
+ loading={priority ? "eager" : "lazy"}
72
+ decoding="async"
73
+ className="h-full w-full object-cover"
74
+ onError={() => setImgError(true)}
75
+ />
76
+ )}
77
+ </div>
78
+
79
+ <div className="absolute inset-x-0 bottom-0 z-[1] h-[60%] bg-gradient-to-t from-black/90 via-black/60 to-transparent" />
80
+
81
+ <span className="absolute left-2 top-2 z-[2] inline-flex items-center gap-1 rounded-md bg-black/50 px-2 py-1 text-[11px] font-semibold text-white">
82
+ {kind}
83
+ </span>
84
+
85
+ <div className="relative z-[2] mt-auto flex w-full flex-col gap-0.5 p-2.5 text-white">
86
+ <div className="flex items-center gap-1 text-[11px] font-medium text-white/85">
87
+ <Calendar className="h-3 w-3 shrink-0" aria-hidden />
88
+ <span className="truncate">{meta}</span>
89
+ </div>
90
+ <div className="line-clamp-1 text-[14px] font-semibold leading-[1.25]">{title}</div>
91
+ </div>
92
+ </>
93
+ );
94
+
95
+ const rootClasses = cn(
96
+ "relative flex aspect-[2/1] flex-col overflow-hidden rounded-xl text-left",
97
+ (href || onClick) &&
98
+ "cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
99
+ className
100
+ );
101
+
102
+ if (href) {
103
+ return (
104
+ <LinkComponent href={href} className={rootClasses} onClick={onClick}>
105
+ {body}
106
+ </LinkComponent>
107
+ );
108
+ }
109
+
110
+ if (onClick) {
111
+ return (
112
+ <button type="button" className={rootClasses} onClick={onClick}>
113
+ {body}
114
+ </button>
115
+ );
116
+ }
117
+
118
+ return <div className={rootClasses}>{body}</div>;
119
+ });
120
+
121
+ export type ConceptCardSkeletonProps = {
122
+ className?: string;
123
+ };
124
+
125
+ export function ConceptCardSkeleton({ className }: ConceptCardSkeletonProps) {
126
+ return (
127
+ <div
128
+ aria-hidden="true"
129
+ className={cn("relative flex aspect-[2/1] flex-col overflow-hidden rounded-xl", className)}
130
+ >
131
+ <Skeleton className="absolute inset-0 rounded-none" />
132
+
133
+ <div className="absolute inset-x-0 bottom-0 z-[1] h-[60%] bg-gradient-to-t from-black/90 via-black/60 to-transparent" />
134
+
135
+ <Skeleton className="absolute left-2 top-2 z-[2] h-[26px] w-16 rounded-md bg-white/20" />
136
+
137
+ <div className="relative z-[2] mt-auto flex w-full flex-col gap-1 p-2.5">
138
+ <Skeleton className="h-[11px] w-24 bg-white/15" />
139
+ <Skeleton className="h-[14px] w-2/3 bg-white/20" />
140
+ </div>
141
+ </div>
142
+ );
143
+ }
@@ -0,0 +1,240 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Flame } from "lucide-react";
4
+ import { expect, within } from "storybook/test";
5
+ import { Coverflow, type CoverflowItem } from "./coverflow";
6
+
7
+ const ITEMS: CoverflowItem[] = [
8
+ {
9
+ id: "warehouse-rave",
10
+ title: "Warehouse Rave",
11
+ meta: "Fri 4 · 90 going",
12
+ badge: (
13
+ <>
14
+ <Flame className="h-3 w-3" aria-hidden />
15
+ Busiest
16
+ </>
17
+ ),
18
+ },
19
+ {
20
+ id: "jazz-rooftop",
21
+ title: "Jazz on the Rooftop",
22
+ meta: "Sat 5 · 64 going",
23
+ isFree: true,
24
+ },
25
+ {
26
+ id: "open-mic",
27
+ title: "Open Mic Night",
28
+ meta: "Sun 6 · 21 going",
29
+ },
30
+ ...Array.from({ length: 4 }, (_, index) => ({
31
+ id: `side-event-${index + 1}`,
32
+ title: `Side event ${index + 1}`,
33
+ meta: `Event ${index + 1} details`,
34
+ })),
35
+ ];
36
+
37
+ // Explicitly annotated rather than `satisfies`: the meta-level decorator makes
38
+ // the inferred type reference storybook's internal CSF module, which tsc cannot
39
+ // name portably (TS2742).
40
+ const meta: Meta<typeof Coverflow> = {
41
+ title: "Events/Coverflow",
42
+ component: Coverflow,
43
+ tags: ["autodocs"],
44
+ args: {
45
+ items: ITEMS,
46
+ activeIndex: 0,
47
+ freeLabel: "Free",
48
+ prevLabel: "Previous event",
49
+ nextLabel: "Next event",
50
+ positionLabel: "1 of 7",
51
+ },
52
+ decorators: [
53
+ (Story) => (
54
+ <div className="max-w-[520px]">
55
+ <Story />
56
+ </div>
57
+ ),
58
+ ],
59
+ parameters: {
60
+ docs: {
61
+ description: {
62
+ component:
63
+ "The busiest-events browser: a 3D perspective **ring** with a large lead poster flanked by desaturating, shrinking side posters. A card's slot is the *signed shortest way round the list*, not `index - activeIndex`, so both flanks stay full at every index — at `activeIndex: 0` the last item sits immediately to the left of the lead rather than leaving that half of the stage empty — and prev/next carry on past either end, reporting the wrapped index through `onActiveIndexChange`. Only `|offset| <= 3` is mounted, so a long list paints seven posters, not all of them. Exactly one card per step crosses the ring's seam; it renders without its transition classes for that one commit so it teleports round the back instantly instead of sliding across the face of the stage. The lane drifts a few pixels back and forth, pauses off-screen, and stops outright (along with the position transition) under `prefers-reduced-motion`. Every rendered card is a real, focusable control — the injected link when it carries an `href`, otherwise a `<button>` — so a keyboard or screen-reader user can jump straight to any of them. The prev/next arrows float over the stage, vertically centred on the poster row, and are not rendered at all for a single item, where they would be inert.",
64
+ },
65
+ },
66
+ },
67
+ };
68
+
69
+ export default meta;
70
+ type Story = StoryObj<typeof Coverflow>;
71
+
72
+ export const Default: Story = {};
73
+
74
+ /**
75
+ * The ring at its old worst case. `activeIndex: 0` used to leave the entire
76
+ * left half of the stage empty with three cards stacked to the right; now item
77
+ * 6 renders at offset −1, item 5 at −2 and item 4 at −3, so both flanks are
78
+ * full. Step backwards from here and the index reported to the parent is 6,
79
+ * not a clamp at 0.
80
+ */
81
+ export const RingAtIndexZero: Story = {
82
+ render: (args) => {
83
+ function RingCoverflow() {
84
+ const [activeIndex, setActiveIndex] = useState(0);
85
+ return (
86
+ <Coverflow
87
+ {...args}
88
+ activeIndex={activeIndex}
89
+ positionLabel={`${activeIndex + 1} of ${args.items!.length}`}
90
+ onActiveIndexChange={setActiveIndex}
91
+ />
92
+ );
93
+ }
94
+ return <RingCoverflow />;
95
+ },
96
+ parameters: {
97
+ docs: {
98
+ description: {
99
+ story:
100
+ "Start at 0 and press *Previous*: the ring turns to item 6 instead of refusing. One card per step comes round the back — it teleports at the deepest tier, off-stage and behind the depth stack, rather than tweening across the middle.",
101
+ },
102
+ },
103
+ },
104
+ };
105
+
106
+ export const Free: Story = {
107
+ args: { activeIndex: 1, positionLabel: "2 of 7" },
108
+ };
109
+
110
+ /**
111
+ * Three items: the ring is smaller than the visible window, so every item is
112
+ * on stage at once — one on each flank rather than three.
113
+ */
114
+ export const FewSidePosters: Story = {
115
+ args: { items: ITEMS.slice(0, 3), positionLabel: "1 of 3" },
116
+ };
117
+
118
+ /**
119
+ * Two items. The shortest path ties at `n / 2`, and the tie always resolves to
120
+ * the positive side, so the second card sits on the RIGHT flank at both
121
+ * indices instead of flipping sides as you step.
122
+ */
123
+ export const TwoItems: Story = {
124
+ render: (args) => {
125
+ function TwoItemCoverflow() {
126
+ const items = args.items!.slice(0, 2);
127
+ const [activeIndex, setActiveIndex] = useState(0);
128
+ return (
129
+ <Coverflow
130
+ {...args}
131
+ items={items}
132
+ activeIndex={activeIndex}
133
+ positionLabel={`${activeIndex + 1} of ${items.length}`}
134
+ onActiveIndexChange={setActiveIndex}
135
+ />
136
+ );
137
+ }
138
+ return <TwoItemCoverflow />;
139
+ },
140
+ };
141
+
142
+ /**
143
+ * A single item — no ring, so no side posters AND no arrows. A prev/next
144
+ * control here could only ever return to the card already showing, and this
145
+ * component does not render controls that look operable and do nothing.
146
+ */
147
+ export const NoSidePosters: Story = {
148
+ args: { items: ITEMS.slice(0, 1), positionLabel: "1 of 1" },
149
+ };
150
+
151
+ /**
152
+ * `caption` is optional. This is the consumer app's shape: `positionLabel`
153
+ * alone, on one clean line with no separator dot and no gap where the caption
154
+ * would have been. Passing a position string as `caption` *as well* is what
155
+ * printed "1 of 7 · 1 of 7".
156
+ */
157
+ export const PositionWithoutCaption: Story = {
158
+ args: { activeIndex: 2, caption: undefined, positionLabel: "3 of 7" },
159
+ };
160
+
161
+ /** Both halves, for a caller that has a real caption to add to the position. */
162
+ export const CaptionAndPosition: Story = {
163
+ args: { activeIndex: 2, caption: "7 events", positionLabel: "3 of 7" },
164
+ };
165
+
166
+ /**
167
+ * The caller owns `activeIndex` — the floating arrows advance it and the
168
+ * `aria-live` region announces the new lead. Watch the lead physically slide
169
+ * out to the side while the next card slides into its place.
170
+ */
171
+ export const Interactive: Story = {
172
+ render: (args) => {
173
+ function InteractiveCoverflow() {
174
+ const [activeIndex, setActiveIndex] = useState(0);
175
+ return (
176
+ <Coverflow
177
+ {...args}
178
+ activeIndex={activeIndex}
179
+ positionLabel={`${activeIndex + 1} of ${args.items!.length}`}
180
+ onActiveIndexChange={setActiveIndex}
181
+ />
182
+ );
183
+ }
184
+ return <InteractiveCoverflow />;
185
+ },
186
+ };
187
+
188
+ /**
189
+ * Clicking any visible side poster jumps straight to it — the
190
+ * keyboard/screen-reader path a mouse user already has, instead of only
191
+ * stepping with `prevLabel`/`nextLabel`.
192
+ */
193
+ export const SidePosterSelect: Story = {
194
+ render: (args) => {
195
+ function SelectableCoverflow() {
196
+ const [activeIndex, setActiveIndex] = useState(0);
197
+ return (
198
+ <Coverflow
199
+ {...args}
200
+ activeIndex={activeIndex}
201
+ positionLabel={`${activeIndex + 1} of ${args.items!.length}`}
202
+ onActiveIndexChange={setActiveIndex}
203
+ />
204
+ );
205
+ }
206
+ return <SelectableCoverflow />;
207
+ },
208
+ parameters: {
209
+ docs: {
210
+ description: {
211
+ story:
212
+ "Every visible side poster is a real button — click or Tab+Enter to any of them to jump straight there via `onActiveIndexChange`.",
213
+ },
214
+ },
215
+ },
216
+ };
217
+
218
+ /**
219
+ * Focus lands on the outermost, most degraded side poster (`|offset| === 3`)
220
+ * to prove the focus ring survives the tier's grayscale + reduced opacity —
221
+ * the ring lives on the un-filtered button wrapper, not the filtered media
222
+ * inside it.
223
+ */
224
+ export const SidePosterFocused: Story = {
225
+ args: { activeIndex: 3 },
226
+ play: async ({ canvasElement }) => {
227
+ const canvas = within(canvasElement);
228
+ const poster = await canvas.findByRole("button", { name: "Warehouse Rave" });
229
+ poster.focus();
230
+ await expect(poster).toHaveFocus();
231
+ },
232
+ parameters: {
233
+ docs: {
234
+ description: {
235
+ story:
236
+ "With `activeIndex` at 3, `Warehouse Rave` (index 0) sits three slots away on the left flank — the farthest, most desaturated tier still mounted. Its `focus-visible` ring is on the un-filtered button, so it stays crisp even though the poster art inside is grayscaled and dimmed.",
237
+ },
238
+ },
239
+ },
240
+ };