@bearmenu/ui 0.8.8 → 0.8.10

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 (206) hide show
  1. package/dist/chunk-24RFI4RQ.js +167 -0
  2. package/dist/chunk-2XCZJWTV.js +121 -0
  3. package/dist/chunk-5RWTOT5Y.js +72 -0
  4. package/dist/chunk-62ES6N32.js +27 -0
  5. package/dist/chunk-AKQ57NXY.js +58 -0
  6. package/dist/chunk-CLLY6XB3.js +41 -0
  7. package/dist/chunk-DTXNDLSE.js +125 -0
  8. package/dist/chunk-F6FMZJDG.js +113 -0
  9. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  10. package/dist/chunk-MZL7CGCL.js +109 -0
  11. package/dist/chunk-Q6QH52LJ.js +38 -0
  12. package/dist/chunk-SY6RV476.js +48 -0
  13. package/dist/chunk-TH6W2PWG.js +213 -0
  14. package/dist/chunk-UDN7UHJW.js +60 -0
  15. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  16. package/dist/components/alert.d.ts +2 -2
  17. package/dist/components/badge.d.ts +1 -1
  18. package/dist/components/concept-compare-rail.d.ts +50 -0
  19. package/dist/components/concept-compare-rail.js +5 -0
  20. package/dist/components/concept-compare.d.ts +110 -0
  21. package/dist/components/concept-compare.js +238 -0
  22. package/dist/components/concept-convergence.d.ts +41 -0
  23. package/dist/components/concept-convergence.js +53 -0
  24. package/dist/components/convergence.d.ts +25 -0
  25. package/dist/components/convergence.js +4 -0
  26. package/dist/components/date-input.d.ts +1 -1
  27. package/dist/components/event-card.js +1 -1
  28. package/dist/components/event-compact-row.d.ts +1 -1
  29. package/dist/components/event-compact-row.js +1 -1
  30. package/dist/components/event-detail-panel.d.ts +171 -25
  31. package/dist/components/event-detail-panel.js +472 -175
  32. package/dist/components/event-index-row.js +1 -1
  33. package/dist/components/event-list-row-wide.js +2 -2
  34. package/dist/components/event-list-row.js +2 -2
  35. package/dist/components/explainer-steps.d.ts +34 -0
  36. package/dist/components/explainer-steps.js +36 -0
  37. package/dist/components/glass-ground.d.ts +66 -0
  38. package/dist/components/glass-ground.js +3 -0
  39. package/dist/components/hub-surface.d.ts +27 -0
  40. package/dist/components/hub-surface.js +30 -0
  41. package/dist/components/hue-chip.d.ts +70 -0
  42. package/dist/components/hue-chip.js +3 -0
  43. package/dist/components/kitchen-dossier.d.ts +88 -0
  44. package/dist/components/kitchen-dossier.js +187 -0
  45. package/dist/components/menu-dead-end.d.ts +56 -0
  46. package/dist/components/menu-dead-end.js +49 -0
  47. package/dist/components/menu-family-block.d.ts +99 -0
  48. package/dist/components/menu-family-block.js +155 -0
  49. package/dist/components/menu-family-card.d.ts +65 -0
  50. package/dist/components/menu-family-card.js +79 -0
  51. package/dist/components/menu-field.d.ts +57 -0
  52. package/dist/components/menu-field.js +93 -0
  53. package/dist/components/menu-filter-chips.d.ts +86 -0
  54. package/dist/components/menu-filter-chips.js +122 -0
  55. package/dist/components/menu-group-rows.d.ts +53 -0
  56. package/dist/components/menu-group-rows.js +67 -0
  57. package/dist/components/menu-hub-hero.d.ts +65 -0
  58. package/dist/components/menu-hub-hero.js +103 -0
  59. package/dist/components/menu-index-header.d.ts +37 -0
  60. package/dist/components/menu-index-header.js +68 -0
  61. package/dist/components/menu-index-list.d.ts +48 -0
  62. package/dist/components/menu-index-list.js +96 -0
  63. package/dist/components/menu-intent-hero.d.ts +69 -0
  64. package/dist/components/menu-intent-hero.js +87 -0
  65. package/dist/components/menu-intent-tiles.d.ts +31 -0
  66. package/dist/components/menu-intent-tiles.js +50 -0
  67. package/dist/components/menu-item.d.ts +127 -0
  68. package/dist/components/menu-item.js +5 -0
  69. package/dist/components/menu-kitchen-list.d.ts +82 -0
  70. package/dist/components/menu-kitchen-list.js +5 -0
  71. package/dist/components/menu-mocks.d.ts +90 -0
  72. package/dist/components/menu-mocks.js +504 -0
  73. package/dist/components/menu-types.d.ts +421 -0
  74. package/dist/components/menu-types.js +1 -0
  75. package/dist/components/motion-icon-controls.js +2 -2
  76. package/dist/components/motion-icon-set.js +21 -9
  77. package/dist/components/motion-icon.d.ts +22 -1
  78. package/dist/components/motion-icon.js +1 -1
  79. package/dist/components/open-now-list.d.ts +40 -0
  80. package/dist/components/open-now-list.js +69 -0
  81. package/dist/components/photo-ground.d.ts +64 -0
  82. package/dist/components/photo-ground.js +3 -0
  83. package/dist/components/place-about-tab.js +2 -2
  84. package/dist/components/place-details-mobile.js +2 -2
  85. package/dist/components/place-types.d.ts +112 -1
  86. package/dist/components/qa-block.d.ts +36 -0
  87. package/dist/components/qa-block.js +24 -0
  88. package/dist/components/scroll-fade.js +3 -121
  89. package/dist/components/signature-chip.d.ts +37 -0
  90. package/dist/components/signature-chip.js +4 -0
  91. package/dist/components/snap-rail.d.ts +48 -5
  92. package/dist/components/snap-rail.js +3 -51
  93. package/dist/components/stat-banner.d.ts +47 -0
  94. package/dist/components/stat-banner.js +61 -0
  95. package/dist/components/story.d.ts +34 -0
  96. package/dist/components/story.js +3 -0
  97. package/dist/components/tag.d.ts +2 -2
  98. package/dist/components/text.d.ts +1 -1
  99. package/dist/components/top-comparison.d.ts +28 -0
  100. package/dist/components/top-comparison.js +76 -0
  101. package/dist/components/top-cross-link.d.ts +34 -0
  102. package/dist/components/top-cross-link.js +101 -0
  103. package/dist/components/top-ranking-entry.d.ts +53 -0
  104. package/dist/components/top-ranking-entry.js +217 -0
  105. package/dist/components/top-ranking-hero.d.ts +61 -0
  106. package/dist/components/top-ranking-hero.js +99 -0
  107. package/dist/components/top-ranking-legend.d.ts +69 -0
  108. package/dist/components/top-ranking-legend.js +124 -0
  109. package/dist/components/top-types.d.ts +109 -0
  110. package/dist/components/top-types.js +1 -0
  111. package/dist/components/venue-grade-dimensions.d.ts +38 -0
  112. package/dist/components/venue-grade-dimensions.js +3 -0
  113. package/dist/components/venue-quotes.d.ts +32 -0
  114. package/dist/components/venue-quotes.js +3 -0
  115. package/dist/components/venue-wall.d.ts +41 -0
  116. package/dist/components/venue-wall.js +118 -0
  117. package/dist/components/weekday-theme-banner.d.ts +70 -0
  118. package/dist/components/weekday-theme-banner.js +127 -0
  119. package/dist/components/weekend-panel.js +2 -2
  120. package/package.json +1 -1
  121. package/src/components/colour-dictionary.stories.tsx +227 -0
  122. package/src/components/concept-compare-rail.tsx +131 -0
  123. package/src/components/concept-compare.stories.tsx +172 -0
  124. package/src/components/concept-compare.tsx +443 -0
  125. package/src/components/concept-convergence.stories.tsx +53 -0
  126. package/src/components/concept-convergence.tsx +90 -0
  127. package/src/components/convergence.stories.tsx +54 -0
  128. package/src/components/convergence.tsx +177 -0
  129. package/src/components/event-compact-row.tsx +1 -1
  130. package/src/components/event-detail-panel.stories.tsx +205 -97
  131. package/src/components/event-detail-panel.test.tsx +181 -0
  132. package/src/components/event-detail-panel.tsx +703 -185
  133. package/src/components/explainer-steps.stories.tsx +46 -0
  134. package/src/components/explainer-steps.tsx +74 -0
  135. package/src/components/glass-ground.tsx +129 -0
  136. package/src/components/hub-surface.stories.tsx +68 -0
  137. package/src/components/hub-surface.tsx +50 -0
  138. package/src/components/hue-chip.stories.tsx +76 -0
  139. package/src/components/hue-chip.test.tsx +62 -0
  140. package/src/components/hue-chip.tsx +99 -0
  141. package/src/components/kitchen-dossier.stories.tsx +108 -0
  142. package/src/components/kitchen-dossier.tsx +322 -0
  143. package/src/components/menu-dead-end.stories.tsx +67 -0
  144. package/src/components/menu-dead-end.tsx +127 -0
  145. package/src/components/menu-family-block.stories.tsx +91 -0
  146. package/src/components/menu-family-block.tsx +314 -0
  147. package/src/components/menu-family-card.stories.tsx +120 -0
  148. package/src/components/menu-family-card.tsx +191 -0
  149. package/src/components/menu-field.stories.tsx +74 -0
  150. package/src/components/menu-field.tsx +199 -0
  151. package/src/components/menu-filter-chips.stories.tsx +107 -0
  152. package/src/components/menu-filter-chips.tsx +238 -0
  153. package/src/components/menu-group-rows.stories.tsx +54 -0
  154. package/src/components/menu-group-rows.tsx +133 -0
  155. package/src/components/menu-hub-hero.stories.tsx +92 -0
  156. package/src/components/menu-hub-hero.tsx +187 -0
  157. package/src/components/menu-index-header.stories.tsx +72 -0
  158. package/src/components/menu-index-header.tsx +125 -0
  159. package/src/components/menu-index-list.stories.tsx +50 -0
  160. package/src/components/menu-index-list.tsx +149 -0
  161. package/src/components/menu-intent-hero.stories.tsx +67 -0
  162. package/src/components/menu-intent-hero.tsx +189 -0
  163. package/src/components/menu-intent-tiles.stories.tsx +54 -0
  164. package/src/components/menu-intent-tiles.tsx +76 -0
  165. package/src/components/menu-item.tsx +416 -0
  166. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  167. package/src/components/menu-kitchen-list.test.tsx +78 -0
  168. package/src/components/menu-kitchen-list.tsx +284 -0
  169. package/src/components/menu-mocks.ts +592 -0
  170. package/src/components/menu-types.ts +442 -0
  171. package/src/components/motion-icon-controls.tsx +11 -11
  172. package/src/components/motion-icon-set.tsx +36 -8
  173. package/src/components/motion-icon.test.tsx +23 -1
  174. package/src/components/motion-icon.tsx +20 -0
  175. package/src/components/open-now-list.stories.tsx +76 -0
  176. package/src/components/open-now-list.tsx +127 -0
  177. package/src/components/photo-ground.tsx +147 -0
  178. package/src/components/place-types.ts +112 -0
  179. package/src/components/qa-block.stories.tsx +43 -0
  180. package/src/components/qa-block.tsx +60 -0
  181. package/src/components/signature-chip.tsx +77 -0
  182. package/src/components/snap-rail.tsx +133 -9
  183. package/src/components/stat-banner.stories.tsx +100 -0
  184. package/src/components/stat-banner.tsx +117 -0
  185. package/src/components/story.tsx +225 -0
  186. package/src/components/top-comparison.stories.tsx +78 -0
  187. package/src/components/top-comparison.tsx +151 -0
  188. package/src/components/top-cross-link.stories.tsx +84 -0
  189. package/src/components/top-cross-link.tsx +171 -0
  190. package/src/components/top-ranking-entry.stories.tsx +171 -0
  191. package/src/components/top-ranking-entry.test.tsx +105 -0
  192. package/src/components/top-ranking-entry.tsx +425 -0
  193. package/src/components/top-ranking-hero.stories.tsx +76 -0
  194. package/src/components/top-ranking-hero.tsx +218 -0
  195. package/src/components/top-ranking-legend.stories.tsx +78 -0
  196. package/src/components/top-ranking-legend.tsx +239 -0
  197. package/src/components/top-types.ts +110 -0
  198. package/src/components/venue-grade-dimensions.stories.tsx +83 -0
  199. package/src/components/venue-grade-dimensions.tsx +91 -0
  200. package/src/components/venue-quotes.stories.tsx +59 -0
  201. package/src/components/venue-quotes.tsx +64 -0
  202. package/src/components/venue-wall.stories.tsx +78 -0
  203. package/src/components/venue-wall.tsx +185 -0
  204. package/src/components/weekday-theme-banner.stories.tsx +113 -0
  205. package/src/components/weekday-theme-banner.tsx +231 -0
  206. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -32,6 +32,8 @@ const snapRailVariants = cva(
32
32
  sm: "[&>*]:w-[46%]",
33
33
  md: "[&>*]:w-[60%]",
34
34
  lg: "[&>*]:w-[80%]",
35
+ /** One card per view — pair with `dots` so the reader knows there is more. */
36
+ full: "[&>*]:w-full",
35
37
  },
36
38
  gap: {
37
39
  sm: "gap-2",
@@ -52,16 +54,138 @@ const snapRailVariants = cva(
52
54
  );
53
55
 
54
56
  export type SnapRailProps = React.HTMLAttributes<HTMLDivElement> &
55
- VariantProps<typeof snapRailVariants>;
57
+ VariantProps<typeof snapRailVariants> & {
58
+ /**
59
+ * Page dots under the rail, one per child, the current one filled. For
60
+ * `itemWidth="full"`, where no next card peeks in to say there is more.
61
+ * Decorative: the rail itself is the control, and every card is reachable
62
+ * by scrolling, so the dots are `aria-hidden`.
63
+ */
64
+ dots?: boolean;
65
+ /** Classes for the dots row, e.g. `lg:hidden` where the rail becomes a grid. */
66
+ dotsClassName?: string;
67
+ /**
68
+ * Reports the current page whenever it changes — for a caller that wants
69
+ * to react to the swipe beyond the dots, e.g. a backdrop that follows the
70
+ * active card. Tracking runs whenever this or `dots` is set.
71
+ */
72
+ onActiveChange?: (index: number) => void;
73
+ };
56
74
 
57
75
  export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
58
- ({ className, itemWidth, gap, bleed, ...props }, ref) => (
59
- <div
60
- ref={ref}
61
- data-slot="snap-rail"
62
- className={cn(snapRailVariants({ itemWidth, gap, bleed }), className)}
63
- {...props}
64
- />
65
- )
76
+ (
77
+ { className, itemWidth, gap, bleed, dots, dotsClassName, onActiveChange, children, ...props },
78
+ ref
79
+ ) => {
80
+ const railRef = React.useRef<HTMLDivElement | null>(null);
81
+ const [active, setActive] = React.useState(0);
82
+ const count = React.Children.count(children);
83
+ const tracks = Boolean(dots || onActiveChange);
84
+ // A ref, so a caller passing a fresh arrow each render does not re-bind
85
+ // the scroll listener each render.
86
+ const onActiveChangeRef = React.useRef(onActiveChange);
87
+ onActiveChangeRef.current = onActiveChange;
88
+
89
+ const setRefs = React.useCallback(
90
+ (node: HTMLDivElement | null) => {
91
+ railRef.current = node;
92
+ if (typeof ref === "function") ref(node);
93
+ else if (ref) ref.current = node;
94
+ },
95
+ [ref]
96
+ );
97
+
98
+ // The child whose left edge is nearest the rail's scroll-padding edge is
99
+ // the current page. Read on scroll, rAF-throttled; passive so it never
100
+ // blocks the swipe it watches.
101
+ React.useEffect(() => {
102
+ if (!tracks) return;
103
+ const rail = railRef.current;
104
+ if (!rail) return;
105
+ let frame = 0;
106
+ let last = -1;
107
+ const sync = () => {
108
+ frame = 0;
109
+ // `|| 0`, not `|| "0"` on the input: with no `scroll-px-*` the computed
110
+ // value is the string "auto", which parses to NaN, and every distance
111
+ // below became NaN — so the nearest child was never found and the page
112
+ // stayed at 0 forever. Only the `bleed` variants set a scroll padding,
113
+ // which is why every rail that used one appeared to work.
114
+ const origin = rail.scrollLeft + (parseFloat(getComputedStyle(rail).scrollPaddingLeft) || 0);
115
+ let best = 0;
116
+ let bestDistance = Number.POSITIVE_INFINITY;
117
+ Array.from(rail.children).forEach((child, index) => {
118
+ const distance = Math.abs((child as HTMLElement).offsetLeft - origin);
119
+ if (distance < bestDistance) {
120
+ bestDistance = distance;
121
+ best = index;
122
+ }
123
+ });
124
+ setActive(best);
125
+ if (best !== last) {
126
+ last = best;
127
+ onActiveChangeRef.current?.(best);
128
+ }
129
+ };
130
+ const onScroll = () => {
131
+ if (!frame) frame = requestAnimationFrame(sync);
132
+ };
133
+ sync();
134
+ rail.addEventListener("scroll", onScroll, { passive: true });
135
+ return () => {
136
+ if (frame) cancelAnimationFrame(frame);
137
+ rail.removeEventListener("scroll", onScroll);
138
+ };
139
+ }, [tracks, count]);
140
+
141
+ const rail = (
142
+ <div
143
+ ref={setRefs}
144
+ data-slot="snap-rail"
145
+ className={cn(snapRailVariants({ itemWidth, gap, bleed }), className)}
146
+ {...props}
147
+ >
148
+ {children}
149
+ </div>
150
+ );
151
+
152
+ if (!dots) return rail;
153
+
154
+ return (
155
+ <div className="flex flex-col gap-3">
156
+ {rail}
157
+ <SnapRailDots count={count} active={active} className={dotsClassName} />
158
+ </div>
159
+ );
160
+ }
66
161
  );
67
162
  SnapRail.displayName = "SnapRail";
163
+
164
+ /**
165
+ * The page dots on their own, for a caller that tracks the rail through
166
+ * `onActiveChange` and wants the dots somewhere other than directly under it —
167
+ * inside a foot band, say. Decorative, as on the rail: the rail is the control.
168
+ */
169
+ export function SnapRailDots({
170
+ count,
171
+ active,
172
+ className,
173
+ }: {
174
+ count: number;
175
+ active: number;
176
+ className?: string;
177
+ }) {
178
+ return (
179
+ <div aria-hidden className={cn("flex justify-center gap-1.5", className)}>
180
+ {Array.from({ length: count }, (_, index) => (
181
+ <span
182
+ key={index}
183
+ className={cn(
184
+ "h-1.5 rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none",
185
+ index === active ? "w-4 bg-foreground" : "w-1.5 bg-foreground/25"
186
+ )}
187
+ />
188
+ ))}
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,100 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { ArrowRight } from "lucide-react";
3
+ import { Button } from "./button";
4
+ import { StatBanner } from "./stat-banner";
5
+
6
+ const meta: Meta<typeof StatBanner> = {
7
+ title: "Menu/StatBanner",
8
+ component: StatBanner,
9
+ tags: ["autodocs"],
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-[520px] bg-background p-4">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ parameters: {
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "One claim about the catalogue, carried by a single number. These sit between the modules that list things and give the page somewhere to STATE what it knows rather than only showing it. Two side by side is the usual arrangement, which is a caller's grid rather than a `StatBannerPair` component — pairing is layout, and a consumer wanting one, three, or a different order should not have to fight a wrapper. Pass `scale` only when all three values come from the same real measurement.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+ type Story = StoryObj<typeof StatBanner>;
29
+
30
+ export const Dark: Story = {
31
+ args: {
32
+ tone: "dark",
33
+ kicker: "ONE DISH, A DOZEN NAMES",
34
+ headline: "“Margherita” appears on eighty-seven menus and never twice the same way.",
35
+ body: "We match them anyway, so the price comparison is real.",
36
+ watermark: "12",
37
+ action: (
38
+ <Button size="sm" variant="secondary">
39
+ How concepts work
40
+ </Button>
41
+ ),
42
+ },
43
+ };
44
+
45
+ export const Warm: Story = {
46
+ args: {
47
+ tone: "warm",
48
+ kicker: "THE REVIEW CENSUS",
49
+ figure: "100",
50
+ figureUnit: "%",
51
+ body: "Every one of the 45,140 items carries a mention count read from the reviews. The typical dish is named twelve times; the most-named, sixty-eight.",
52
+ scale: ["1", "12", "68"],
53
+ action: (
54
+ <Button size="sm" variant="ghost">
55
+ How the census works
56
+ <ArrowRight className="h-3.5 w-3.5" />
57
+ </Button>
58
+ ),
59
+ },
60
+ };
61
+
62
+ /** The pair, as the hub arranges it — a caller's grid, not a component. */
63
+ export const AsAPair: Story = {
64
+ render: () => (
65
+ <div className="grid gap-5 lg:grid-cols-2">
66
+ <StatBanner
67
+ tone="dark"
68
+ kicker="ONE DISH, A DOZEN NAMES"
69
+ headline="Eighty-seven menus, a dozen names."
70
+ watermark="12"
71
+ />
72
+ <StatBanner
73
+ tone="warm"
74
+ kicker="THE REVIEW CENSUS"
75
+ figure="100"
76
+ figureUnit="%"
77
+ body="A mention count on every item in the catalogue."
78
+ scale={["1", "12", "68"]}
79
+ />
80
+ </div>
81
+ ),
82
+ decorators: [
83
+ (Story) => (
84
+ <div className="w-[1000px] bg-background p-4">
85
+ <Story />
86
+ </div>
87
+ ),
88
+ ],
89
+ };
90
+
91
+ /** No scale — because nothing counted the distribution. */
92
+ export const WithoutScale: Story = {
93
+ args: {
94
+ tone: "warm",
95
+ kicker: "THE WIDEST SPREAD WE FOUND",
96
+ figure: "4.99",
97
+ figureUnit: "— 30 lei",
98
+ body: "The same espresso, across the ninety-eight bars and cafés that pour one.",
99
+ },
100
+ };
@@ -0,0 +1,117 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import { cn } from "../lib/utils";
3
+
4
+ /**
5
+ * StatBanner — one claim about the catalogue, carried by a single number.
6
+ *
7
+ * These sit between the modules that list things and give the page somewhere to
8
+ * state what it knows rather than only showing it. Two side by side is the
9
+ * usual arrangement, which is a caller's grid rather than a `StatBannerPair`
10
+ * component — pairing is layout, and a consumer wanting one, or three, or a
11
+ * different order should not have to fight a wrapper for it.
12
+ *
13
+ * `scale` draws the distribution the figure sits in — low, the figure, high.
14
+ * Pass it only when all three are real numbers from the same measurement; a
15
+ * scale invented around a figure is worse than no scale, because it implies a
16
+ * spread nobody counted.
17
+ *
18
+ * Server-safe.
19
+ */
20
+
21
+ const bannerVariants = cva(
22
+ "relative flex flex-col gap-2 overflow-hidden rounded-[18px] p-5",
23
+ {
24
+ variants: {
25
+ tone: {
26
+ /** Dark ground, for the claim that needs the most weight. */
27
+ dark: "bg-[oklch(0.21_0.012_55)] text-white",
28
+ /** Tinted ground, for the supporting figure beside it. */
29
+ warm: "bg-accent-subtle text-foreground",
30
+ },
31
+ },
32
+ defaultVariants: { tone: "dark" },
33
+ }
34
+ );
35
+
36
+ export type StatBannerProps = VariantProps<typeof bannerVariants> & {
37
+ /** Small caps kicker, e.g. "THE REVIEW CENSUS". */
38
+ kicker?: string;
39
+ /**
40
+ * The headline claim. On the dark tone this is the whole point; on the warm
41
+ * one it usually sits under `figure`.
42
+ */
43
+ headline?: string;
44
+ body?: string;
45
+ /** The number itself, pre-formatted, e.g. "100". */
46
+ figure?: string;
47
+ /** Unit set small beside the figure, e.g. "%" or "lei". */
48
+ figureUnit?: string;
49
+ /** Oversized watermark numeral behind the content. */
50
+ watermark?: string;
51
+ /** Low, middle and high of the distribution. All three, or none. */
52
+ scale?: [string, string, string];
53
+ /** The action — pass a rendered link or button. */
54
+ action?: React.ReactNode;
55
+ className?: string;
56
+ };
57
+
58
+ export function StatBanner({
59
+ tone,
60
+ kicker,
61
+ headline,
62
+ body,
63
+ figure,
64
+ figureUnit,
65
+ watermark,
66
+ scale,
67
+ action,
68
+ className,
69
+ }: StatBannerProps) {
70
+ return (
71
+ <div className={cn(bannerVariants({ tone }), className)}>
72
+ {watermark && (
73
+ <span
74
+ aria-hidden
75
+ className="pointer-events-none absolute -bottom-8 right-2 select-none font-serif text-[150px] font-bold leading-none opacity-[0.08]"
76
+ >
77
+ {watermark}
78
+ </span>
79
+ )}
80
+
81
+ <div className="relative z-[1] flex flex-1 flex-col gap-2">
82
+ {kicker && (
83
+ <span className="text-[10px] font-bold tracking-[0.11em] opacity-80">{kicker}</span>
84
+ )}
85
+
86
+ {figure && (
87
+ <div className="text-[40px] font-extrabold leading-none tracking-[-0.03em] tabular-nums">
88
+ {figure}
89
+ {figureUnit && (
90
+ <span className="ml-1 text-lg font-extrabold">{figureUnit}</span>
91
+ )}
92
+ </div>
93
+ )}
94
+
95
+ {headline && (
96
+ <h3 className="font-serif text-xl font-semibold leading-[1.2] tracking-[-0.02em] lg:text-2xl">
97
+ {headline}
98
+ </h3>
99
+ )}
100
+
101
+ {body && <p className="text-[13px] leading-[1.5] opacity-85">{body}</p>}
102
+
103
+ {scale && (
104
+ <div className="mt-1 flex items-center gap-2 text-[11px] tabular-nums opacity-70">
105
+ <span>{scale[0]}</span>
106
+ <span aria-hidden className="h-px flex-1 bg-current opacity-30" />
107
+ <span>{scale[1]}</span>
108
+ <span aria-hidden className="h-px flex-1 bg-current opacity-30" />
109
+ <span>{scale[2]}</span>
110
+ </div>
111
+ )}
112
+
113
+ {action && <div className="mt-auto flex items-center pt-3">{action}</div>}
114
+ </div>
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,225 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { useInView } from "framer-motion";
5
+ import { cn } from "../lib/utils";
6
+
7
+ /**
8
+ * Story — a few things said one at a time in the same space, on a timer.
9
+ *
10
+ * The kitchen dossier has three or four things worth saying about a place —
11
+ * the dish it is known for, what people say, its grade — and a story says
12
+ * them in one panel, part after part, instead of asking for a swipe per fact.
13
+ * Segments at the top show how many parts there are and which one is up;
14
+ * the panel cross-fades between them; a tap advances, a segment jumps.
15
+ *
16
+ * ## Autoplay only while looked at
17
+ *
18
+ * The timer runs only while the story is mostly in view (`useInView`, 60%)
19
+ * and not pressed, so a rail of six kitchens is not six timers and
20
+ * a card half-scrolled away does not burn through its story unseen.
21
+ *
22
+ * It runs under `prefers-reduced-motion` too. The setting is about motion —
23
+ * things sliding and fading — and the cross-fade honours it through
24
+ * `motion-reduce:transition-none`; but a story whose segments never fill
25
+ * reads as a broken story, not a calmer one, and the parts still change on
26
+ * their own schedule as they would in a slideshow. Pressing holds it.
27
+ *
28
+ * ## Three pieces, one state
29
+ *
30
+ * The segments sit at the card's top edge and the panel in its foot, with the
31
+ * card's own identity between them, so the state lives in a provider and the
32
+ * two pieces read it. The card stays a server component: it renders the
33
+ * parts and hands them to `StoryPanel` as nodes.
34
+ *
35
+ * ## The panel keeps its height
36
+ *
37
+ * Every part is rendered into the same grid cell and the inactive ones are
38
+ * hidden in place, so the panel is as tall as its tallest part at every
39
+ * moment and the card does not jump as the story runs.
40
+ */
41
+
42
+ type StoryContextValue = {
43
+ count: number;
44
+ active: number;
45
+ /** Progress of the active part, 0–1, for the segment fill. */
46
+ progress: number;
47
+ playing: boolean;
48
+ go: (index: number) => void;
49
+ next: () => void;
50
+ };
51
+
52
+ const StoryContext = React.createContext<StoryContextValue | null>(null);
53
+
54
+ function useStory(): StoryContextValue {
55
+ const value = React.useContext(StoryContext);
56
+ if (!value) throw new Error("Story pieces must render inside StoryProvider");
57
+ return value;
58
+ }
59
+
60
+ export type StoryProviderProps = {
61
+ /** How many parts the story has. */
62
+ count: number;
63
+ /** How long each part shows. */
64
+ durationMs?: number;
65
+ /** Accessible name of the tap-to-advance, e.g. "Next". */
66
+ nextLabel: string;
67
+ className?: string;
68
+ children: React.ReactNode;
69
+ };
70
+
71
+ /** How often the segment fill is redrawn while playing. */
72
+ const TICK_MS = 100;
73
+
74
+ export function StoryProvider({
75
+ count,
76
+ durationMs = 5000,
77
+ nextLabel,
78
+ className,
79
+ children,
80
+ }: StoryProviderProps) {
81
+ const ref = React.useRef<HTMLDivElement>(null);
82
+ const inView = useInView(ref, { amount: 0.6 });
83
+ const [active, setActive] = React.useState(0);
84
+ const [elapsed, setElapsed] = React.useState(0);
85
+ const [held, setHeld] = React.useState(false);
86
+
87
+ const playing = Boolean(inView && !held && count > 1);
88
+
89
+ const go = React.useCallback(
90
+ (index: number) => {
91
+ setActive(((index % count) + count) % count);
92
+ setElapsed(0);
93
+ },
94
+ [count]
95
+ );
96
+ const next = React.useCallback(() => go(active + 1), [go, active]);
97
+
98
+ /* Two effects, not one updater that also advances: a state updater must be
99
+ pure — React runs it twice in development — and an advance inside it
100
+ skipped every other part. The tick only counts; the roll-over is its own
101
+ effect. */
102
+ React.useEffect(() => {
103
+ if (!playing) return;
104
+ const timer = window.setInterval(() => setElapsed((previous) => previous + TICK_MS), TICK_MS);
105
+ return () => window.clearInterval(timer);
106
+ }, [playing]);
107
+
108
+ React.useEffect(() => {
109
+ if (elapsed < durationMs) return;
110
+ setActive((current) => (current + 1) % count);
111
+ setElapsed(0);
112
+ }, [elapsed, durationMs, count]);
113
+
114
+ const value = React.useMemo<StoryContextValue>(
115
+ () => ({ count, active, progress: Math.min(elapsed / durationMs, 1), playing, go, next }),
116
+ [count, active, elapsed, durationMs, playing, go, next]
117
+ );
118
+
119
+ /* A tap anywhere on the story that is not a link or a button advances —
120
+ the card's own targets (the name, the dish, the action, the segments)
121
+ keep theirs, and the rest of the card is "next". Keyboard readers move
122
+ through the segments, which are tabs. */
123
+ const advance = (event: React.MouseEvent<HTMLDivElement>) => {
124
+ if (count < 2) return;
125
+ if ((event.target as Element).closest("a, button")) return;
126
+ next();
127
+ };
128
+
129
+ return (
130
+ <StoryContext.Provider value={value}>
131
+ <div
132
+ ref={ref}
133
+ className={cn(count > 1 && "cursor-pointer", className)}
134
+ title={count > 1 ? nextLabel : undefined}
135
+ onClick={advance}
136
+ /* Held = pressed, not hovered. A cursor resting on a card is not a
137
+ reader holding a story still, and a hover-pause made the segments
138
+ look broken to anyone whose mouse happened to be over the rail. */
139
+ onPointerDown={() => setHeld(true)}
140
+ onPointerUp={() => setHeld(false)}
141
+ onPointerLeave={() => setHeld(false)}
142
+ onPointerCancel={() => setHeld(false)}
143
+ >
144
+ {children}
145
+ </div>
146
+ </StoryContext.Provider>
147
+ );
148
+ }
149
+
150
+ export type StorySegmentsProps = {
151
+ /**
152
+ * Accessible name per segment, one per part, e.g. `["Part 1 of 3", …]`.
153
+ * Strings rather than a function: a server component renders this and a
154
+ * function cannot cross into a client one.
155
+ */
156
+ labels: string[];
157
+ className?: string;
158
+ };
159
+
160
+ /** The segments: one per part, the active one filling on the timer. */
161
+ export function StorySegments({ labels, className }: StorySegmentsProps) {
162
+ const { count, active, progress, go } = useStory();
163
+ if (count < 2) return null;
164
+
165
+ return (
166
+ <div className={cn("flex gap-1", className)} role="tablist">
167
+ {Array.from({ length: count }, (_, index) => {
168
+ const fill = index < active ? 1 : index === active ? progress : 0;
169
+ return (
170
+ <button
171
+ key={index}
172
+ type="button"
173
+ role="tab"
174
+ aria-selected={index === active}
175
+ aria-label={labels[index]}
176
+ onClick={() => go(index)}
177
+ /* A 24px tall target around a 2px bar — the bar is the visual,
178
+ the button is the thumb's. */
179
+ className="group/segment flex h-6 flex-1 items-center focus-visible:outline-none"
180
+ >
181
+ <span className="relative block h-[3px] w-full overflow-hidden rounded-full bg-white/25 group-focus-visible/segment:ring-2 group-focus-visible/segment:ring-white/70">
182
+ <span
183
+ className="absolute inset-y-0 left-0 rounded-full bg-white"
184
+ style={{ width: `${fill * 100}%` }}
185
+ />
186
+ </span>
187
+ </button>
188
+ );
189
+ })}
190
+ </div>
191
+ );
192
+ }
193
+
194
+ export type StoryPanelProps = {
195
+ /** The parts, in order. Rendered by the caller; shown one at a time. */
196
+ parts: React.ReactNode[];
197
+ className?: string;
198
+ };
199
+
200
+ /** The panel: every part in one cell, the active one visible. */
201
+ export function StoryPanel({ parts, className }: StoryPanelProps) {
202
+ const { active } = useStory();
203
+
204
+ return (
205
+ <div className={cn("relative", className)}>
206
+ <div className="grid [&>*]:col-start-1 [&>*]:row-start-1">
207
+ {parts.map((part, index) => {
208
+ const isActive = index === active;
209
+ return (
210
+ <div
211
+ key={index}
212
+ aria-hidden={!isActive}
213
+ className={cn(
214
+ "transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none",
215
+ isActive ? "opacity-100" : "pointer-events-none invisible translate-y-1.5 opacity-0"
216
+ )}
217
+ >
218
+ {part}
219
+ </div>
220
+ );
221
+ })}
222
+ </div>
223
+ </div>
224
+ );
225
+ }
@@ -0,0 +1,78 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TopComparison } from "./top-comparison";
3
+ import type { TopComparisonRow } from "./top-types";
4
+
5
+ const rows: TopComparisonRow[] = [
6
+ ["Salt & Stone", "9.4", "Truffle Tagliatelle", "62 lei", true, true],
7
+ ["The Copper Yard", "9.1", "Carbonara", "48 lei", false, true],
8
+ ["Osteria Mica", "8.9", "Pizza Margherita", "38 lei", false, false],
9
+ ["Il Forno", "8.7", "Pizza Diavola", "44 lei", true, true],
10
+ ["Vecchio Mulino", "8.6", "Cacio e pepe", "46 lei", false, true],
11
+ ["La Nonna", "8.4", "Lasagne al forno", "54 lei", false, false],
12
+ ["Pasta Fresca", "8.1", "Gnocchi al pesto", "24 lei", false, false],
13
+ ].map(([name, gradeLabel, signature, priceLabel, late, terrace], index) => ({
14
+ rank: index + 1,
15
+ name: String(name),
16
+ href: `/places/${String(name).toLowerCase().replace(/[^a-z]+/g, "-")}`,
17
+ gradeLabel: String(gradeLabel),
18
+ signature: String(signature),
19
+ priceLabel: String(priceLabel),
20
+ late: Boolean(late),
21
+ terrace: Boolean(terrace),
22
+ }));
23
+
24
+ const labels = {
25
+ venue: "Kitchen",
26
+ grade: "Grade",
27
+ signature: "Signature",
28
+ price: "Price",
29
+ late: "Past midnight",
30
+ terrace: "Terrace",
31
+ yes: "yes",
32
+ no: "no",
33
+ };
34
+
35
+ const meta: Meta<typeof TopComparison> = {
36
+ title: "Top/TopComparison",
37
+ component: TopComparison,
38
+ tags: ["autodocs"],
39
+ args: { rows, labels, caption: "The ranking side by side" },
40
+ decorators: [
41
+ (Story) => (
42
+ <div className="w-[1000px] bg-background p-4">
43
+ <Story />
44
+ </div>
45
+ ),
46
+ ],
47
+ parameters: {
48
+ docs: {
49
+ description: {
50
+ component:
51
+ "The only place in the product where you can compare the whole shortlist on the same axes, and it is static so it costs nothing. **A table on desktop, a ranked strip on the phone**: seven columns at 358px is unreadable, and a horizontally scrolling table hides the column you are comparing — which is the one thing the control exists to do. Two renderings of one dataset, so they cannot disagree. A *no* cell carries screen-reader text rather than being an empty dash.",
52
+ },
53
+ },
54
+ },
55
+ };
56
+
57
+ export default meta;
58
+ type Story = StoryObj<typeof TopComparison>;
59
+
60
+ export const Default: Story = {};
61
+
62
+ /** The strip, which is what the table becomes under `lg`. */
63
+ export const Mobile: Story = {
64
+ decorators: [
65
+ (Story) => (
66
+ <div className="w-[390px] bg-background p-3">
67
+ <Story />
68
+ </div>
69
+ ),
70
+ ],
71
+ };
72
+
73
+ /** Missing facts render as dashes, never as blanks that read as "no". */
74
+ export const Sparse: Story = {
75
+ args: {
76
+ rows: rows.slice(0, 3).map((row) => ({ ...row, signature: undefined, priceLabel: undefined })),
77
+ },
78
+ };