@bearmenu/ui 0.8.15 → 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/README.md +1 -0
- package/dist/{chunk-ZLPWEHZO.js → chunk-54FQQZZJ.js} +1 -1
- package/dist/{chunk-5FCC5L5F.js → chunk-5LTILG3L.js} +29 -6
- package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
- package/dist/{chunk-V4E333QS.js → chunk-5YNIQ2BL.js} +7 -29
- 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-RIH2IS7X.js +29 -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 +3 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/button.d.ts +5 -1
- package/dist/components/button.js +3 -2
- package/dist/components/calendar.js +3 -2
- package/dist/components/carousel.js +3 -2
- package/dist/components/coach-marks.d.ts +63 -0
- package/dist/components/coach-marks.js +162 -0
- package/dist/components/combobox.js +3 -2
- 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/date-input.d.ts +1 -1
- package/dist/components/doorway-tiles.d.ts +11 -0
- package/dist/components/doorway-tiles.js +5 -3
- package/dist/components/empty-state.js +3 -2
- package/dist/components/event-detail-panel.js +4 -3
- package/dist/components/event-index-row.js +1 -1
- package/dist/components/event-list-row-wide.js +4 -3
- 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/haptic-switch.d.ts +35 -0
- package/dist/components/haptic-switch.js +3 -0
- package/dist/components/hue-chip.js +1 -1
- package/dist/components/item.js +4 -1
- package/dist/components/kitchen-dossier.js +2 -2
- package/dist/components/link-button.js +3 -2
- package/dist/components/menu-family-block.d.ts +8 -1
- package/dist/components/menu-family-block.js +103 -69
- 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 +3 -2
- 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 +4 -3
- package/dist/components/pagination.js +3 -2
- 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/searchable-select.js +3 -2
- 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 +3 -2
- package/dist/components/switch.d.ts +7 -1
- package/dist/components/switch.js +33 -24
- 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/dist/hooks/use-haptic.d.ts +9 -1
- package/dist/hooks/use-haptic.js +1 -1
- 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 +41 -6
- 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/haptic-switch.test.tsx +134 -0
- package/src/components/haptic-switch.tsx +62 -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/switch.tsx +29 -18
- 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/src/hooks/use-haptic.test.tsx +54 -59
- package/src/hooks/use-haptic.ts +18 -44
- 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}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { HapticSwitch, isHapticSwitchTarget } from "./haptic-switch";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { Switch } from "./switch";
|
|
7
|
+
|
|
8
|
+
const IOS_UA =
|
|
9
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1";
|
|
10
|
+
|
|
11
|
+
function pretendIos() {
|
|
12
|
+
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(IOS_UA);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const overlay = () => document.querySelector<HTMLInputElement>('input[data-slot="haptic-switch"]');
|
|
16
|
+
|
|
17
|
+
describe("HapticSwitch", () => {
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders nothing off iOS", () => {
|
|
23
|
+
render(<HapticSwitch />);
|
|
24
|
+
expect(overlay()).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders an invisible native switch on iOS, out of the tab order and the a11y tree", () => {
|
|
28
|
+
pretendIos();
|
|
29
|
+
render(<HapticSwitch />);
|
|
30
|
+
const input = overlay();
|
|
31
|
+
expect(input).not.toBeNull();
|
|
32
|
+
expect(input?.type).toBe("checkbox");
|
|
33
|
+
expect(input?.hasAttribute("switch")).toBe(true);
|
|
34
|
+
expect(input?.getAttribute("aria-hidden")).toBe("true");
|
|
35
|
+
expect(input?.tabIndex).toBe(-1);
|
|
36
|
+
expect(input?.className).toContain("opacity-0");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("mirrors the host's disabled state", () => {
|
|
40
|
+
pretendIos();
|
|
41
|
+
render(<HapticSwitch disabled />);
|
|
42
|
+
expect(overlay()?.disabled).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("identifies its own click target", () => {
|
|
46
|
+
pretendIos();
|
|
47
|
+
render(<HapticSwitch />);
|
|
48
|
+
expect(isHapticSwitchTarget(overlay())).toBe(true);
|
|
49
|
+
expect(isHapticSwitchTarget(document.body)).toBe(false);
|
|
50
|
+
expect(isHapticSwitchTarget(null)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("Button haptic on iOS", () => {
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("lays the switch over the button and still runs onClick when the switch is tapped", async () => {
|
|
60
|
+
pretendIos();
|
|
61
|
+
const onClick = vi.fn();
|
|
62
|
+
render(
|
|
63
|
+
<Button haptic onClick={onClick}>
|
|
64
|
+
Save
|
|
65
|
+
</Button>
|
|
66
|
+
);
|
|
67
|
+
const button = screen.getByRole("button", { name: "Save" });
|
|
68
|
+
expect(button.className).toContain("relative");
|
|
69
|
+
const input = overlay();
|
|
70
|
+
expect(input?.parentElement).toBe(button);
|
|
71
|
+
|
|
72
|
+
await userEvent.click(input!);
|
|
73
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect(input?.checked).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("does not render the switch without haptic, or with asChild", () => {
|
|
78
|
+
pretendIos();
|
|
79
|
+
const { rerender } = render(<Button>Plain</Button>);
|
|
80
|
+
expect(overlay()).toBeNull();
|
|
81
|
+
rerender(
|
|
82
|
+
<Button haptic asChild>
|
|
83
|
+
<a href="/x">Link</a>
|
|
84
|
+
</Button>
|
|
85
|
+
);
|
|
86
|
+
expect(overlay()).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("forwards a submit tapped through the switch to the form", async () => {
|
|
90
|
+
pretendIos();
|
|
91
|
+
const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
|
|
92
|
+
render(
|
|
93
|
+
<form onSubmit={onSubmit}>
|
|
94
|
+
<Button type="submit" haptic>
|
|
95
|
+
Send
|
|
96
|
+
</Button>
|
|
97
|
+
</form>
|
|
98
|
+
);
|
|
99
|
+
await userEvent.click(overlay()!);
|
|
100
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does not forward the submit when the click handler cancelled it", async () => {
|
|
104
|
+
pretendIos();
|
|
105
|
+
const onSubmit = vi.fn((e: React.FormEvent) => e.preventDefault());
|
|
106
|
+
render(
|
|
107
|
+
<form onSubmit={onSubmit}>
|
|
108
|
+
<Button type="submit" haptic onClick={(e) => e.preventDefault()}>
|
|
109
|
+
Send
|
|
110
|
+
</Button>
|
|
111
|
+
</form>
|
|
112
|
+
);
|
|
113
|
+
await userEvent.click(overlay()!);
|
|
114
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("Switch haptic on iOS", () => {
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
vi.restoreAllMocks();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("toggles through a tap on the overlay", async () => {
|
|
124
|
+
pretendIos();
|
|
125
|
+
const onCheckedChange = vi.fn();
|
|
126
|
+
render(<Switch haptic aria-label="Precise" onCheckedChange={onCheckedChange} />);
|
|
127
|
+
const root = screen.getByRole("switch", { name: "Precise" });
|
|
128
|
+
expect(root.className).toContain("relative");
|
|
129
|
+
expect(overlay()?.parentElement).toBe(root);
|
|
130
|
+
|
|
131
|
+
await userEvent.click(overlay()!);
|
|
132
|
+
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { isIos } from "../hooks/use-haptic";
|
|
5
|
+
|
|
6
|
+
export const HAPTIC_SWITCH_SLOT = "haptic-switch";
|
|
7
|
+
|
|
8
|
+
/** True when a click's target is the overlay switch rather than the host. */
|
|
9
|
+
export function isHapticSwitchTarget(target: EventTarget | null): boolean {
|
|
10
|
+
return target instanceof HTMLElement && target.dataset.slot === HAPTIC_SWITCH_SLOT;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type HapticSwitchProps = {
|
|
14
|
+
/** Mirror the host's disabled state; a disabled switch neither flips nor ticks. */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The only haptic iOS Safari has left.
|
|
20
|
+
*
|
|
21
|
+
* WebKit plays the system toggle haptic when an `<input type="checkbox" switch>`
|
|
22
|
+
* flips under the user's finger, and since iOS 26.5 under the finger is the
|
|
23
|
+
* only way — a programmatic click on the input or its label no longer counts
|
|
24
|
+
* (WebKit #65327). So the switch goes INSIDE the control, stretched over it and
|
|
25
|
+
* invisible, and the tap that lands on the control lands on the switch first.
|
|
26
|
+
* The click then bubbles to the host's own handler as usual.
|
|
27
|
+
*
|
|
28
|
+
* What the host must do:
|
|
29
|
+
* - be positioned (`relative` at least) so `inset-0` fills it;
|
|
30
|
+
* - not call `preventDefault()` on the click — that cancels the toggle, and
|
|
31
|
+
* the toggle is the haptic;
|
|
32
|
+
* - if it is `type="submit"`, submit explicitly: the click target is the
|
|
33
|
+
* switch, so the button's implicit submission never runs. `Button` does
|
|
34
|
+
* this through `form.requestSubmit()`.
|
|
35
|
+
*
|
|
36
|
+
* Renders nothing on the server and on non-iOS clients (so the first client
|
|
37
|
+
* render matches SSR). Keyboard and assistive-tech activation land on the host,
|
|
38
|
+
* not the switch, and stay silent. `opacity: 0`, never `display: none` — a
|
|
39
|
+
* switch that is not rendered does not tick.
|
|
40
|
+
*/
|
|
41
|
+
export function HapticSwitch({ disabled = false }: HapticSwitchProps) {
|
|
42
|
+
const [enabled, setEnabled] = React.useState(false);
|
|
43
|
+
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
setEnabled(isIos());
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
if (!enabled) return null;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<input
|
|
52
|
+
type="checkbox"
|
|
53
|
+
// WebKit's non-standard attribute; React's input types do not know it.
|
|
54
|
+
ref={(el) => el?.setAttribute("switch", "")}
|
|
55
|
+
data-slot={HAPTIC_SWITCH_SLOT}
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
tabIndex={-1}
|
|
58
|
+
disabled={disabled}
|
|
59
|
+
className="absolute inset-0 m-0 h-full w-full cursor-[inherit] rounded-[inherit] opacity-0 touch-manipulation [-webkit-tap-highlight-color:transparent]"
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -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
|
}
|