@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.
- package/dist/chunk-24RFI4RQ.js +167 -0
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/{chunk-AXV5O2IZ.js → chunk-AKQ57NXY.js} +3 -3
- package/dist/chunk-CLLY6XB3.js +41 -0
- package/dist/chunk-DTXNDLSE.js +125 -0
- package/dist/chunk-F6FMZJDG.js +113 -0
- package/dist/{chunk-2WDJR6K7.js → chunk-G7IFL4Z4.js} +4 -1
- package/dist/chunk-MZL7CGCL.js +109 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-compare-rail.d.ts +50 -0
- package/dist/components/concept-compare-rail.js +5 -0
- package/dist/components/concept-compare.d.ts +75 -38
- package/dist/components/concept-compare.js +209 -133
- package/dist/components/concept-convergence.d.ts +41 -0
- package/dist/components/concept-convergence.js +53 -0
- package/dist/components/convergence.d.ts +25 -0
- package/dist/components/convergence.js +4 -0
- package/dist/components/date-input.d.ts +1 -1
- package/dist/components/event-card.js +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.js +1 -1
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +1 -1
- package/dist/components/event-list-row.js +2 -2
- package/dist/components/explainer-steps.d.ts +34 -0
- package/dist/components/explainer-steps.js +36 -0
- package/dist/components/glass-ground.d.ts +66 -0
- package/dist/components/glass-ground.js +3 -0
- package/dist/components/hub-surface.js +11 -1
- package/dist/components/hue-chip.d.ts +70 -0
- package/dist/components/hue-chip.js +3 -0
- package/dist/components/kitchen-dossier.d.ts +88 -0
- package/dist/components/kitchen-dossier.js +187 -0
- package/dist/components/menu-dead-end.d.ts +56 -0
- package/dist/components/menu-dead-end.js +49 -0
- package/dist/components/menu-family-block.d.ts +99 -0
- package/dist/components/menu-family-block.js +155 -0
- package/dist/components/menu-family-card.d.ts +33 -27
- package/dist/components/menu-family-card.js +43 -85
- package/dist/components/menu-field.js +92 -2
- package/dist/components/menu-filter-chips.d.ts +86 -0
- package/dist/components/menu-filter-chips.js +122 -0
- package/dist/components/menu-group-rows.d.ts +53 -0
- package/dist/components/menu-group-rows.js +67 -0
- package/dist/components/menu-index-header.d.ts +37 -0
- package/dist/components/menu-index-header.js +68 -0
- package/dist/components/menu-index-list.d.ts +48 -0
- package/dist/components/menu-index-list.js +96 -0
- package/dist/components/menu-intent-hero.d.ts +69 -0
- package/dist/components/menu-intent-hero.js +87 -0
- package/dist/components/menu-kitchen-list.d.ts +82 -0
- package/dist/components/menu-kitchen-list.js +5 -0
- package/dist/components/menu-mocks.d.ts +37 -2
- package/dist/components/menu-mocks.js +152 -27
- package/dist/components/menu-types.d.ts +182 -6
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +6 -6
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/photo-ground.d.ts +64 -0
- package/dist/components/photo-ground.js +3 -0
- package/dist/components/place-about-tab.js +2 -2
- package/dist/components/place-details-mobile.js +2 -2
- package/dist/components/place-types.d.ts +2 -2
- package/dist/components/qa-block.d.ts +36 -0
- package/dist/components/qa-block.js +24 -0
- package/dist/components/scroll-fade.js +3 -121
- package/dist/components/snap-rail.d.ts +24 -1
- package/dist/components/snap-rail.js +3 -109
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/text.d.ts +3 -3
- package/dist/components/top-comparison.d.ts +28 -0
- package/dist/components/top-comparison.js +76 -0
- package/dist/components/top-cross-link.d.ts +34 -0
- package/dist/components/top-cross-link.js +101 -0
- package/dist/components/top-ranking-entry.d.ts +53 -0
- package/dist/components/top-ranking-entry.js +217 -0
- package/dist/components/top-ranking-hero.d.ts +61 -0
- package/dist/components/top-ranking-hero.js +99 -0
- package/dist/components/top-ranking-legend.d.ts +69 -0
- package/dist/components/top-ranking-legend.js +124 -0
- package/dist/components/top-types.d.ts +109 -0
- package/dist/components/top-types.js +1 -0
- package/dist/components/venue-grade-dimensions.d.ts +3 -1
- package/dist/components/venue-grade-dimensions.js +1 -1
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +122 -54
- package/src/components/concept-compare.tsx +363 -196
- package/src/components/concept-convergence.stories.tsx +53 -0
- package/src/components/concept-convergence.tsx +90 -0
- package/src/components/convergence.stories.tsx +54 -0
- package/src/components/convergence.tsx +177 -0
- package/src/components/explainer-steps.stories.tsx +46 -0
- package/src/components/explainer-steps.tsx +74 -0
- package/src/components/glass-ground.tsx +129 -0
- package/src/components/hub-surface.tsx +11 -1
- package/src/components/hue-chip.stories.tsx +76 -0
- package/src/components/hue-chip.test.tsx +62 -0
- package/src/components/hue-chip.tsx +99 -0
- package/src/components/kitchen-dossier.stories.tsx +108 -0
- package/src/components/kitchen-dossier.tsx +322 -0
- package/src/components/menu-dead-end.stories.tsx +67 -0
- package/src/components/menu-dead-end.tsx +127 -0
- package/src/components/menu-family-block.stories.tsx +91 -0
- package/src/components/menu-family-block.tsx +314 -0
- package/src/components/menu-family-card.stories.tsx +43 -35
- package/src/components/menu-family-card.tsx +122 -142
- package/src/components/menu-filter-chips.stories.tsx +107 -0
- package/src/components/menu-filter-chips.tsx +238 -0
- package/src/components/menu-group-rows.stories.tsx +54 -0
- package/src/components/menu-group-rows.tsx +133 -0
- package/src/components/menu-index-header.stories.tsx +72 -0
- package/src/components/menu-index-header.tsx +125 -0
- package/src/components/menu-index-list.stories.tsx +50 -0
- package/src/components/menu-index-list.tsx +149 -0
- package/src/components/menu-intent-hero.stories.tsx +67 -0
- package/src/components/menu-intent-hero.tsx +189 -0
- package/src/components/menu-kitchen-list.stories.tsx +67 -0
- package/src/components/menu-kitchen-list.test.tsx +78 -0
- package/src/components/menu-kitchen-list.tsx +284 -0
- package/src/components/menu-mocks.ts +198 -23
- package/src/components/menu-types.ts +189 -6
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +6 -5
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +2 -2
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/snap-rail.tsx +58 -15
- package/src/components/story.tsx +225 -0
- package/src/components/top-comparison.stories.tsx +78 -0
- package/src/components/top-comparison.tsx +151 -0
- package/src/components/top-cross-link.stories.tsx +84 -0
- package/src/components/top-cross-link.tsx +171 -0
- package/src/components/top-ranking-entry.stories.tsx +171 -0
- package/src/components/top-ranking-entry.test.tsx +105 -0
- package/src/components/top-ranking-entry.tsx +425 -0
- package/src/components/top-ranking-hero.stories.tsx +76 -0
- package/src/components/top-ranking-hero.tsx +218 -0
- package/src/components/top-ranking-legend.stories.tsx +78 -0
- package/src/components/top-ranking-legend.tsx +239 -0
- package/src/components/top-types.ts +110 -0
- package/src/components/venue-grade-dimensions.tsx +2 -2
- package/dist/chunk-THOVPZNN.js +0 -37
- package/dist/chunk-YS6BHKZO.js +0 -92
- package/dist/components/ranked-venue-list.d.ts +0 -49
- package/dist/components/ranked-venue-list.js +0 -87
- package/dist/components/signature-line-list.d.ts +0 -45
- package/dist/components/signature-line-list.js +0 -99
- package/dist/components/venue-busyness-strip.d.ts +0 -35
- package/dist/components/venue-busyness-strip.js +0 -37
- package/dist/components/venue-signature-block.d.ts +0 -39
- package/dist/components/venue-signature-block.js +0 -6
- package/dist/components/venue-spotlight.d.ts +0 -74
- package/dist/components/venue-spotlight.js +0 -138
- package/src/components/ranked-venue-list.stories.tsx +0 -80
- package/src/components/ranked-venue-list.tsx +0 -177
- package/src/components/signature-line-list.stories.tsx +0 -85
- package/src/components/signature-line-list.tsx +0 -136
- package/src/components/venue-busyness-strip.stories.tsx +0 -66
- package/src/components/venue-busyness-strip.tsx +0 -64
- package/src/components/venue-signature-block.stories.tsx +0 -68
- package/src/components/venue-signature-block.tsx +0 -71
- package/src/components/venue-spotlight.stories.tsx +0 -141
- package/src/components/venue-spotlight.tsx +0 -236
- 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
|
-
(
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
<
|
|
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
|
+
}
|