@bearmenu/ui 0.8.16 → 0.8.18
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-WYTJR2YU.js → chunk-5CPBSNWQ.js} +6 -1
- package/dist/{chunk-36KEHCET.js → chunk-5LTILG3L.js} +15 -4
- package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
- package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
- package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
- package/dist/{chunk-ZLPWEHZO.js → chunk-EQYB4Q77.js} +2 -2
- package/dist/chunk-LYREUZH6.js +156 -0
- package/dist/chunk-PW247TG2.js +81 -0
- package/dist/chunk-QY66ME5B.js +207 -0
- package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
- package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
- package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
- package/dist/components/alert-dialog.js +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/calendar.js +1 -1
- package/dist/components/card.d.ts +2 -2
- package/dist/components/carousel.js +1 -1
- package/dist/components/coach-marks.d.ts +63 -0
- package/dist/components/coach-marks.js +162 -0
- package/dist/components/combobox.js +1 -1
- package/dist/components/command.d.ts +15 -15
- package/dist/components/concept-compare-rail.js +3 -3
- package/dist/components/concept-compare.js +3 -3
- package/dist/components/concept-convergence.js +2 -2
- package/dist/components/doorway-tiles.d.ts +11 -0
- package/dist/components/doorway-tiles.js +5 -3
- package/dist/components/empty-state.js +1 -1
- package/dist/components/event-card.d.ts +22 -4
- package/dist/components/event-card.js +95 -50
- package/dist/components/event-day-header.d.ts +5 -3
- package/dist/components/event-day-header.js +12 -4
- package/dist/components/event-detail-panel.js +2 -2
- package/dist/components/event-door.d.ts +41 -0
- package/dist/components/event-door.js +65 -0
- package/dist/components/event-hub-hero.d.ts +10 -2
- package/dist/components/event-hub-hero.js +11 -5
- 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 +1 -1
- package/dist/components/event-tonight-strip.d.ts +50 -0
- package/dist/components/event-tonight-strip.js +67 -0
- package/dist/components/facet-group.d.ts +8 -1
- package/dist/components/facet-group.js +3 -0
- package/dist/components/filter-category-row.js +1 -1
- package/dist/components/hue-chip.js +1 -1
- package/dist/components/item.d.ts +2 -2
- package/dist/components/item.js +4 -1
- package/dist/components/kitchen-dossier.js +1 -1
- package/dist/components/link-button.js +1 -1
- package/dist/components/menu-family-block.d.ts +8 -1
- package/dist/components/menu-family-block.js +101 -68
- package/dist/components/menu-family-card.js +2 -2
- package/dist/components/menu-filter-chips.d.ts +12 -3
- package/dist/components/menu-filter-chips.js +22 -4
- package/dist/components/menu-hub-hero.d.ts +7 -1
- package/dist/components/menu-hub-hero.js +2 -1
- package/dist/components/menu-index-list.js +1 -1
- package/dist/components/menu-item.js +3 -3
- package/dist/components/menu-kitchen-list.d.ts +14 -2
- package/dist/components/menu-kitchen-list.js +1 -1
- package/dist/components/multi-select-combobox.js +2 -2
- package/dist/components/pagination.js +1 -1
- package/dist/components/photo-ground.d.ts +7 -1
- package/dist/components/photo-ground.js +1 -1
- package/dist/components/place-card.js +2 -2
- package/dist/components/place-details-mobile.js +1 -1
- package/dist/components/poster-stack.d.ts +36 -3
- package/dist/components/poster-stack.js +49 -18
- package/dist/components/searchable-select.js +1 -1
- package/dist/components/section-header.d.ts +7 -1
- package/dist/components/section-header.js +11 -7
- package/dist/components/signature-chip.js +2 -2
- package/dist/components/sliding-panels.js +1 -1
- package/dist/components/snap-rail.d.ts +2 -2
- package/dist/components/snap-rail.js +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +3 -3
- package/dist/components/timeline-day.d.ts +54 -0
- package/dist/components/timeline-day.js +65 -0
- package/dist/components/timeline-hour-puck.d.ts +47 -0
- package/dist/components/timeline-hour-puck.js +3 -0
- package/dist/components/timeline-hour.d.ts +96 -0
- package/dist/components/{timeline-band.js → timeline-hour.js} +2 -1
- package/dist/components/timeline-playhead.js +1 -3
- package/dist/components/toggle-group.js +9 -4
- package/dist/components/toggle.d.ts +1 -1
- package/dist/components/toggle.js +1 -1
- package/dist/components/top-ranking-entry.js +3 -3
- package/package.json +1 -1
- package/src/components/badge.stories.tsx +31 -1
- package/src/components/badge.tsx +2 -0
- package/src/components/button.stories.tsx +5 -4
- package/src/components/button.test.tsx +11 -1
- package/src/components/button.tsx +16 -5
- package/src/components/coach-marks.stories.tsx +79 -0
- package/src/components/coach-marks.test.tsx +140 -0
- package/src/components/coach-marks.tsx +252 -0
- package/src/components/doorway-tiles.stories.tsx +34 -0
- package/src/components/doorway-tiles.test.tsx +27 -0
- package/src/components/doorway-tiles.tsx +30 -2
- package/src/components/event-card.tsx +70 -13
- package/src/components/event-day-header.tsx +19 -7
- package/src/components/event-door.stories.tsx +88 -0
- package/src/components/event-door.tsx +97 -0
- package/src/components/event-hub-hero.tsx +21 -3
- package/src/components/event-tonight-strip.stories.tsx +78 -0
- package/src/components/event-tonight-strip.test.tsx +59 -0
- package/src/components/event-tonight-strip.tsx +134 -0
- package/src/components/facet-group.tsx +9 -0
- package/src/components/hue-chip.test.tsx +6 -3
- package/src/components/hue-chip.tsx +5 -2
- package/src/components/item.tsx +4 -1
- package/src/components/menu-family-block.stories.tsx +12 -0
- package/src/components/menu-family-block.tsx +53 -20
- package/src/components/menu-filter-chips.tsx +32 -4
- package/src/components/menu-hub-hero.test.tsx +13 -0
- package/src/components/menu-hub-hero.tsx +9 -2
- package/src/components/menu-kitchen-list.tsx +45 -6
- package/src/components/photo-ground.tsx +7 -0
- package/src/components/poster-stack.stories.tsx +43 -1
- package/src/components/poster-stack.tsx +98 -19
- package/src/components/section-header.test.tsx +15 -0
- package/src/components/section-header.tsx +26 -12
- package/src/components/snap-rail.tsx +6 -1
- package/src/components/timeline-day.stories.tsx +201 -0
- package/src/components/timeline-day.test.tsx +78 -0
- package/src/components/timeline-day.tsx +114 -0
- package/src/components/timeline-hour-puck.tsx +139 -0
- package/src/components/timeline-hour.tsx +266 -0
- package/src/components/timeline-playhead.tsx +1 -1
- package/src/components/toggle-group.stories.tsx +37 -1
- package/src/components/toggle-group.tsx +11 -6
- package/src/components/toggle.tsx +8 -0
- package/dist/chunk-NSNEANOQ.js +0 -104
- package/dist/chunk-SXQZ7DLH.js +0 -167
- package/dist/components/hour-band.d.ts +0 -66
- package/dist/components/hour-band.js +0 -81
- package/dist/components/hour-group.d.ts +0 -67
- package/dist/components/hour-group.js +0 -59
- package/dist/components/timeline-band.d.ts +0 -64
- package/src/components/hour-band.stories.tsx +0 -182
- package/src/components/hour-band.tsx +0 -146
- package/src/components/hour-group.stories.tsx +0 -100
- package/src/components/hour-group.tsx +0 -124
- package/src/components/timeline-band.stories.tsx +0 -206
- package/src/components/timeline-band.tsx +0 -247
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import { ArrowRight, Lightbulb } from "lucide-react";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { Button } from "./button";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CoachMarks — a guided tour drawn OVER the real page.
|
|
11
|
+
*
|
|
12
|
+
* Four things, one at a time: the page dims, the current stop's target is cut
|
|
13
|
+
* out of the dimming in a spotlight, its step number sits on the spotlight's
|
|
14
|
+
* corner, and one callout beside the spotlight says what the target is. Only
|
|
15
|
+
* the current stop is marked — a map of every stop's number over the page
|
|
16
|
+
* competes with the page's own numerals and with the callout's "Step N of
|
|
17
|
+
* M"; the way through is Next, Back and the arrow keys. The reader learns
|
|
18
|
+
* the surface by looking at the surface, not at a picture of it; nothing on
|
|
19
|
+
* the page moves or re-renders to be taught.
|
|
20
|
+
*
|
|
21
|
+
* Targets are found by `data-coach-mark="<stop id>"` on the page's own
|
|
22
|
+
* elements, so the page keeps its markup and the tour attaches from outside.
|
|
23
|
+
* The tour is built from the stops whose target is ON THE PAGE when it opens
|
|
24
|
+
* (or when the itinerary changes): a stop with nothing to point at is not
|
|
25
|
+
* counted, so "Step 3 of 5" is always a step the reader will reach. Where
|
|
26
|
+
* the same id is on two elements — a desktop and a phone rendering of one
|
|
27
|
+
* control — the visible one is taken.
|
|
28
|
+
*
|
|
29
|
+
* The consumer owns the words (`labels`, `stops`) and the open state; the
|
|
30
|
+
* tour owns its position, the geometry (8px breathing room, the callout's
|
|
31
|
+
* side, re-measure on scroll and resize) and the keys (Escape closes, the
|
|
32
|
+
* arrows step). `onProgress` reports the current stop for a toolbar label.
|
|
33
|
+
*
|
|
34
|
+
* Rendered into `document.body`: a page wrapper with an opacity or transform
|
|
35
|
+
* transition is a stacking context, and the tour must sit above everything
|
|
36
|
+
* the page and its chrome draw, whatever wraps the consumer.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export type CoachMarkStop = {
|
|
40
|
+
/** Matches the target's `data-coach-mark` attribute. */
|
|
41
|
+
id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
body: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type CoachMarksLabels = {
|
|
47
|
+
/** "Step 2 of 4". */
|
|
48
|
+
progress: (step: number, total: number) => string;
|
|
49
|
+
back: string;
|
|
50
|
+
next: string;
|
|
51
|
+
/** The last stop's forward action, e.g. "Done". */
|
|
52
|
+
done: string;
|
|
53
|
+
skip: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type CoachMarksProps = {
|
|
57
|
+
open: boolean;
|
|
58
|
+
stops: CoachMarkStop[];
|
|
59
|
+
/** Skip, Done, Escape and a tap on the dimmed page all end here. */
|
|
60
|
+
onClose: () => void;
|
|
61
|
+
/**
|
|
62
|
+
* 1-based position, the count of stops the page actually has, and the
|
|
63
|
+
* stop being shown — its id is what a consumer remembers, since the
|
|
64
|
+
* position shifts when a stop's target is missing.
|
|
65
|
+
*/
|
|
66
|
+
onProgress?: (step: number, total: number, stop: CoachMarkStop) => void;
|
|
67
|
+
labels: CoachMarksLabels;
|
|
68
|
+
className?: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
type Rect = { top: number; left: number; width: number; height: number };
|
|
72
|
+
|
|
73
|
+
const PAD = 8;
|
|
74
|
+
const CALLOUT_WIDTH = 320;
|
|
75
|
+
const CALLOUT_GAP = 14;
|
|
76
|
+
/** The room a side needs before the callout goes there. */
|
|
77
|
+
const CALLOUT_ROOM = 280;
|
|
78
|
+
const EDGE = 12;
|
|
79
|
+
|
|
80
|
+
/** The step number, an inverted disc on the spotlight's corner: dark on the gold ring. */
|
|
81
|
+
const PIN =
|
|
82
|
+
"pointer-events-none absolute z-[1] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-[oklch(0.26_0.03_60)] text-[13px] font-extrabold text-[oklch(0.82_0.15_85)] ring-2 ring-[oklch(0.82_0.15_85)] shadow-[0_2px_8px_oklch(0.16_0.012_55_/_0.45)] transition-[top,left] duration-300 motion-reduce:transition-none";
|
|
83
|
+
|
|
84
|
+
/** The visible element carrying the id — a `display: none` twin has no boxes. */
|
|
85
|
+
function findTarget(id: string): HTMLElement | null {
|
|
86
|
+
if (typeof document === "undefined") return null;
|
|
87
|
+
const candidates = document.querySelectorAll<HTMLElement>(`[data-coach-mark="${id}"]`);
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (candidate.getClientRects().length > 0) return candidate;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function measure(element: HTMLElement): Rect {
|
|
95
|
+
const box = element.getBoundingClientRect();
|
|
96
|
+
return { top: box.top, left: box.left, width: box.width, height: box.height };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function CoachMarks({ open, stops, onClose, onProgress, labels, className }: CoachMarksProps) {
|
|
100
|
+
// The stops the page can show, fixed when the tour opens or the itinerary
|
|
101
|
+
// changes — by its ids, so a consumer rebuilding the array each render does
|
|
102
|
+
// not send the tour back to the first stop.
|
|
103
|
+
const [available, setAvailable] = useState<CoachMarkStop[]>([]);
|
|
104
|
+
const [index, setIndex] = useState(0);
|
|
105
|
+
const [rect, setRect] = useState<Rect | null>(null);
|
|
106
|
+
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
|
107
|
+
const itinerary = stops.map((stop) => stop.id).join("|");
|
|
108
|
+
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
if (!open) return;
|
|
111
|
+
const present = stops.filter((stop) => findTarget(stop.id));
|
|
112
|
+
setAvailable(present);
|
|
113
|
+
setIndex(0);
|
|
114
|
+
if (present.length === 0) onClose();
|
|
115
|
+
// `stops` is read whenever its ids change; the words alone do not restart the tour.
|
|
116
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
117
|
+
}, [open, itinerary]);
|
|
118
|
+
|
|
119
|
+
const current = available[index];
|
|
120
|
+
const currentId = current?.id;
|
|
121
|
+
|
|
122
|
+
const remeasure = useCallback(() => {
|
|
123
|
+
const element = currentId ? findTarget(currentId) : null;
|
|
124
|
+
setRect(element ? measure(element) : null);
|
|
125
|
+
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
|
126
|
+
}, [currentId]);
|
|
127
|
+
|
|
128
|
+
// Bring the current target into view, then measure it — again once the
|
|
129
|
+
// smooth scroll has settled.
|
|
130
|
+
useLayoutEffect(() => {
|
|
131
|
+
if (!open || !current) return;
|
|
132
|
+
const element = findTarget(current.id);
|
|
133
|
+
if (!element) {
|
|
134
|
+
// The page changed under the tour: move on, or end when nothing is left.
|
|
135
|
+
const forward = available.findIndex((stop, at) => at > index && findTarget(stop.id));
|
|
136
|
+
if (forward !== -1) setIndex(forward);
|
|
137
|
+
else onClose();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
element.scrollIntoView({ block: "center", behavior: "smooth", inline: "nearest" });
|
|
141
|
+
remeasure();
|
|
142
|
+
const settle = setTimeout(remeasure, 400);
|
|
143
|
+
return () => clearTimeout(settle);
|
|
144
|
+
}, [open, current, index, available, remeasure, onClose]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!open || !current) return;
|
|
148
|
+
onProgress?.(index + 1, available.length, current);
|
|
149
|
+
}, [open, current, index, available.length, onProgress]);
|
|
150
|
+
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!open) return;
|
|
153
|
+
window.addEventListener("resize", remeasure);
|
|
154
|
+
window.addEventListener("scroll", remeasure, true);
|
|
155
|
+
const onKey = (event: KeyboardEvent) => {
|
|
156
|
+
if (event.key === "Escape") onClose();
|
|
157
|
+
if (event.key === "ArrowRight") setIndex((at) => Math.min(at + 1, available.length - 1));
|
|
158
|
+
if (event.key === "ArrowLeft") setIndex((at) => Math.max(at - 1, 0));
|
|
159
|
+
};
|
|
160
|
+
window.addEventListener("keydown", onKey);
|
|
161
|
+
return () => {
|
|
162
|
+
window.removeEventListener("resize", remeasure);
|
|
163
|
+
window.removeEventListener("scroll", remeasure, true);
|
|
164
|
+
window.removeEventListener("keydown", onKey);
|
|
165
|
+
};
|
|
166
|
+
}, [open, remeasure, onClose, available.length]);
|
|
167
|
+
|
|
168
|
+
if (!open || !current || !rect) return null;
|
|
169
|
+
|
|
170
|
+
const last = index === available.length - 1;
|
|
171
|
+
const spot = {
|
|
172
|
+
top: rect.top - PAD,
|
|
173
|
+
left: rect.left - PAD,
|
|
174
|
+
width: rect.width + PAD * 2,
|
|
175
|
+
height: rect.height + PAD * 2,
|
|
176
|
+
};
|
|
177
|
+
// Below the target when there is room for the callout, else above it; a
|
|
178
|
+
// target taller than the viewport leaves room on neither side, and the
|
|
179
|
+
// callout then sits at the bottom edge, over the target's lower part.
|
|
180
|
+
const roomBelow = viewport.height - (spot.top + spot.height);
|
|
181
|
+
const calloutLeft = Math.min(
|
|
182
|
+
Math.max(spot.left, EDGE),
|
|
183
|
+
Math.max(EDGE, viewport.width - CALLOUT_WIDTH - EDGE)
|
|
184
|
+
);
|
|
185
|
+
const calloutPlace =
|
|
186
|
+
roomBelow > CALLOUT_ROOM
|
|
187
|
+
? { top: spot.top + spot.height + CALLOUT_GAP, left: calloutLeft }
|
|
188
|
+
: spot.top > CALLOUT_ROOM
|
|
189
|
+
? { bottom: viewport.height - spot.top + CALLOUT_GAP, left: calloutLeft }
|
|
190
|
+
: { bottom: EDGE, left: calloutLeft };
|
|
191
|
+
|
|
192
|
+
return createPortal(
|
|
193
|
+
<div
|
|
194
|
+
className={cn("fixed inset-0 z-[80]", className)}
|
|
195
|
+
role="dialog"
|
|
196
|
+
aria-modal
|
|
197
|
+
aria-label={current.title}
|
|
198
|
+
>
|
|
199
|
+
{/* The dimming is the spotlight's shadow: one element, cut out where
|
|
200
|
+
the target is, so the target stays fully visible and the rest of the
|
|
201
|
+
page falls back. A tap on the dim closes the tour. */}
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
aria-label={labels.skip}
|
|
205
|
+
onClick={onClose}
|
|
206
|
+
className="absolute inset-0 cursor-default"
|
|
207
|
+
/>
|
|
208
|
+
<div
|
|
209
|
+
aria-hidden
|
|
210
|
+
className="pointer-events-none absolute rounded-[18px] shadow-[0_0_0_9999px_oklch(0.16_0.012_55_/_0.55)] ring-2 ring-[oklch(0.82_0.15_85)] transition-[top,left,width,height] duration-300 motion-reduce:transition-none"
|
|
211
|
+
style={spot}
|
|
212
|
+
/>
|
|
213
|
+
|
|
214
|
+
{/* The step number on the spotlight's corner; the callout says the rest. */}
|
|
215
|
+
<span aria-hidden className={PIN} style={{ top: rect.top - 2, left: rect.left - 2 }}>
|
|
216
|
+
{index + 1}
|
|
217
|
+
</span>
|
|
218
|
+
|
|
219
|
+
{/* The callout. */}
|
|
220
|
+
<div
|
|
221
|
+
className="absolute z-[2] w-[min(320px,calc(100vw-24px))] rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-xl"
|
|
222
|
+
style={calloutPlace}
|
|
223
|
+
>
|
|
224
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-[oklch(0.82_0.15_85)] px-2.5 py-1 text-[10.5px] font-extrabold uppercase tracking-[0.08em] text-[oklch(0.26_0.03_60)]">
|
|
225
|
+
<Lightbulb className="h-3 w-3" aria-hidden />
|
|
226
|
+
{labels.progress(index + 1, available.length)}
|
|
227
|
+
</span>
|
|
228
|
+
<p className="mt-3 text-[15px] font-bold leading-snug">{current.title}</p>
|
|
229
|
+
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{current.body}</p>
|
|
230
|
+
<div className="mt-4 flex items-center gap-2">
|
|
231
|
+
{index > 0 && (
|
|
232
|
+
<Button type="button" variant="ghost" size="sm" onClick={() => setIndex(index - 1)}>
|
|
233
|
+
{labels.back}
|
|
234
|
+
</Button>
|
|
235
|
+
)}
|
|
236
|
+
<Button
|
|
237
|
+
type="button"
|
|
238
|
+
size="sm"
|
|
239
|
+
onClick={() => (last ? onClose() : setIndex(index + 1))}
|
|
240
|
+
>
|
|
241
|
+
{last ? labels.done : labels.next}
|
|
242
|
+
{!last && <ArrowRight aria-hidden />}
|
|
243
|
+
</Button>
|
|
244
|
+
<Button type="button" variant="link" size="sm" className="ml-auto" onClick={onClose}>
|
|
245
|
+
{labels.skip}
|
|
246
|
+
</Button>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
</div>,
|
|
250
|
+
document.body
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { TextSearch } from "lucide-react";
|
|
2
3
|
import { DoorwayTiles } from "./doorway-tiles";
|
|
3
4
|
import {
|
|
4
5
|
mockFamilyTiles,
|
|
@@ -58,6 +59,25 @@ export const PolaroidPartial: Story = {
|
|
|
58
59
|
args: { treatment: "polaroid", tiles: mockFamilyTiles.slice(0, 5) },
|
|
59
60
|
};
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The closing tile as a door to an ACTION — the menu's search page. No
|
|
64
|
+
* photograph, no initial: the print holds a glyph on the photo ground, and
|
|
65
|
+
* the tile keeps its siblings' silhouette.
|
|
66
|
+
*/
|
|
67
|
+
export const PolaroidWithSearchDoor: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
treatment: "polaroid",
|
|
70
|
+
tiles: mockFamilyTiles,
|
|
71
|
+
all: {
|
|
72
|
+
...mockFamilyTilesAll,
|
|
73
|
+
label: "Search the menu",
|
|
74
|
+
countLabel: "535 dishes · 210 drinks",
|
|
75
|
+
photoUrl: undefined,
|
|
76
|
+
icon: <TextSearch strokeWidth={1.75} aria-hidden />,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
61
81
|
/** The occasions: the mood's hue multiplied over a room. */
|
|
62
82
|
export const Ground: Story = {
|
|
63
83
|
args: { treatment: "ground", tiles: mockOccasionTiles, all: mockOccasionTilesAll },
|
|
@@ -81,3 +101,17 @@ export const GroundWithoutPhotos: Story = {
|
|
|
81
101
|
tiles: mockOccasionTiles.map(({ photoUrl: _photoUrl, ...tile }) => tile),
|
|
82
102
|
},
|
|
83
103
|
};
|
|
104
|
+
|
|
105
|
+
/** The closing tile as a door to an action on the ground treatment: the glyph where the initial was. */
|
|
106
|
+
export const GroundWithActionDoor: Story = {
|
|
107
|
+
args: {
|
|
108
|
+
treatment: "ground",
|
|
109
|
+
tiles: mockOccasionTiles,
|
|
110
|
+
all: {
|
|
111
|
+
...mockOccasionTilesAll,
|
|
112
|
+
label: "Search the menu",
|
|
113
|
+
photoUrl: undefined,
|
|
114
|
+
icon: <TextSearch strokeWidth={1.75} aria-hidden />,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -30,6 +30,33 @@ describe("DoorwayTiles", () => {
|
|
|
30
30
|
expect(screen.getByText(withoutPhoto.label.charAt(0))).toBeInTheDocument();
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
// A door about an action carries a glyph in the print: no photograph, and
|
|
34
|
+
// not the initial either — that would read as a family starting with "S".
|
|
35
|
+
it("draws the glyph, not the initial or the photo, where a tile has an icon", () => {
|
|
36
|
+
const search = {
|
|
37
|
+
...mockFamilyTilesAll,
|
|
38
|
+
label: "Search the menu",
|
|
39
|
+
icon: <svg data-testid="glyph" />,
|
|
40
|
+
};
|
|
41
|
+
render(<DoorwayTiles treatment="polaroid" tiles={mockFamilyTiles.slice(0, 1)} all={search} />);
|
|
42
|
+
expect(screen.getByTestId("glyph")).toBeInTheDocument();
|
|
43
|
+
expect(screen.queryByText("S")).not.toBeInTheDocument();
|
|
44
|
+
expect(document.querySelectorAll("img")).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("ground: the glyph takes the initial's place and no photograph is drawn for it", () => {
|
|
48
|
+
const door = {
|
|
49
|
+
...mockOccasionTilesAll,
|
|
50
|
+
label: "Search the menu",
|
|
51
|
+
icon: <svg data-testid="glyph" />,
|
|
52
|
+
};
|
|
53
|
+
render(<DoorwayTiles treatment="ground" tiles={mockOccasionTiles.slice(0, 1)} all={door} />);
|
|
54
|
+
expect(screen.getByTestId("glyph")).toBeInTheDocument();
|
|
55
|
+
expect(screen.queryByText("S")).not.toBeInTheDocument();
|
|
56
|
+
const withPhoto = mockOccasionTiles.slice(0, 1).filter((t) => t.photoUrl).length;
|
|
57
|
+
expect(document.querySelectorAll("img")).toHaveLength(withPhoto);
|
|
58
|
+
});
|
|
59
|
+
|
|
33
60
|
it("omits the index tile when no index is given", () => {
|
|
34
61
|
render(<DoorwayTiles treatment="polaroid" tiles={mockFamilyTiles.slice(0, 3)} />);
|
|
35
62
|
expect(screen.getAllByRole("link")).toHaveLength(3);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cn } from "../lib/utils";
|
|
2
2
|
import type { MenuLinkComponent, MenuLinkProps } from "./menu-types";
|
|
3
|
-
import { PhotoGround } from "./photo-ground";
|
|
3
|
+
import { PHOTO_GLYPH_SHADOW, PHOTO_GROUND, PhotoGround } from "./photo-ground";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* DoorwayTiles — a grid of doors: every class the reader browses by, as
|
|
@@ -89,6 +89,17 @@ export type DoorwayTile = {
|
|
|
89
89
|
gradient?: string;
|
|
90
90
|
/** Pre-resolved photograph of the CLASS, from the consumer's own origin. */
|
|
91
91
|
photoUrl?: string;
|
|
92
|
+
/**
|
|
93
|
+
* A glyph in the photograph's place — for a tile about an ACTION, not a
|
|
94
|
+
* thing. A search door has no class to photograph, and any subject put on
|
|
95
|
+
* it (a table, a lens, a book) reads as one more family; the initial the
|
|
96
|
+
* fallback draws is a family's initial too. So the print holds the glyph
|
|
97
|
+
* instead, on the photo ground with the photo fallback's drop shadow, and
|
|
98
|
+
* the tile keeps its siblings' silhouette. Wins over `photoUrl`.
|
|
99
|
+
*
|
|
100
|
+
* A node rather than a name: the app owns its iconography.
|
|
101
|
+
*/
|
|
102
|
+
icon?: React.ReactNode;
|
|
92
103
|
};
|
|
93
104
|
|
|
94
105
|
export type DoorwayTilesTreatment = "polaroid" | "ground";
|
|
@@ -144,6 +155,17 @@ const GROUND_LIFT = "absolute inset-0 z-0 opacity-[0.22]";
|
|
|
144
155
|
const GROUND_GLYPH =
|
|
145
156
|
"absolute inset-0 z-0 flex items-center justify-center font-serif text-[64px] font-semibold leading-none text-white/15";
|
|
146
157
|
|
|
158
|
+
/**
|
|
159
|
+
* The action glyph. In the polaroid it fills the print at 44% on the photo
|
|
160
|
+
* ground, white with the fallback family's drop shadow; on the ground
|
|
161
|
+
* treatment it takes the initial's place at the initial's weight.
|
|
162
|
+
*/
|
|
163
|
+
const PRINT_ICON =
|
|
164
|
+
`absolute inset-0 flex items-center justify-center ${PHOTO_GROUND} text-white/90 ` +
|
|
165
|
+
`[&_svg]:h-[44%] [&_svg]:w-[44%] [&_svg]:${PHOTO_GLYPH_SHADOW}`;
|
|
166
|
+
const GROUND_ICON =
|
|
167
|
+
"absolute inset-0 z-0 flex items-center justify-center text-white/15 [&_svg]:h-[40%] [&_svg]:w-[40%]";
|
|
168
|
+
|
|
147
169
|
export function DoorwayTiles({
|
|
148
170
|
tiles,
|
|
149
171
|
all,
|
|
@@ -167,7 +189,9 @@ export function DoorwayTiles({
|
|
|
167
189
|
>
|
|
168
190
|
{isPolaroid ? (
|
|
169
191
|
<span aria-hidden className={POLAROID}>
|
|
170
|
-
{tile.
|
|
192
|
+
{tile.icon ? (
|
|
193
|
+
<span className={PRINT_ICON}>{tile.icon}</span>
|
|
194
|
+
) : tile.photoUrl ? (
|
|
171
195
|
/* Not lazy: the grid sits directly under the hero, and these
|
|
172
196
|
photographs are the first thing the reader looks at. */
|
|
173
197
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -186,6 +210,10 @@ export function DoorwayTiles({
|
|
|
186
210
|
/>
|
|
187
211
|
)}
|
|
188
212
|
</span>
|
|
213
|
+
) : tile.icon ? (
|
|
214
|
+
<span aria-hidden className={GROUND_ICON}>
|
|
215
|
+
{tile.icon}
|
|
216
|
+
</span>
|
|
189
217
|
) : tile.photoUrl ? (
|
|
190
218
|
<>
|
|
191
219
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
@@ -55,8 +55,22 @@ export type EventCardProps = {
|
|
|
55
55
|
* `Coverflow`) — it crops less than `3/4` and is not the same as it.
|
|
56
56
|
*/
|
|
57
57
|
posterAspect?: "4/3" | "3/4" | "4/5";
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Larger title — the featured/highlight slot. Never a clamp: the title
|
|
60
|
+
* wraps in full either way. `"lg"` applies it from the `lg` breakpoint
|
|
61
|
+
* only: the timeline's day hero is an ordinary card in the phone column and
|
|
62
|
+
* the lane's feature poster on desktop, one node.
|
|
63
|
+
*/
|
|
64
|
+
emphasis?: boolean | "lg";
|
|
65
|
+
/**
|
|
66
|
+
* `card` (default) at every width. `row-lg`: the card below `lg`, and from
|
|
67
|
+
* `lg` the compact row `EventCompactRow` draws — a 64px portrait poster
|
|
68
|
+
* beside the text, overlay pills off, the fee as a chip on the tags row.
|
|
69
|
+
* The timeline demotes an hour's later events to rows on desktop only, and
|
|
70
|
+
* a server render cannot swap components per viewport; this is the one
|
|
71
|
+
* node that paints both.
|
|
72
|
+
*/
|
|
73
|
+
density?: "card" | "row-lg";
|
|
60
74
|
/** Top-left overlay pill (the design's "Busy" hype marker). Include its icon. */
|
|
61
75
|
badge?: React.ReactNode;
|
|
62
76
|
/** Bottom-left overlay pill naming the category. Include its icon. */
|
|
@@ -77,8 +91,12 @@ export type EventCardProps = {
|
|
|
77
91
|
* locale, and "is this live" depends on the app's clock, not the DS's.
|
|
78
92
|
*/
|
|
79
93
|
liveStatus?: string;
|
|
80
|
-
/**
|
|
81
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Hide the day/time line — the timeline supplies its own hour heading.
|
|
96
|
+
* `"below-lg"` hides it on the phone only: the desktop lane's hero sits
|
|
97
|
+
* beside whatever hour is passing and has to carry its own start time.
|
|
98
|
+
*/
|
|
99
|
+
hideWhen?: boolean | "below-lg";
|
|
82
100
|
/**
|
|
83
101
|
* Already over — dims the poster, mutes the title and shows the `endedLabel`
|
|
84
102
|
* chip. Never row-level opacity, which composites the text below AA. Same
|
|
@@ -111,6 +129,7 @@ export const EventCard = memo(function EventCard({
|
|
|
111
129
|
formatters,
|
|
112
130
|
posterAspect = "4/3",
|
|
113
131
|
emphasis,
|
|
132
|
+
density = "card",
|
|
114
133
|
badge,
|
|
115
134
|
tagLabel,
|
|
116
135
|
tags,
|
|
@@ -129,7 +148,10 @@ export const EventCard = memo(function EventCard({
|
|
|
129
148
|
}: EventCardProps) {
|
|
130
149
|
const [imgError, setImgError] = useState(false);
|
|
131
150
|
const showEnded = isEnded && !!endedLabel;
|
|
132
|
-
|
|
151
|
+
// `"below-lg"` still renders the line (hidden by CSS on the phone), so the
|
|
152
|
+
// time is formatted whenever the line can show anywhere.
|
|
153
|
+
const time = hideWhen === true ? "" : formatters.time(event.starts_at);
|
|
154
|
+
const isRowLg = density === "row-lg";
|
|
133
155
|
|
|
134
156
|
const fee = resolveEventEntryFee(event);
|
|
135
157
|
const showPoster = !!event.poster_url && !imgError;
|
|
@@ -146,6 +168,8 @@ export const EventCard = memo(function EventCard({
|
|
|
146
168
|
: posterAspect === "4/5"
|
|
147
169
|
? "aspect-[4/5]"
|
|
148
170
|
: "aspect-[4/3]",
|
|
171
|
+
// The compact row's poster from `lg`: 64px, portrait, tighter radius.
|
|
172
|
+
isRowLg && "lg:aspect-[4/5] lg:w-16 lg:shrink-0 lg:rounded-[10px]",
|
|
149
173
|
)}
|
|
150
174
|
>
|
|
151
175
|
{showPoster ? (
|
|
@@ -164,8 +188,14 @@ export const EventCard = memo(function EventCard({
|
|
|
164
188
|
)}
|
|
165
189
|
|
|
166
190
|
{/* Top overlay row — hype badge left, entry fee right. The empty span
|
|
167
|
-
keeps the fee pill right-aligned when there is no badge.
|
|
168
|
-
|
|
191
|
+
keeps the fee pill right-aligned when there is no badge. Off on a
|
|
192
|
+
64px row poster, where the fee moves to the tags row. */}
|
|
193
|
+
<div
|
|
194
|
+
className={cn(
|
|
195
|
+
"absolute inset-x-2 top-2 flex items-start justify-between gap-2",
|
|
196
|
+
isRowLg && "lg:hidden",
|
|
197
|
+
)}
|
|
198
|
+
>
|
|
169
199
|
{badge ? (
|
|
170
200
|
<span className={cn(PILL_BASE, "bg-black/55 text-white")}>
|
|
171
201
|
{badge}
|
|
@@ -187,7 +217,12 @@ export const EventCard = memo(function EventCard({
|
|
|
187
217
|
</div>
|
|
188
218
|
|
|
189
219
|
{tagLabel && (
|
|
190
|
-
<div
|
|
220
|
+
<div
|
|
221
|
+
className={cn(
|
|
222
|
+
"absolute inset-x-2 bottom-2 flex gap-1",
|
|
223
|
+
isRowLg && "lg:hidden",
|
|
224
|
+
)}
|
|
225
|
+
>
|
|
191
226
|
<span className={cn(PILL_BASE, "bg-black/55 text-white")}>
|
|
192
227
|
{tagLabel}
|
|
193
228
|
</span>
|
|
@@ -195,6 +230,8 @@ export const EventCard = memo(function EventCard({
|
|
|
195
230
|
)}
|
|
196
231
|
</div>
|
|
197
232
|
|
|
233
|
+
{/* The text column — a wrapper so `row-lg` can lay it beside the poster. */}
|
|
234
|
+
<div className={cn("flex min-w-0 flex-col", isRowLg && "lg:flex-1")}>
|
|
198
235
|
{/* NEVER CLAMPED, in any variant. The title is the one thing a card
|
|
199
236
|
exists to say, and "Movie Night: Futni m…" says none of it — the
|
|
200
237
|
truncated form was the reported defect. It wraps to as many lines as
|
|
@@ -205,9 +242,11 @@ export const EventCard = memo(function EventCard({
|
|
|
205
242
|
<h3
|
|
206
243
|
className={cn(
|
|
207
244
|
"mt-2.5 leading-[1.35]",
|
|
208
|
-
emphasis
|
|
245
|
+
emphasis === true
|
|
209
246
|
? "text-lg font-bold tracking-[-0.02em]"
|
|
210
247
|
: "text-sm font-semibold",
|
|
248
|
+
emphasis === "lg" && "lg:text-lg lg:font-bold lg:tracking-[-0.02em]",
|
|
249
|
+
isRowLg && "lg:mt-0 lg:text-[13.5px]",
|
|
211
250
|
isEnded && "text-muted-foreground",
|
|
212
251
|
)}
|
|
213
252
|
>
|
|
@@ -219,8 +258,13 @@ export const EventCard = memo(function EventCard({
|
|
|
219
258
|
<span className="truncate">{venue}</span>
|
|
220
259
|
</div>
|
|
221
260
|
|
|
222
|
-
{
|
|
223
|
-
<div
|
|
261
|
+
{hideWhen !== true && (
|
|
262
|
+
<div
|
|
263
|
+
className={cn(
|
|
264
|
+
"mt-[3px] flex items-center gap-1.5 text-xs text-muted-foreground",
|
|
265
|
+
hideWhen === "below-lg" && "hidden lg:flex",
|
|
266
|
+
)}
|
|
267
|
+
>
|
|
224
268
|
<span>{dayLabel ?? formatters.day(event.starts_at)}</span>
|
|
225
269
|
{/* An empty time is "hour unknown" (see `EventFormatters.time`): the
|
|
226
270
|
day stands alone, no dangling separator. Consumers used to have
|
|
@@ -244,13 +288,21 @@ export const EventCard = memo(function EventCard({
|
|
|
244
288
|
</div>
|
|
245
289
|
)}
|
|
246
290
|
|
|
247
|
-
{
|
|
291
|
+
{/* The row look carries the fee as a chip here, where the compact row
|
|
292
|
+
puts it — the poster is too small for an overlay pill. */}
|
|
293
|
+
{((tags && tags.length > 0) || (isRowLg && fee.kind === "free")) && (
|
|
248
294
|
<TagGroup className="mt-1.5">
|
|
249
|
-
{tags
|
|
295
|
+
{tags?.map((tag) => (
|
|
250
296
|
<Tag key={tag} size="sm">
|
|
251
297
|
{tag}
|
|
252
298
|
</Tag>
|
|
253
299
|
))}
|
|
300
|
+
{isRowLg && fee.kind === "free" && (
|
|
301
|
+
<Tag size="sm" variant="success" className="hidden font-bold lg:inline-flex">
|
|
302
|
+
<Gift className="h-3 w-3" aria-hidden />
|
|
303
|
+
{labels.free}
|
|
304
|
+
</Tag>
|
|
305
|
+
)}
|
|
254
306
|
</TagGroup>
|
|
255
307
|
)}
|
|
256
308
|
|
|
@@ -268,13 +320,18 @@ export const EventCard = memo(function EventCard({
|
|
|
268
320
|
</Tag>
|
|
269
321
|
</div>
|
|
270
322
|
)}
|
|
323
|
+
</div>
|
|
271
324
|
</>
|
|
272
325
|
);
|
|
273
326
|
|
|
274
327
|
const rootClasses = cn(
|
|
275
328
|
"flex min-w-0 flex-col text-left",
|
|
329
|
+
// The compact row from `lg`: poster beside text, the row's own padding
|
|
330
|
+
// and negative gutter so the hover wash bleeds like `EventCompactRow`'s.
|
|
331
|
+
isRowLg && "lg:-mx-2.5 lg:flex-row lg:items-start lg:gap-[11px] lg:rounded-lg lg:px-2.5 lg:py-2",
|
|
276
332
|
(href || onClick) &&
|
|
277
333
|
"cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl",
|
|
334
|
+
(href || onClick) && isRowLg && "lg:hover:bg-foreground/5",
|
|
278
335
|
className,
|
|
279
336
|
);
|
|
280
337
|
|
|
@@ -47,10 +47,12 @@ export type EventDayHeaderProps = {
|
|
|
47
47
|
*/
|
|
48
48
|
action?: React.ReactNode;
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* `sm`: the phone scale (30px day number, 15px weekday, 11.5px meta).
|
|
51
|
+
* `default`: the desktop scale (58 / 18 / 12.5). `responsive`: `sm` below
|
|
52
|
+
* `lg` and `default` from it, in one node — for a server render that
|
|
53
|
+
* cannot know the viewport.
|
|
52
54
|
*/
|
|
53
|
-
size?: "sm" | "default";
|
|
55
|
+
size?: "sm" | "default" | "responsive";
|
|
54
56
|
href?: string;
|
|
55
57
|
linkComponent?: React.ComponentType<EventDayHeaderLinkProps>;
|
|
56
58
|
onClick?: () => void;
|
|
@@ -70,6 +72,9 @@ export function EventDayHeader({
|
|
|
70
72
|
}: EventDayHeaderProps) {
|
|
71
73
|
const isInteractive = !!href || !!onClick;
|
|
72
74
|
const large = size === "default";
|
|
75
|
+
// `responsive` is the phone scale with every `default` class as an `lg:`
|
|
76
|
+
// twin — one node for a server-rendered timeline.
|
|
77
|
+
const responsive = size === "responsive";
|
|
73
78
|
|
|
74
79
|
const body = (
|
|
75
80
|
<>
|
|
@@ -78,21 +83,27 @@ export function EventDayHeader({
|
|
|
78
83
|
"font-black leading-[0.82] tracking-[-0.05em] tabular-nums",
|
|
79
84
|
// `sm` is the phone scale: 30px, the design's app day head. 40px
|
|
80
85
|
// was a page-scale number that took a third of a 390px row.
|
|
81
|
-
large ? "text-[58px]" : "text-[30px] leading-[0.86]"
|
|
86
|
+
large ? "text-[58px]" : "text-[30px] leading-[0.86]",
|
|
87
|
+
responsive && "lg:text-[58px] lg:leading-[0.82]"
|
|
82
88
|
)}
|
|
83
89
|
>
|
|
84
90
|
{dayNumber}
|
|
85
91
|
</div>
|
|
86
|
-
<div className={large ? "pb-1" : "pb-0.5"}>
|
|
92
|
+
<div className={cn(large ? "pb-1" : "pb-0.5", responsive && "lg:pb-1")}>
|
|
87
93
|
<div
|
|
88
|
-
className={cn(
|
|
94
|
+
className={cn(
|
|
95
|
+
"font-extrabold tracking-[-0.015em]",
|
|
96
|
+
large ? "text-lg" : "text-[15px]",
|
|
97
|
+
responsive && "lg:text-lg"
|
|
98
|
+
)}
|
|
89
99
|
>
|
|
90
100
|
{weekday}
|
|
91
101
|
</div>
|
|
92
102
|
<div
|
|
93
103
|
className={cn(
|
|
94
104
|
"mt-0.5 text-muted-foreground",
|
|
95
|
-
large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]"
|
|
105
|
+
large ? "whitespace-nowrap text-[12.5px]" : "text-[11.5px]",
|
|
106
|
+
responsive && "lg:whitespace-nowrap lg:text-[12.5px]"
|
|
96
107
|
)}
|
|
97
108
|
>
|
|
98
109
|
{meta}
|
|
@@ -132,6 +143,7 @@ export function EventDayHeader({
|
|
|
132
143
|
className={cn(
|
|
133
144
|
"relative flex w-full items-end gap-3 pb-3 pt-[18px]",
|
|
134
145
|
large && "gap-[18px] pb-4",
|
|
146
|
+
responsive && "lg:gap-[18px] lg:pb-4",
|
|
135
147
|
className
|
|
136
148
|
)}
|
|
137
149
|
>
|