@bearmenu/ui 0.8.16 → 0.8.17
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-ZLPWEHZO.js → chunk-54FQQZZJ.js} +1 -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-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/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/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/concept-compare-rail.js +2 -2
- package/dist/components/concept-compare.js +2 -2
- 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-detail-panel.js +2 -2
- 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/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.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-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 +1 -1
- package/dist/components/place-details-mobile.js +1 -1
- 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/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/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-kitchen-list.tsx +45 -6
- package/src/components/photo-ground.tsx +7 -0
- package/src/components/section-header.test.tsx +15 -0
- package/src/components/section-header.tsx +26 -12
- 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-SXQZ7DLH.js +0 -167
|
@@ -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 */}
|
|
@@ -118,6 +118,13 @@ export type FacetGroupProps = Omit<
|
|
|
118
118
|
/** e.g. "Show all". Omit to hide the trailing action. */
|
|
119
119
|
moreLabel?: string;
|
|
120
120
|
onMore?: () => void;
|
|
121
|
+
/**
|
|
122
|
+
* Whether the trailing action has already expanded the options it controls
|
|
123
|
+
* — set it when `onMore` reveals more rows in place (and `moreLabel` reads
|
|
124
|
+
* "Show fewer"), so the button carries `aria-expanded`. Leave it unset when
|
|
125
|
+
* the action opens another surface.
|
|
126
|
+
*/
|
|
127
|
+
moreExpanded?: boolean;
|
|
121
128
|
className?: string;
|
|
122
129
|
};
|
|
123
130
|
|
|
@@ -133,6 +140,7 @@ export function FacetGroup({
|
|
|
133
140
|
onToggle,
|
|
134
141
|
moreLabel,
|
|
135
142
|
onMore,
|
|
143
|
+
moreExpanded,
|
|
136
144
|
className,
|
|
137
145
|
...rest
|
|
138
146
|
}: FacetGroupProps) {
|
|
@@ -304,6 +312,7 @@ export function FacetGroup({
|
|
|
304
312
|
<button
|
|
305
313
|
type="button"
|
|
306
314
|
onClick={onMore}
|
|
315
|
+
aria-expanded={moreExpanded}
|
|
307
316
|
className="cursor-pointer pt-[11px] text-[12.5px] font-bold text-primary transition-colors duration-150 hover:text-primary/80 motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:rounded-sm"
|
|
308
317
|
>
|
|
309
318
|
{moreLabel}
|
|
@@ -31,9 +31,12 @@ describe("hueTint", () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
describe("HueChip", () => {
|
|
34
|
+
// The label sits in an inner span that ellipsises; the chip is its parent.
|
|
35
|
+
const chipOf = (text: string) => screen.getByText(text).parentElement!;
|
|
36
|
+
|
|
34
37
|
it("wears the tint when it has a hue", () => {
|
|
35
38
|
render(<HueChip hue="oklch(0.62 0.14 82)">Sweet Pastry</HueChip>);
|
|
36
|
-
const chip =
|
|
39
|
+
const chip = chipOf("Sweet Pastry");
|
|
37
40
|
expect(chip.getAttribute("style")).toContain("oklab");
|
|
38
41
|
expect(chip.className).not.toContain("bg-muted");
|
|
39
42
|
});
|
|
@@ -41,7 +44,7 @@ describe("HueChip", () => {
|
|
|
41
44
|
it("falls back to the neutral rather than to a colour", () => {
|
|
42
45
|
// A facet with no family behind it must not borrow another family's colour.
|
|
43
46
|
render(<HueChip>Fast Food</HueChip>);
|
|
44
|
-
const chip =
|
|
47
|
+
const chip = chipOf("Fast Food");
|
|
45
48
|
expect(chip.className).toContain("bg-muted");
|
|
46
49
|
expect(chip.getAttribute("style")).toBeNull();
|
|
47
50
|
});
|
|
@@ -52,7 +55,7 @@ describe("HueChip", () => {
|
|
|
52
55
|
<HueChip as="li">Salad</HueChip>
|
|
53
56
|
</ul>
|
|
54
57
|
);
|
|
55
|
-
expect(
|
|
58
|
+
expect(chipOf("Salad").tagName).toBe("LI");
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
it("takes an accessible name for a chip whose text is a bare figure", () => {
|
|
@@ -86,14 +86,17 @@ export function HueChip({
|
|
|
86
86
|
<Tag
|
|
87
87
|
aria-label={ariaLabel}
|
|
88
88
|
className={cn(
|
|
89
|
-
|
|
89
|
+
// One line, and never wider than its row: a chip that wraps grows past
|
|
90
|
+
// its 28px and paints over its neighbours; a chip that overflows pushes
|
|
91
|
+
// the row. The label ellipsises instead.
|
|
92
|
+
"inline-flex h-7 min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-[11.5px] text-foreground",
|
|
90
93
|
!hue && "bg-muted",
|
|
91
94
|
className
|
|
92
95
|
)}
|
|
93
96
|
style={hue ? { backgroundColor: hueTint(hue, tone, base) } : undefined}
|
|
94
97
|
>
|
|
95
98
|
{icon}
|
|
96
|
-
{children}
|
|
99
|
+
<span className="min-w-0 truncate">{children}</span>
|
|
97
100
|
</Tag>
|
|
98
101
|
);
|
|
99
102
|
}
|
package/src/components/item.tsx
CHANGED
|
@@ -20,7 +20,10 @@ const itemVariants = cva(
|
|
|
20
20
|
lg: "px-5 py-4",
|
|
21
21
|
},
|
|
22
22
|
interactive: {
|
|
23
|
-
|
|
23
|
+
// The surface tint a hover is, not the brand fill: `bg-accent` here is
|
|
24
|
+
// the brown, and a row that inverts under the cursor loses its muted
|
|
25
|
+
// description. Same step the buttons take (`--accent-subtle`).
|
|
26
|
+
true: "cursor-pointer hover:bg-accent-subtle hover:border-accent/40 active:bg-muted active:scale-[0.99]",
|
|
24
27
|
false: "",
|
|
25
28
|
},
|
|
26
29
|
},
|
|
@@ -61,6 +61,18 @@ export const Default: Story = {
|
|
|
61
61
|
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS } },
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
+
/** The phone card at every width — for a desktop rail where the two-column card is too wide to scan. */
|
|
65
|
+
export const Compact: Story = {
|
|
66
|
+
args: { family: { ...mockFamilyBlock, photoUrls: PHOTOS }, variant: "compact" },
|
|
67
|
+
decorators: [
|
|
68
|
+
(Story) => (
|
|
69
|
+
<div className="w-[360px] bg-background p-4">
|
|
70
|
+
<Story />
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
64
76
|
/**
|
|
65
77
|
* No photographs: the mosaic ground with the family's initial, never a grey box.
|
|
66
78
|
* A missing photograph is not a broken image, and a placeholder makes a real
|
|
@@ -99,6 +99,13 @@ export type MenuFamilyBlockProps = {
|
|
|
99
99
|
hue?: string;
|
|
100
100
|
/** Finished CSS gradient — the family's colour over the mosaic. */
|
|
101
101
|
gradient?: string;
|
|
102
|
+
/**
|
|
103
|
+
* `wide` (default): the phone card below `lg`, the two-column card — photo
|
|
104
|
+
* third, rows beside it — from `lg`. `compact`: the phone card at every
|
|
105
|
+
* width, for a rail on a desktop page where the two-column card is too much
|
|
106
|
+
* to scan across (the browse page's concept rail).
|
|
107
|
+
*/
|
|
108
|
+
variant?: "wide" | "compact";
|
|
102
109
|
linkComponent?: MenuLinkComponent;
|
|
103
110
|
className?: string;
|
|
104
111
|
};
|
|
@@ -108,17 +115,20 @@ const GROUND = PHOTO_GROUND;
|
|
|
108
115
|
|
|
109
116
|
/**
|
|
110
117
|
* The ink. Nearly absent at the top, heavier at the foot on the phone — the
|
|
111
|
-
* top 40% is the photograph, the rows sit on 0.92
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* it renders nothing in Storybook and the other three consumers.
|
|
118
|
+
* top 40% is the photograph, the rows sit on 0.92+. It is the layer the
|
|
119
|
+
* contrast rests on. Literal rather than `var(--scrim)`: that token lives in
|
|
120
|
+
* the app's stylesheet, not this package's, so a DS component reading it
|
|
121
|
+
* renders nothing in Storybook and the other three consumers.
|
|
116
122
|
*/
|
|
117
|
-
const
|
|
118
|
-
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)]
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
const INK_PHONE =
|
|
124
|
+
"bg-[linear-gradient(to_bottom,oklch(0.16_0.012_55_/_0.12)_0%,oklch(0.16_0.012_55_/_0.55)_38%,oklch(0.16_0.012_55_/_0.92)_62%,oklch(0.16_0.012_55_/_0.96)_100%)]";
|
|
125
|
+
/**
|
|
126
|
+
* From `lg` the ramp runs to the right: clear over the photo third, dark
|
|
127
|
+
* under the rows — the rows sit on the ink itself, no opaque paper, so the
|
|
128
|
+
* photograph still reads through the ramp's shoulder.
|
|
129
|
+
*/
|
|
130
|
+
const INK_WIDE =
|
|
131
|
+
"lg:bg-[linear-gradient(to_right,oklch(0.16_0.012_55_/_0.32)_0%,oklch(0.16_0.012_55_/_0.62)_34%,oklch(0.16_0.012_55_/_0.9)_52%,oklch(0.16_0.012_55_/_0.96)_100%)]";
|
|
122
132
|
|
|
123
133
|
/**
|
|
124
134
|
* A second, LOCAL scrim under the name, count and action — the photo column's
|
|
@@ -144,9 +154,11 @@ export function MenuFamilyBlock({
|
|
|
144
154
|
formatCount = String,
|
|
145
155
|
hue,
|
|
146
156
|
gradient,
|
|
157
|
+
variant = "wide",
|
|
147
158
|
linkComponent: LinkComponent = DefaultLink,
|
|
148
159
|
className,
|
|
149
160
|
}: MenuFamilyBlockProps) {
|
|
161
|
+
const wide = variant === "wide";
|
|
150
162
|
const [photo] = family.photoUrls ?? [];
|
|
151
163
|
// "+N more" counts past the rows shown, which are the venues the reader can
|
|
152
164
|
// see — the photograph is one of them, not a venue on its own.
|
|
@@ -161,7 +173,9 @@ export function MenuFamilyBlock({
|
|
|
161
173
|
className={cn(
|
|
162
174
|
/* `isolate` confines the blend and the backdrop-filter to this card.
|
|
163
175
|
`dark` scopes every token inside to its dark-theme value. */
|
|
164
|
-
|
|
176
|
+
/* A flex column, so a `h-full` card in a rail fills its row: siblings
|
|
177
|
+
with two rows and with three end on the same baseline. */
|
|
178
|
+
"dark relative isolate flex flex-col overflow-hidden rounded-[14px] text-foreground",
|
|
165
179
|
GROUND,
|
|
166
180
|
className
|
|
167
181
|
)}
|
|
@@ -171,21 +185,32 @@ export function MenuFamilyBlock({
|
|
|
171
185
|
<PhotoGround photos={[photo]} initials={initial} mode="stack" gradient={gradient} />
|
|
172
186
|
|
|
173
187
|
{/* ── Layer 2: the ink ────────────────────────────────────────────── */}
|
|
174
|
-
<span aria-hidden className={cn("absolute inset-0",
|
|
188
|
+
<span aria-hidden className={cn("absolute inset-0", INK_PHONE, wide && INK_WIDE)} />
|
|
175
189
|
|
|
176
190
|
{/* ── The content, floating ───────────────────────────────────────── */}
|
|
177
|
-
<div
|
|
191
|
+
<div
|
|
192
|
+
className={cn(
|
|
193
|
+
"relative grid flex-1 grid-cols-1 grid-rows-[auto_1fr]",
|
|
194
|
+
wide && "lg:grid-cols-[34%_1fr] lg:grid-rows-1"
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
178
197
|
{/* The photo column. On the phone it is the card's head, with the
|
|
179
198
|
overflow pill pinned to the corner and the photograph clear above
|
|
180
199
|
the name; on desktop the left third, its stack anchored to the foot. */}
|
|
181
200
|
<div
|
|
182
201
|
className={cn(
|
|
183
|
-
"relative flex min-h-[220px] flex-col justify-end p-3.5
|
|
202
|
+
"relative flex min-h-[220px] flex-col justify-end p-3.5",
|
|
203
|
+
wide && "lg:min-h-[320px] lg:p-5",
|
|
184
204
|
TEXT_SCRIM
|
|
185
205
|
)}
|
|
186
206
|
>
|
|
187
207
|
{pill && (
|
|
188
|
-
<span
|
|
208
|
+
<span
|
|
209
|
+
className={cn(
|
|
210
|
+
"absolute right-3.5 top-3.5 rounded-full bg-[oklch(0.16_0.012_55_/_0.78)] px-2.5 py-1 text-[11px] font-semibold text-white",
|
|
211
|
+
wide && "lg:static lg:mb-3 lg:self-start"
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
189
214
|
{pill}
|
|
190
215
|
</span>
|
|
191
216
|
)}
|
|
@@ -195,7 +220,12 @@ export function MenuFamilyBlock({
|
|
|
195
220
|
{family.kicker}
|
|
196
221
|
</span>
|
|
197
222
|
)}
|
|
198
|
-
<h3
|
|
223
|
+
<h3
|
|
224
|
+
className={cn(
|
|
225
|
+
"text-balance font-serif text-[26px] font-semibold leading-tight tracking-[-0.02em] text-white",
|
|
226
|
+
wide && "lg:text-[30px]"
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
199
229
|
{family.name}
|
|
200
230
|
</h3>
|
|
201
231
|
{/* The count, the facts and the band on one line: a few facts about
|
|
@@ -230,7 +260,7 @@ export function MenuFamilyBlock({
|
|
|
230
260
|
aria-label={labels.browse}
|
|
231
261
|
className={cn(buttonVariants({ variant: "ink", size: "pill" }), "mt-3.5 w-max max-w-full")}
|
|
232
262
|
>
|
|
233
|
-
{labels.browseShort ? (
|
|
263
|
+
{labels.browseShort && wide ? (
|
|
234
264
|
<>
|
|
235
265
|
<span className="lg:hidden">{labels.browse}</span>
|
|
236
266
|
<span className="hidden lg:inline">{labels.browseShort}</span>
|
|
@@ -241,10 +271,10 @@ export function MenuFamilyBlock({
|
|
|
241
271
|
</LinkComponent>
|
|
242
272
|
</div>
|
|
243
273
|
|
|
244
|
-
{/* The row column: chips, then the rows, on
|
|
245
|
-
<div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5 lg:p-5"
|
|
274
|
+
{/* The row column: chips, then the rows, on the ink's dark shoulder. */}
|
|
275
|
+
<div className={cn("flex min-w-0 flex-col px-3.5 pb-3.5", wide && "lg:p-5")}>
|
|
246
276
|
{family.chips && family.chips.length > 0 && (
|
|
247
|
-
<ul className="flex flex-wrap items-center gap-1.5 lg:justify-end">
|
|
277
|
+
<ul className={cn("flex flex-wrap items-center gap-1.5", wide && "lg:justify-end")}>
|
|
248
278
|
{family.chips.map((chip) => (
|
|
249
279
|
/* Label only. The counts are VENUES beside a dish group's name,
|
|
250
280
|
which every reader takes for dishes. */
|
|
@@ -261,6 +291,9 @@ export function MenuFamilyBlock({
|
|
|
261
291
|
<MenuKitchenList
|
|
262
292
|
kitchens={venues}
|
|
263
293
|
labels={labels.kitchens}
|
|
294
|
+
/* A half-width column: the dish goes under the venue, not
|
|
295
|
+
beside it. The compact card is the phone row throughout. */
|
|
296
|
+
layout={wide ? "stacked" : "phone"}
|
|
264
297
|
linkComponent={LinkComponent}
|
|
265
298
|
/>
|
|
266
299
|
</>
|