@bearmenu/ui 0.8.9 → 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 (178) 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-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
  5. package/dist/chunk-CLLY6XB3.js +41 -0
  6. package/dist/chunk-DTXNDLSE.js +125 -0
  7. package/dist/chunk-F6FMZJDG.js +113 -0
  8. package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
  9. package/dist/chunk-MZL7CGCL.js +109 -0
  10. package/dist/chunk-SY6RV476.js +48 -0
  11. package/dist/chunk-UDN7UHJW.js +60 -0
  12. package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
  13. package/dist/components/badge.d.ts +1 -1
  14. package/dist/components/button.d.ts +1 -1
  15. package/dist/components/command.d.ts +7 -7
  16. package/dist/components/concept-compare-rail.d.ts +50 -0
  17. package/dist/components/concept-compare-rail.js +5 -0
  18. package/dist/components/concept-compare.d.ts +75 -38
  19. package/dist/components/concept-compare.js +209 -133
  20. package/dist/components/concept-convergence.d.ts +41 -0
  21. package/dist/components/concept-convergence.js +53 -0
  22. package/dist/components/convergence.d.ts +25 -0
  23. package/dist/components/convergence.js +4 -0
  24. package/dist/components/date-input.d.ts +1 -1
  25. package/dist/components/event-card.js +1 -1
  26. package/dist/components/event-compact-row.js +1 -1
  27. package/dist/components/event-detail-panel.js +1 -1
  28. package/dist/components/event-index-row.js +1 -1
  29. package/dist/components/event-list-row-wide.js +1 -1
  30. package/dist/components/event-list-row.js +2 -2
  31. package/dist/components/explainer-steps.d.ts +34 -0
  32. package/dist/components/explainer-steps.js +36 -0
  33. package/dist/components/glass-ground.d.ts +66 -0
  34. package/dist/components/glass-ground.js +3 -0
  35. package/dist/components/hub-surface.js +11 -1
  36. package/dist/components/hue-chip.d.ts +70 -0
  37. package/dist/components/hue-chip.js +3 -0
  38. package/dist/components/kitchen-dossier.d.ts +88 -0
  39. package/dist/components/kitchen-dossier.js +187 -0
  40. package/dist/components/menu-dead-end.d.ts +56 -0
  41. package/dist/components/menu-dead-end.js +49 -0
  42. package/dist/components/menu-family-block.d.ts +99 -0
  43. package/dist/components/menu-family-block.js +155 -0
  44. package/dist/components/menu-family-card.d.ts +33 -27
  45. package/dist/components/menu-family-card.js +43 -85
  46. package/dist/components/menu-field.js +92 -2
  47. package/dist/components/menu-filter-chips.d.ts +86 -0
  48. package/dist/components/menu-filter-chips.js +122 -0
  49. package/dist/components/menu-group-rows.d.ts +53 -0
  50. package/dist/components/menu-group-rows.js +67 -0
  51. package/dist/components/menu-index-header.d.ts +37 -0
  52. package/dist/components/menu-index-header.js +68 -0
  53. package/dist/components/menu-index-list.d.ts +48 -0
  54. package/dist/components/menu-index-list.js +96 -0
  55. package/dist/components/menu-intent-hero.d.ts +69 -0
  56. package/dist/components/menu-intent-hero.js +87 -0
  57. package/dist/components/menu-kitchen-list.d.ts +82 -0
  58. package/dist/components/menu-kitchen-list.js +5 -0
  59. package/dist/components/menu-mocks.d.ts +37 -2
  60. package/dist/components/menu-mocks.js +152 -27
  61. package/dist/components/menu-types.d.ts +182 -6
  62. package/dist/components/motion-icon-controls.js +2 -2
  63. package/dist/components/motion-icon-set.js +6 -6
  64. package/dist/components/motion-icon.d.ts +22 -1
  65. package/dist/components/motion-icon.js +1 -1
  66. package/dist/components/photo-ground.d.ts +64 -0
  67. package/dist/components/photo-ground.js +3 -0
  68. package/dist/components/place-about-tab.js +2 -2
  69. package/dist/components/place-details-mobile.js +2 -2
  70. package/dist/components/place-types.d.ts +2 -2
  71. package/dist/components/qa-block.d.ts +36 -0
  72. package/dist/components/qa-block.js +24 -0
  73. package/dist/components/scroll-fade.js +3 -121
  74. package/dist/components/snap-rail.d.ts +24 -1
  75. package/dist/components/snap-rail.js +3 -109
  76. package/dist/components/spinner.d.ts +2 -2
  77. package/dist/components/story.d.ts +34 -0
  78. package/dist/components/story.js +3 -0
  79. package/dist/components/text.d.ts +3 -3
  80. package/dist/components/top-comparison.d.ts +28 -0
  81. package/dist/components/top-comparison.js +76 -0
  82. package/dist/components/top-cross-link.d.ts +34 -0
  83. package/dist/components/top-cross-link.js +101 -0
  84. package/dist/components/top-ranking-entry.d.ts +53 -0
  85. package/dist/components/top-ranking-entry.js +217 -0
  86. package/dist/components/top-ranking-hero.d.ts +61 -0
  87. package/dist/components/top-ranking-hero.js +99 -0
  88. package/dist/components/top-ranking-legend.d.ts +69 -0
  89. package/dist/components/top-ranking-legend.js +124 -0
  90. package/dist/components/top-types.d.ts +109 -0
  91. package/dist/components/top-types.js +1 -0
  92. package/dist/components/venue-grade-dimensions.d.ts +3 -1
  93. package/dist/components/venue-grade-dimensions.js +1 -1
  94. package/dist/components/weekend-panel.js +2 -2
  95. package/package.json +1 -1
  96. package/src/components/concept-compare-rail.tsx +131 -0
  97. package/src/components/concept-compare.stories.tsx +122 -54
  98. package/src/components/concept-compare.tsx +363 -196
  99. package/src/components/concept-convergence.stories.tsx +53 -0
  100. package/src/components/concept-convergence.tsx +90 -0
  101. package/src/components/convergence.stories.tsx +54 -0
  102. package/src/components/convergence.tsx +177 -0
  103. package/src/components/explainer-steps.stories.tsx +46 -0
  104. package/src/components/explainer-steps.tsx +74 -0
  105. package/src/components/glass-ground.tsx +129 -0
  106. package/src/components/hub-surface.tsx +11 -1
  107. package/src/components/hue-chip.stories.tsx +76 -0
  108. package/src/components/hue-chip.test.tsx +62 -0
  109. package/src/components/hue-chip.tsx +99 -0
  110. package/src/components/kitchen-dossier.stories.tsx +108 -0
  111. package/src/components/kitchen-dossier.tsx +322 -0
  112. package/src/components/menu-dead-end.stories.tsx +67 -0
  113. package/src/components/menu-dead-end.tsx +127 -0
  114. package/src/components/menu-family-block.stories.tsx +91 -0
  115. package/src/components/menu-family-block.tsx +314 -0
  116. package/src/components/menu-family-card.stories.tsx +43 -35
  117. package/src/components/menu-family-card.tsx +122 -142
  118. package/src/components/menu-filter-chips.stories.tsx +107 -0
  119. package/src/components/menu-filter-chips.tsx +238 -0
  120. package/src/components/menu-group-rows.stories.tsx +54 -0
  121. package/src/components/menu-group-rows.tsx +133 -0
  122. package/src/components/menu-index-header.stories.tsx +72 -0
  123. package/src/components/menu-index-header.tsx +125 -0
  124. package/src/components/menu-index-list.stories.tsx +50 -0
  125. package/src/components/menu-index-list.tsx +149 -0
  126. package/src/components/menu-intent-hero.stories.tsx +67 -0
  127. package/src/components/menu-intent-hero.tsx +189 -0
  128. package/src/components/menu-kitchen-list.stories.tsx +67 -0
  129. package/src/components/menu-kitchen-list.test.tsx +78 -0
  130. package/src/components/menu-kitchen-list.tsx +284 -0
  131. package/src/components/menu-mocks.ts +198 -23
  132. package/src/components/menu-types.ts +189 -6
  133. package/src/components/motion-icon-controls.tsx +11 -11
  134. package/src/components/motion-icon-set.tsx +6 -5
  135. package/src/components/motion-icon.test.tsx +23 -1
  136. package/src/components/motion-icon.tsx +20 -0
  137. package/src/components/photo-ground.tsx +147 -0
  138. package/src/components/place-types.ts +2 -2
  139. package/src/components/qa-block.stories.tsx +43 -0
  140. package/src/components/qa-block.tsx +60 -0
  141. package/src/components/snap-rail.tsx +58 -15
  142. package/src/components/story.tsx +225 -0
  143. package/src/components/top-comparison.stories.tsx +78 -0
  144. package/src/components/top-comparison.tsx +151 -0
  145. package/src/components/top-cross-link.stories.tsx +84 -0
  146. package/src/components/top-cross-link.tsx +171 -0
  147. package/src/components/top-ranking-entry.stories.tsx +171 -0
  148. package/src/components/top-ranking-entry.test.tsx +105 -0
  149. package/src/components/top-ranking-entry.tsx +425 -0
  150. package/src/components/top-ranking-hero.stories.tsx +76 -0
  151. package/src/components/top-ranking-hero.tsx +218 -0
  152. package/src/components/top-ranking-legend.stories.tsx +78 -0
  153. package/src/components/top-ranking-legend.tsx +239 -0
  154. package/src/components/top-types.ts +110 -0
  155. package/src/components/venue-grade-dimensions.tsx +2 -2
  156. package/dist/chunk-THOVPZNN.js +0 -37
  157. package/dist/chunk-YS6BHKZO.js +0 -92
  158. package/dist/components/ranked-venue-list.d.ts +0 -49
  159. package/dist/components/ranked-venue-list.js +0 -87
  160. package/dist/components/signature-line-list.d.ts +0 -45
  161. package/dist/components/signature-line-list.js +0 -99
  162. package/dist/components/venue-busyness-strip.d.ts +0 -35
  163. package/dist/components/venue-busyness-strip.js +0 -37
  164. package/dist/components/venue-signature-block.d.ts +0 -39
  165. package/dist/components/venue-signature-block.js +0 -6
  166. package/dist/components/venue-spotlight.d.ts +0 -74
  167. package/dist/components/venue-spotlight.js +0 -138
  168. package/src/components/ranked-venue-list.stories.tsx +0 -80
  169. package/src/components/ranked-venue-list.tsx +0 -177
  170. package/src/components/signature-line-list.stories.tsx +0 -85
  171. package/src/components/signature-line-list.tsx +0 -136
  172. package/src/components/venue-busyness-strip.stories.tsx +0 -66
  173. package/src/components/venue-busyness-strip.tsx +0 -64
  174. package/src/components/venue-signature-block.stories.tsx +0 -68
  175. package/src/components/venue-signature-block.tsx +0 -71
  176. package/src/components/venue-spotlight.stories.tsx +0 -141
  177. package/src/components/venue-spotlight.tsx +0 -236
  178. package/dist/{chunk-XWMRABZW.js → chunk-HSIYYA2E.js} +1 -1
@@ -0,0 +1,60 @@
1
+ import { cn } from "../lib/utils";
2
+ import type { QaItem } from "./menu-types";
3
+
4
+ /**
5
+ * QaBlock — questions answered from the numbers, as static prose.
6
+ *
7
+ * ## Never an accordion
8
+ *
9
+ * This block exists to be crawled as much as read: it is the visible half of
10
+ * the page's `FAQPage` markup, and the rule is that structured data must mirror
11
+ * content the reader can actually see. Collapsing it hides every answer from
12
+ * the crawler's first paint and buys nothing — three short answers are cheaper
13
+ * to scroll past than to expand.
14
+ *
15
+ * The caller renders the JSON-LD from the SAME array it passes here, so the
16
+ * markup and the visible answers cannot drift.
17
+ *
18
+ * ## Three across, then one
19
+ *
20
+ * Answers are two or three lines each, so a single column wastes the desktop
21
+ * measure and a two-column grid leaves an orphan. On the phone it is one
22
+ * column, in order.
23
+ */
24
+
25
+ export type QaBlockProps = {
26
+ items: QaItem[];
27
+ /** Section heading, e.g. "Questions people ask". Omit where the page supplies one. */
28
+ heading?: string;
29
+ /** Heading level for the questions. Defaults to `h3`, under a page's `h2`. */
30
+ questionAs?: "h3" | "h4";
31
+ className?: string;
32
+ };
33
+
34
+ export function QaBlock({ items, heading, questionAs: Question = "h3", className }: QaBlockProps) {
35
+ return (
36
+ <section className={cn(className)}>
37
+ {heading && (
38
+ <h2 className="text-[15px] font-bold leading-tight tracking-[-0.01em]">{heading}</h2>
39
+ )}
40
+
41
+ <div
42
+ className={cn(
43
+ "grid grid-cols-1 gap-[18px] lg:grid-cols-3 lg:gap-[26px]",
44
+ heading && "mt-3.5"
45
+ )}
46
+ >
47
+ {items.map((item) => (
48
+ <div key={item.question}>
49
+ <Question className="text-sm font-bold leading-[1.35] tracking-[-0.01em] lg:text-[15px]">
50
+ {item.question}
51
+ </Question>
52
+ <p className="mt-[7px] text-[13px] leading-[1.65] text-muted-foreground [text-wrap:pretty] lg:text-[13.5px]">
53
+ {item.answer}
54
+ </p>
55
+ </div>
56
+ ))}
57
+ </div>
58
+ </section>
59
+ );
60
+ }
@@ -64,13 +64,27 @@ export type SnapRailProps = React.HTMLAttributes<HTMLDivElement> &
64
64
  dots?: boolean;
65
65
  /** Classes for the dots row, e.g. `lg:hidden` where the rail becomes a grid. */
66
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;
67
73
  };
68
74
 
69
75
  export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
70
- ({ className, itemWidth, gap, bleed, dots, dotsClassName, children, ...props }, ref) => {
76
+ (
77
+ { className, itemWidth, gap, bleed, dots, dotsClassName, onActiveChange, children, ...props },
78
+ ref
79
+ ) => {
71
80
  const railRef = React.useRef<HTMLDivElement | null>(null);
72
81
  const [active, setActive] = React.useState(0);
73
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;
74
88
 
75
89
  const setRefs = React.useCallback(
76
90
  (node: HTMLDivElement | null) => {
@@ -85,13 +99,19 @@ export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
85
99
  // the current page. Read on scroll, rAF-throttled; passive so it never
86
100
  // blocks the swipe it watches.
87
101
  React.useEffect(() => {
88
- if (!dots) return;
102
+ if (!tracks) return;
89
103
  const rail = railRef.current;
90
104
  if (!rail) return;
91
105
  let frame = 0;
106
+ let last = -1;
92
107
  const sync = () => {
93
108
  frame = 0;
94
- const origin = rail.scrollLeft + parseFloat(getComputedStyle(rail).scrollPaddingLeft || "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);
95
115
  let best = 0;
96
116
  let bestDistance = Number.POSITIVE_INFINITY;
97
117
  Array.from(rail.children).forEach((child, index) => {
@@ -102,6 +122,10 @@ export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
102
122
  }
103
123
  });
104
124
  setActive(best);
125
+ if (best !== last) {
126
+ last = best;
127
+ onActiveChangeRef.current?.(best);
128
+ }
105
129
  };
106
130
  const onScroll = () => {
107
131
  if (!frame) frame = requestAnimationFrame(sync);
@@ -112,7 +136,7 @@ export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
112
136
  if (frame) cancelAnimationFrame(frame);
113
137
  rail.removeEventListener("scroll", onScroll);
114
138
  };
115
- }, [dots, count]);
139
+ }, [tracks, count]);
116
140
 
117
141
  const rail = (
118
142
  <div
@@ -130,19 +154,38 @@ export const SnapRail = React.forwardRef<HTMLDivElement, SnapRailProps>(
130
154
  return (
131
155
  <div className="flex flex-col gap-3">
132
156
  {rail}
133
- <div aria-hidden className={cn("flex justify-center gap-1.5", dotsClassName)}>
134
- {Array.from({ length: count }, (_, index) => (
135
- <span
136
- key={index}
137
- className={cn(
138
- "h-1.5 rounded-full transition-[width,background-color] duration-200 motion-reduce:transition-none",
139
- index === active ? "w-4 bg-foreground" : "w-1.5 bg-foreground/25"
140
- )}
141
- />
142
- ))}
143
- </div>
157
+ <SnapRailDots count={count} active={active} className={dotsClassName} />
144
158
  </div>
145
159
  );
146
160
  }
147
161
  );
148
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,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
+ };
@@ -0,0 +1,151 @@
1
+ import { Moon, Sun } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
4
+ import type { TopComparisonRow } from "./top-types";
5
+
6
+ /**
7
+ * TopComparison — the ranking side by side.
8
+ *
9
+ * The only place in the product where you can compare the whole shortlist at
10
+ * once on the same axes, and it costs nothing because it is static. It is not
11
+ * a filter: narrowing links out to the browsing tool.
12
+ *
13
+ * ## A table on desktop, a ranked strip on the phone
14
+ *
15
+ * Seven columns at 358px is unreadable, and a horizontally scrolling table
16
+ * hides the column you are comparing — which is the one thing the control
17
+ * exists to do. The strip keeps the side-by-side scan, which is the table's
18
+ * actual job, by dropping to the four facts that fit: rank, name, signature
19
+ * with its price, and the grade, with late and terrace as glyphs.
20
+ *
21
+ * Two renderings of one dataset, so they cannot disagree.
22
+ */
23
+
24
+ function DefaultLink({ href, ...props }: MenuLinkProps) {
25
+ return <a href={href} {...props} />;
26
+ }
27
+
28
+ export type TopComparisonLabels = {
29
+ venue: string;
30
+ grade: string;
31
+ signature: string;
32
+ price: string;
33
+ late: string;
34
+ terrace: string;
35
+ /** Screen-reader text for a "no" cell — never an empty cell. */
36
+ no: string;
37
+ yes: string;
38
+ };
39
+
40
+ export type TopComparisonProps = {
41
+ rows: TopComparisonRow[];
42
+ labels: TopComparisonLabels;
43
+ /** Accessible name for the table. */
44
+ caption: string;
45
+ linkComponent?: MenuLinkComponent;
46
+ className?: string;
47
+ };
48
+
49
+ export function TopComparison({
50
+ rows,
51
+ labels,
52
+ caption,
53
+ linkComponent: LinkComponent = DefaultLink,
54
+ className,
55
+ }: TopComparisonProps) {
56
+ return (
57
+ <div className={className}>
58
+ {/* Desktop: the full table. `overflow-x-auto` on the wrapper so a narrow
59
+ desktop window scrolls the table rather than the page. */}
60
+ <div className="hidden overflow-x-auto lg:block">
61
+ <table className="w-full border-collapse text-[13px]">
62
+ <caption className="sr-only">{caption}</caption>
63
+ <thead>
64
+ <tr className="border-b border-border text-left text-[11px] font-bold uppercase tracking-[0.07em] text-muted-foreground">
65
+ <th scope="col" className="w-10 py-2 font-bold">
66
+ <span className="sr-only">#</span>
67
+ </th>
68
+ <th scope="col" className="py-2 font-bold">{labels.venue}</th>
69
+ <th scope="col" className="py-2 font-bold">{labels.grade}</th>
70
+ <th scope="col" className="py-2 font-bold">{labels.signature}</th>
71
+ <th scope="col" className="py-2 font-bold">{labels.price}</th>
72
+ <th scope="col" className="py-2 font-bold">{labels.late}</th>
73
+ <th scope="col" className="py-2 font-bold">{labels.terrace}</th>
74
+ </tr>
75
+ </thead>
76
+ <tbody>
77
+ {rows.map((row) => (
78
+ <tr key={row.name} className="border-b border-border/60">
79
+ <td className="py-2.5 tabular-nums text-muted-foreground">
80
+ {String(row.rank).padStart(2, "0")}
81
+ </td>
82
+ <td className="py-2.5 font-semibold">
83
+ <LinkComponent
84
+ href={row.href}
85
+ className="hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
86
+ >
87
+ {row.name}
88
+ </LinkComponent>
89
+ </td>
90
+ <td className="py-2.5 font-bold tabular-nums text-accent">{row.gradeLabel ?? "—"}</td>
91
+ <td className="py-2.5 text-muted-foreground">{row.signature ?? "—"}</td>
92
+ <td className="py-2.5 tabular-nums">{row.priceLabel ?? "—"}</td>
93
+ <td className="py-2.5">
94
+ <Mark on={row.late} yes={labels.yes} no={labels.no} />
95
+ </td>
96
+ <td className="py-2.5">
97
+ <Mark on={row.terrace} yes={labels.yes} no={labels.no} />
98
+ </td>
99
+ </tr>
100
+ ))}
101
+ </tbody>
102
+ </table>
103
+ </div>
104
+
105
+ {/* Phone: one row per venue. */}
106
+ <ul className="lg:hidden">
107
+ {rows.map((row) => (
108
+ <li key={row.name} className="border-t border-border first:border-t-0">
109
+ <LinkComponent
110
+ href={row.href}
111
+ className="flex items-center gap-3 py-2.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
112
+ >
113
+ <span className="w-6 shrink-0 text-[13px] font-bold tabular-nums text-muted-foreground">
114
+ {String(row.rank).padStart(2, "0")}
115
+ </span>
116
+ <span className="min-w-0 flex-1">
117
+ <span className="block truncate text-[13.5px] font-bold">{row.name}</span>
118
+ {(row.signature || row.priceLabel) && (
119
+ <span className="block truncate text-[11.5px] text-muted-foreground">
120
+ {[row.signature, row.priceLabel].filter(Boolean).join(" · ")}
121
+ </span>
122
+ )}
123
+ </span>
124
+ <span className="flex shrink-0 items-center gap-1 text-muted-foreground">
125
+ {row.late && <Moon className="h-3.5 w-3.5" aria-label={labels.late} />}
126
+ {row.terrace && <Sun className="h-3.5 w-3.5" aria-label={labels.terrace} />}
127
+ </span>
128
+ {row.gradeLabel && (
129
+ <span className="shrink-0 text-[13px] font-extrabold tabular-nums text-accent">
130
+ {row.gradeLabel}
131
+ </span>
132
+ )}
133
+ </LinkComponent>
134
+ </li>
135
+ ))}
136
+ </ul>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ /** A yes/no cell that a screen reader can read — never a bare dash. */
142
+ function Mark({ on, yes, no }: { on?: boolean; yes: string; no: string }) {
143
+ return on ? (
144
+ <span className="font-semibold text-[color:var(--free,oklch(0.45_0.13_155))]">{yes}</span>
145
+ ) : (
146
+ <span className="text-muted-foreground">
147
+ <span aria-hidden>—</span>
148
+ <span className="sr-only">{no}</span>
149
+ </span>
150
+ );
151
+ }