@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.
Files changed (102) hide show
  1. package/README.md +1 -0
  2. package/dist/{chunk-ZLPWEHZO.js → chunk-54FQQZZJ.js} +1 -1
  3. package/dist/{chunk-5FCC5L5F.js → chunk-5LTILG3L.js} +29 -6
  4. package/dist/{chunk-P43B3ONY.js → chunk-5XREWOBB.js} +3 -1
  5. package/dist/{chunk-V4E333QS.js → chunk-5YNIQ2BL.js} +7 -29
  6. package/dist/{chunk-CLLY6XB3.js → chunk-6VOWT4YR.js} +6 -3
  7. package/dist/{chunk-S33K4RC5.js → chunk-BKKQ7GCZ.js} +1 -1
  8. package/dist/chunk-QY66ME5B.js +207 -0
  9. package/dist/chunk-RIH2IS7X.js +29 -0
  10. package/dist/{chunk-T6S2ZBV6.js → chunk-SDWN6L4O.js} +1 -1
  11. package/dist/{chunk-CNJQUHCU.js → chunk-SWD6CJ35.js} +2 -1
  12. package/dist/{chunk-HDR7V6IZ.js → chunk-TIQNM4XD.js} +8 -1
  13. package/dist/components/alert-dialog.js +3 -2
  14. package/dist/components/badge.d.ts +1 -1
  15. package/dist/components/badge.js +1 -1
  16. package/dist/components/button.d.ts +5 -1
  17. package/dist/components/button.js +3 -2
  18. package/dist/components/calendar.js +3 -2
  19. package/dist/components/carousel.js +3 -2
  20. package/dist/components/coach-marks.d.ts +63 -0
  21. package/dist/components/coach-marks.js +162 -0
  22. package/dist/components/combobox.js +3 -2
  23. package/dist/components/concept-compare-rail.js +2 -2
  24. package/dist/components/concept-compare.js +2 -2
  25. package/dist/components/concept-convergence.js +2 -2
  26. package/dist/components/date-input.d.ts +1 -1
  27. package/dist/components/doorway-tiles.d.ts +11 -0
  28. package/dist/components/doorway-tiles.js +5 -3
  29. package/dist/components/empty-state.js +3 -2
  30. package/dist/components/event-detail-panel.js +4 -3
  31. package/dist/components/event-index-row.js +1 -1
  32. package/dist/components/event-list-row-wide.js +4 -3
  33. package/dist/components/event-list-row.js +1 -1
  34. package/dist/components/facet-group.d.ts +8 -1
  35. package/dist/components/facet-group.js +3 -0
  36. package/dist/components/filter-category-row.js +1 -1
  37. package/dist/components/haptic-switch.d.ts +35 -0
  38. package/dist/components/haptic-switch.js +3 -0
  39. package/dist/components/hue-chip.js +1 -1
  40. package/dist/components/item.js +4 -1
  41. package/dist/components/kitchen-dossier.js +2 -2
  42. package/dist/components/link-button.js +3 -2
  43. package/dist/components/menu-family-block.d.ts +8 -1
  44. package/dist/components/menu-family-block.js +103 -69
  45. package/dist/components/menu-family-card.js +2 -2
  46. package/dist/components/menu-filter-chips.d.ts +12 -3
  47. package/dist/components/menu-filter-chips.js +22 -4
  48. package/dist/components/menu-index-list.js +3 -2
  49. package/dist/components/menu-item.js +3 -3
  50. package/dist/components/menu-kitchen-list.d.ts +14 -2
  51. package/dist/components/menu-kitchen-list.js +1 -1
  52. package/dist/components/multi-select-combobox.js +4 -3
  53. package/dist/components/pagination.js +3 -2
  54. package/dist/components/photo-ground.d.ts +7 -1
  55. package/dist/components/photo-ground.js +1 -1
  56. package/dist/components/place-card.js +2 -2
  57. package/dist/components/place-details-mobile.js +1 -1
  58. package/dist/components/searchable-select.js +3 -2
  59. package/dist/components/section-header.d.ts +7 -1
  60. package/dist/components/section-header.js +11 -7
  61. package/dist/components/signature-chip.js +2 -2
  62. package/dist/components/sliding-panels.js +3 -2
  63. package/dist/components/switch.d.ts +7 -1
  64. package/dist/components/switch.js +33 -24
  65. package/dist/components/toggle-group.js +9 -4
  66. package/dist/components/toggle.d.ts +1 -1
  67. package/dist/components/toggle.js +1 -1
  68. package/dist/components/top-ranking-entry.js +3 -3
  69. package/dist/hooks/use-haptic.d.ts +9 -1
  70. package/dist/hooks/use-haptic.js +1 -1
  71. package/package.json +1 -1
  72. package/src/components/badge.stories.tsx +31 -1
  73. package/src/components/badge.tsx +2 -0
  74. package/src/components/button.stories.tsx +5 -4
  75. package/src/components/button.test.tsx +11 -1
  76. package/src/components/button.tsx +41 -6
  77. package/src/components/coach-marks.stories.tsx +79 -0
  78. package/src/components/coach-marks.test.tsx +140 -0
  79. package/src/components/coach-marks.tsx +252 -0
  80. package/src/components/doorway-tiles.stories.tsx +34 -0
  81. package/src/components/doorway-tiles.test.tsx +27 -0
  82. package/src/components/doorway-tiles.tsx +30 -2
  83. package/src/components/facet-group.tsx +9 -0
  84. package/src/components/haptic-switch.test.tsx +134 -0
  85. package/src/components/haptic-switch.tsx +62 -0
  86. package/src/components/hue-chip.test.tsx +6 -3
  87. package/src/components/hue-chip.tsx +5 -2
  88. package/src/components/item.tsx +4 -1
  89. package/src/components/menu-family-block.stories.tsx +12 -0
  90. package/src/components/menu-family-block.tsx +53 -20
  91. package/src/components/menu-filter-chips.tsx +32 -4
  92. package/src/components/menu-kitchen-list.tsx +45 -6
  93. package/src/components/photo-ground.tsx +7 -0
  94. package/src/components/section-header.test.tsx +15 -0
  95. package/src/components/section-header.tsx +26 -12
  96. package/src/components/switch.tsx +29 -18
  97. package/src/components/toggle-group.stories.tsx +37 -1
  98. package/src/components/toggle-group.tsx +11 -6
  99. package/src/components/toggle.tsx +8 -0
  100. package/src/hooks/use-haptic.test.tsx +54 -59
  101. package/src/hooks/use-haptic.ts +18 -44
  102. 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.photoUrl ? (
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 = screen.getByText("Sweet Pastry");
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 = screen.getByText("Fast Food");
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(screen.getByText("Salad").tagName).toBe("LI");
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
- "inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-[11.5px] text-foreground",
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
  }