@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.
- package/dist/chunk-24RFI4RQ.js +167 -0
- package/dist/chunk-2XCZJWTV.js +121 -0
- package/dist/chunk-5RWTOT5Y.js +72 -0
- package/dist/chunk-62ES6N32.js +27 -0
- package/dist/chunk-AKQ57NXY.js +58 -0
- 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-Q6QH52LJ.js +38 -0
- package/dist/chunk-SY6RV476.js +48 -0
- package/dist/chunk-TH6W2PWG.js +213 -0
- package/dist/chunk-UDN7UHJW.js +60 -0
- package/dist/{chunk-TT3WFS6S.js → chunk-YOKHBAIH.js} +11 -11
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- 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 +110 -0
- package/dist/components/concept-compare.js +238 -0
- 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.d.ts +1 -1
- package/dist/components/event-compact-row.js +1 -1
- package/dist/components/event-detail-panel.d.ts +171 -25
- package/dist/components/event-detail-panel.js +472 -175
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +2 -2
- 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.d.ts +27 -0
- package/dist/components/hub-surface.js +30 -0
- 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 +65 -0
- package/dist/components/menu-family-card.js +79 -0
- package/dist/components/menu-field.d.ts +57 -0
- package/dist/components/menu-field.js +93 -0
- 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-hub-hero.d.ts +65 -0
- package/dist/components/menu-hub-hero.js +103 -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-intent-tiles.d.ts +31 -0
- package/dist/components/menu-intent-tiles.js +50 -0
- package/dist/components/menu-item.d.ts +127 -0
- package/dist/components/menu-item.js +5 -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 +90 -0
- package/dist/components/menu-mocks.js +504 -0
- package/dist/components/menu-types.d.ts +421 -0
- package/dist/components/menu-types.js +1 -0
- package/dist/components/motion-icon-controls.js +2 -2
- package/dist/components/motion-icon-set.js +21 -9
- package/dist/components/motion-icon.d.ts +22 -1
- package/dist/components/motion-icon.js +1 -1
- package/dist/components/open-now-list.d.ts +40 -0
- package/dist/components/open-now-list.js +69 -0
- 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 +112 -1
- 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/signature-chip.d.ts +37 -0
- package/dist/components/signature-chip.js +4 -0
- package/dist/components/snap-rail.d.ts +48 -5
- package/dist/components/snap-rail.js +3 -51
- package/dist/components/stat-banner.d.ts +47 -0
- package/dist/components/stat-banner.js +61 -0
- package/dist/components/story.d.ts +34 -0
- package/dist/components/story.js +3 -0
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +1 -1
- 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 +38 -0
- package/dist/components/venue-grade-dimensions.js +3 -0
- package/dist/components/venue-quotes.d.ts +32 -0
- package/dist/components/venue-quotes.js +3 -0
- package/dist/components/venue-wall.d.ts +41 -0
- package/dist/components/venue-wall.js +118 -0
- package/dist/components/weekday-theme-banner.d.ts +70 -0
- package/dist/components/weekday-theme-banner.js +127 -0
- package/dist/components/weekend-panel.js +2 -2
- package/package.json +1 -1
- package/src/components/colour-dictionary.stories.tsx +227 -0
- package/src/components/concept-compare-rail.tsx +131 -0
- package/src/components/concept-compare.stories.tsx +172 -0
- package/src/components/concept-compare.tsx +443 -0
- 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/event-compact-row.tsx +1 -1
- package/src/components/event-detail-panel.stories.tsx +205 -97
- package/src/components/event-detail-panel.test.tsx +181 -0
- package/src/components/event-detail-panel.tsx +703 -185
- 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.stories.tsx +68 -0
- package/src/components/hub-surface.tsx +50 -0
- 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 +120 -0
- package/src/components/menu-family-card.tsx +191 -0
- package/src/components/menu-field.stories.tsx +74 -0
- package/src/components/menu-field.tsx +199 -0
- 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-hub-hero.stories.tsx +92 -0
- package/src/components/menu-hub-hero.tsx +187 -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-intent-tiles.stories.tsx +54 -0
- package/src/components/menu-intent-tiles.tsx +76 -0
- package/src/components/menu-item.tsx +416 -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 +592 -0
- package/src/components/menu-types.ts +442 -0
- package/src/components/motion-icon-controls.tsx +11 -11
- package/src/components/motion-icon-set.tsx +36 -8
- package/src/components/motion-icon.test.tsx +23 -1
- package/src/components/motion-icon.tsx +20 -0
- package/src/components/open-now-list.stories.tsx +76 -0
- package/src/components/open-now-list.tsx +127 -0
- package/src/components/photo-ground.tsx +147 -0
- package/src/components/place-types.ts +112 -0
- package/src/components/qa-block.stories.tsx +43 -0
- package/src/components/qa-block.tsx +60 -0
- package/src/components/signature-chip.tsx +77 -0
- package/src/components/snap-rail.tsx +133 -9
- package/src/components/stat-banner.stories.tsx +100 -0
- package/src/components/stat-banner.tsx +117 -0
- 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.stories.tsx +83 -0
- package/src/components/venue-grade-dimensions.tsx +91 -0
- package/src/components/venue-quotes.stories.tsx +59 -0
- package/src/components/venue-quotes.tsx +64 -0
- package/src/components/venue-wall.stories.tsx +78 -0
- package/src/components/venue-wall.tsx +185 -0
- package/src/components/weekday-theme-banner.stories.tsx +113 -0
- package/src/components/weekday-theme-banner.tsx +231 -0
- 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
|
-
(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
};
|