@bearmenu/ui 0.8.20 → 0.8.22

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.
@@ -25,15 +25,50 @@ import * as React from 'react';
25
25
  * side, re-measure on scroll and resize) and the keys (Escape closes, the
26
26
  * arrows step). `onProgress` reports the current stop for a toolbar label.
27
27
  *
28
- * Rendered into `document.body`: a page wrapper with an opacity or transform
29
- * transition is a stacking context, and the tour must sit above everything
30
- * the page and its chrome draw, whatever wraps the consumer.
28
+ * The spotlight is a hole in the dimming, in hit-testing as well as in
29
+ * paint: a tap on the ringed control reaches it. A gold ring around a real,
30
+ * un-dimmed control is an invitation, and a tour that swallowed the tap
31
+ * would be lying. Acting ends the tour — the thing the reader opened is the
32
+ * lesson, and whatever it opened would otherwise sit under or over an
33
+ * `aria-modal` dialog that promises the page beneath is inert. The consumer
34
+ * has already heard the stop through `onProgress`, so a "N new" door can
35
+ * pick the tour up later. The dimmed page itself does not close the tour: a
36
+ * thumb resting on it while reading is not a decision.
37
+ *
38
+ * A target is scrolled into view only when it is not already in it. The
39
+ * page's fixed chrome (`inset`) narrows what "in view" means, and a stop can
40
+ * opt out of scrolling altogether (`scroll: false`): `scrollIntoView` on a
41
+ * pinned rail scrolls the document to the rail's in-flow position, which is
42
+ * the top of whatever it pins over, and a reader mid-feed loses their place.
43
+ *
44
+ * Rendered into `document.body` above every drawer, sheet and dialog: a page
45
+ * wrapper with an opacity or transform transition is a stacking context, and
46
+ * the tour must sit above everything the page and its chrome draw, whatever
47
+ * wraps the consumer.
31
48
  */
32
49
  type CoachMarkStop = {
33
50
  /** Matches the target's `data-coach-mark` attribute. */
34
51
  id: string;
35
52
  title: string;
36
53
  body: string;
54
+ /**
55
+ * A `control` (a button, a field, a toggle) wears the gold ring; a `region`
56
+ * (a list, a rail, a block) is defined by the cut-out's edge alone. Stated,
57
+ * not inferred: a toggle group is a div of buttons and a results region a
58
+ * list of links. Defaults to `control`.
59
+ */
60
+ kind?: "control" | "region";
61
+ /**
62
+ * Never scroll to this target — for a control pinned to the viewport, whose
63
+ * box says where it is but whose in-flow position is somewhere else.
64
+ * Defaults to scrolling when the target is out of view.
65
+ */
66
+ scroll?: boolean;
67
+ };
68
+ /** The viewport's fixed chrome: what a target must clear to count as in view. */
69
+ type CoachMarksInset = {
70
+ top?: number;
71
+ bottom?: number;
37
72
  };
38
73
  type CoachMarksLabels = {
39
74
  /** "Step 2 of 4". */
@@ -47,7 +82,7 @@ type CoachMarksLabels = {
47
82
  type CoachMarksProps = {
48
83
  open: boolean;
49
84
  stops: CoachMarkStop[];
50
- /** Skip, Done, Escape and a tap on the dimmed page all end here. */
85
+ /** Skip, Done, Escape and a tap on the spotlit target all end here. */
51
86
  onClose: () => void;
52
87
  /**
53
88
  * 1-based position, the count of stops the page actually has, and the
@@ -56,8 +91,10 @@ type CoachMarksProps = {
56
91
  */
57
92
  onProgress?: (step: number, total: number, stop: CoachMarkStop) => void;
58
93
  labels: CoachMarksLabels;
94
+ /** The page's fixed header and bottom bar, in px; the callout keeps clear of them too. */
95
+ inset?: CoachMarksInset;
59
96
  className?: string;
60
97
  };
61
- declare function CoachMarks({ open, stops, onClose, onProgress, labels, className }: CoachMarksProps): React.ReactPortal | null;
98
+ declare function CoachMarks({ open, stops, onClose, onProgress, labels, inset, className, }: CoachMarksProps): React.ReactPortal | null;
62
99
 
63
- export { type CoachMarkStop, CoachMarks, type CoachMarksLabels, type CoachMarksProps };
100
+ export { type CoachMarkStop, CoachMarks, type CoachMarksInset, type CoachMarksLabels, type CoachMarksProps };
@@ -3,7 +3,7 @@ import '../chunk-RIH2IS7X.js';
3
3
  import '../chunk-5YNIQ2BL.js';
4
4
  import { cn } from '../chunk-FEK5XGZW.js';
5
5
  import '../chunk-MMUBH76A.js';
6
- import { useState, useLayoutEffect, useCallback, useEffect } from 'react';
6
+ import { useState, useLayoutEffect, useCallback, useRef, useEffect } from 'react';
7
7
  import { createPortal } from 'react-dom';
8
8
  import { Lightbulb, ArrowRight } from 'lucide-react';
9
9
  import { jsxs, jsx } from 'react/jsx-runtime';
@@ -13,7 +13,15 @@ var CALLOUT_WIDTH = 320;
13
13
  var CALLOUT_GAP = 14;
14
14
  var CALLOUT_ROOM = 280;
15
15
  var EDGE = 12;
16
+ var PIN_SIZE = 32;
17
+ var PIN_SNAP = 56;
18
+ var CARET_MARGIN = 16;
16
19
  var PIN = "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";
20
+ function dimClip(spot, viewport) {
21
+ const right = spot.left + spot.width;
22
+ const bottom = spot.top + spot.height;
23
+ return `polygon(evenodd, 0 0, ${viewport.width}px 0, ${viewport.width}px ${viewport.height}px, 0 ${viewport.height}px, 0 0, ${spot.left}px ${spot.top}px, ${spot.left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${spot.top}px, ${spot.left}px ${spot.top}px)`;
24
+ }
17
25
  function findTarget(id) {
18
26
  if (typeof document === "undefined") return null;
19
27
  const candidates = document.querySelectorAll(`[data-coach-mark="${id}"]`);
@@ -26,7 +34,22 @@ function measure(element) {
26
34
  const box = element.getBoundingClientRect();
27
35
  return { top: box.top, left: box.left, width: box.width, height: box.height };
28
36
  }
29
- function CoachMarks({ open, stops, onClose, onProgress, labels, className }) {
37
+ function inView(element, inset) {
38
+ var _a, _b;
39
+ const box = element.getBoundingClientRect();
40
+ return box.top - PAD >= ((_a = inset.top) != null ? _a : 0) && box.bottom + PAD <= window.innerHeight - ((_b = inset.bottom) != null ? _b : 0);
41
+ }
42
+ var NO_INSET = {};
43
+ function CoachMarks({
44
+ open,
45
+ stops,
46
+ onClose,
47
+ onProgress,
48
+ labels,
49
+ inset = NO_INSET,
50
+ className
51
+ }) {
52
+ var _a, _b;
30
53
  const [available, setAvailable] = useState([]);
31
54
  const [index, setIndex] = useState(0);
32
55
  const [rect, setRect] = useState(null);
@@ -34,36 +57,57 @@ function CoachMarks({ open, stops, onClose, onProgress, labels, className }) {
34
57
  const itinerary = stops.map((stop) => stop.id).join("|");
35
58
  useLayoutEffect(() => {
36
59
  if (!open) return;
37
- const present = stops.filter((stop) => findTarget(stop.id));
60
+ const present = stops.filter((stop) => findTarget(stop.id)).map((stop) => stop.id);
38
61
  setAvailable(present);
39
62
  setIndex(0);
40
63
  if (present.length === 0) onClose();
41
64
  }, [open, itinerary]);
42
- const current = available[index];
43
- const currentId = current == null ? void 0 : current.id;
65
+ const currentId = available[index];
66
+ const current = currentId ? stops.find((stop) => stop.id === currentId) : void 0;
67
+ const scrolls = (current == null ? void 0 : current.scroll) !== false;
68
+ const insetTop = (_a = inset.top) != null ? _a : 0;
69
+ const insetBottom = (_b = inset.bottom) != null ? _b : 0;
44
70
  const remeasure = useCallback(() => {
45
71
  const element = currentId ? findTarget(currentId) : null;
46
72
  setRect(element ? measure(element) : null);
47
73
  setViewport({ width: window.innerWidth, height: window.innerHeight });
48
74
  }, [currentId]);
49
75
  useLayoutEffect(() => {
50
- if (!open || !current) return;
51
- const element = findTarget(current.id);
76
+ if (!open || !currentId) return;
77
+ const element = findTarget(currentId);
52
78
  if (!element) {
53
- const forward = available.findIndex((stop, at) => at > index && findTarget(stop.id));
79
+ const forward = available.findIndex((id, at) => at > index && findTarget(id));
54
80
  if (forward !== -1) setIndex(forward);
55
81
  else onClose();
56
82
  return;
57
83
  }
58
- element.scrollIntoView({ block: "center", behavior: "smooth", inline: "nearest" });
84
+ if (scrolls) {
85
+ const band = window.innerHeight - insetTop - insetBottom;
86
+ const tall = element.getBoundingClientRect().height + PAD * 2 > band;
87
+ const visible = inView(element, { top: insetTop, bottom: insetBottom });
88
+ element.style.scrollMarginTop = `${insetTop + PAD}px`;
89
+ element.scrollIntoView({
90
+ block: visible ? "nearest" : tall ? "start" : "center",
91
+ behavior: "smooth",
92
+ inline: "nearest"
93
+ });
94
+ }
59
95
  remeasure();
60
96
  const settle = setTimeout(remeasure, 400);
61
- return () => clearTimeout(settle);
62
- }, [open, current, index, available, remeasure, onClose]);
97
+ return () => {
98
+ clearTimeout(settle);
99
+ element.style.scrollMarginTop = "";
100
+ };
101
+ }, [open, currentId, scrolls, index, available, insetTop, insetBottom, remeasure, onClose]);
102
+ const progressRef = useRef(onProgress);
103
+ useLayoutEffect(() => {
104
+ progressRef.current = onProgress;
105
+ });
63
106
  useEffect(() => {
107
+ var _a2;
64
108
  if (!open || !current) return;
65
- onProgress == null ? void 0 : onProgress(index + 1, available.length, current);
66
- }, [open, current, index, available.length, onProgress]);
109
+ (_a2 = progressRef.current) == null ? void 0 : _a2.call(progressRef, index + 1, available.length, current);
110
+ }, [open, currentId, index, available.length]);
67
111
  useEffect(() => {
68
112
  if (!open) return;
69
113
  window.addEventListener("resize", remeasure);
@@ -73,73 +117,105 @@ function CoachMarks({ open, stops, onClose, onProgress, labels, className }) {
73
117
  if (event.key === "ArrowRight") setIndex((at) => Math.min(at + 1, available.length - 1));
74
118
  if (event.key === "ArrowLeft") setIndex((at) => Math.max(at - 1, 0));
75
119
  };
120
+ const onClick = (event) => {
121
+ const target = currentId ? findTarget(currentId) : null;
122
+ if (target && event.target instanceof Node && target.contains(event.target)) onClose();
123
+ };
76
124
  window.addEventListener("keydown", onKey);
125
+ window.addEventListener("click", onClick, true);
77
126
  return () => {
78
127
  window.removeEventListener("resize", remeasure);
79
128
  window.removeEventListener("scroll", remeasure, true);
80
129
  window.removeEventListener("keydown", onKey);
130
+ window.removeEventListener("click", onClick, true);
81
131
  };
82
- }, [open, remeasure, onClose, available.length]);
132
+ }, [open, currentId, remeasure, onClose, available.length]);
83
133
  if (!open || !current || !rect) return null;
84
134
  const last = index === available.length - 1;
135
+ const region = current.kind === "region";
85
136
  const spot = {
86
137
  top: rect.top - PAD,
87
138
  left: rect.left - PAD,
88
139
  width: rect.width + PAD * 2,
89
140
  height: rect.height + PAD * 2
90
141
  };
91
- const roomBelow = viewport.height - (spot.top + spot.height);
142
+ const small = rect.width <= PIN_SNAP || rect.height <= PIN_SNAP;
143
+ const pinLeft = small ? spot.left : rect.left - 2;
144
+ const pinTop = spot.top - PIN_SIZE / 2 < insetTop + EDGE ? spot.top + spot.height : small ? spot.top : rect.top - 2;
145
+ const bandTop = insetTop;
146
+ const bandBottom = viewport.height - insetBottom;
147
+ const roomBelow = bandBottom - (spot.top + spot.height);
148
+ const roomAbove = spot.top - bandTop;
92
149
  const calloutLeft = Math.min(
93
150
  Math.max(spot.left, EDGE),
94
151
  Math.max(EDGE, viewport.width - CALLOUT_WIDTH - EDGE)
95
152
  );
96
- const calloutPlace = roomBelow > CALLOUT_ROOM ? { top: spot.top + spot.height + CALLOUT_GAP, left: calloutLeft } : spot.top > CALLOUT_ROOM ? { bottom: viewport.height - spot.top + CALLOUT_GAP, left: calloutLeft } : { bottom: EDGE, left: calloutLeft };
153
+ const side = roomBelow > CALLOUT_ROOM ? "below" : roomAbove > CALLOUT_ROOM ? "above" : "edge";
154
+ const calloutPlace = side === "below" ? { top: spot.top + spot.height + CALLOUT_GAP, left: calloutLeft } : side === "above" ? { bottom: viewport.height - spot.top + CALLOUT_GAP, left: calloutLeft } : { bottom: viewport.height - bandBottom + EDGE, left: calloutLeft };
155
+ const calloutWidth = Math.min(CALLOUT_WIDTH, viewport.width - EDGE * 2);
156
+ const caretLeft = Math.min(
157
+ Math.max(spot.left + spot.width / 2 - calloutLeft, CARET_MARGIN),
158
+ calloutWidth - CARET_MARGIN
159
+ );
97
160
  return createPortal(
98
161
  /* @__PURE__ */ jsxs(
99
162
  "div",
100
163
  {
101
- className: cn("fixed inset-0 z-[80]", className),
164
+ className: cn("pointer-events-none fixed inset-0 z-[1100]", className),
102
165
  role: "dialog",
103
166
  "aria-modal": true,
104
167
  "aria-label": current.title,
105
168
  children: [
106
169
  /* @__PURE__ */ jsx(
107
- "button",
170
+ "div",
108
171
  {
109
- type: "button",
110
- "aria-label": labels.skip,
111
- onClick: onClose,
112
- className: "absolute inset-0 cursor-default"
172
+ "aria-hidden": true,
173
+ className: "pointer-events-auto absolute inset-0",
174
+ style: { clipPath: dimClip(spot, viewport) }
113
175
  }
114
176
  ),
115
177
  /* @__PURE__ */ jsx(
116
178
  "div",
117
179
  {
118
180
  "aria-hidden": true,
119
- 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",
181
+ className: cn(
182
+ "pointer-events-none absolute shadow-[0_0_0_9999px_oklch(0.16_0.012_55_/_0.55)] transition-[top,left,width,height] duration-300 motion-reduce:transition-none",
183
+ region ? "rounded-2xl" : "rounded-[18px] ring-2 ring-[oklch(0.82_0.15_85)]"
184
+ ),
120
185
  style: spot
121
186
  }
122
187
  ),
123
- /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: PIN, style: { top: rect.top - 2, left: rect.left - 2 }, children: index + 1 }),
188
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, className: PIN, style: { top: pinTop, left: pinLeft }, children: index + 1 }),
124
189
  /* @__PURE__ */ jsxs(
125
190
  "div",
126
191
  {
127
- className: "absolute z-[2] w-[min(320px,calc(100vw-24px))] rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-xl",
192
+ className: "pointer-events-auto absolute z-[2] w-[min(320px,calc(100vw-24px))] rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-[var(--shadow-overlay)]",
128
193
  style: calloutPlace,
129
194
  children: [
130
- /* @__PURE__ */ jsxs("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)]", children: [
195
+ side !== "edge" && /* @__PURE__ */ jsx(
196
+ "span",
197
+ {
198
+ "aria-hidden": true,
199
+ className: cn(
200
+ "absolute h-3 w-3 -translate-x-1/2 rotate-45 border-border bg-card",
201
+ side === "below" ? "-top-[7px] border-l border-t" : "-bottom-[7px] border-b border-r"
202
+ ),
203
+ style: { left: caretLeft }
204
+ }
205
+ ),
206
+ /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-full bg-[oklch(0.82_0.15_85)] px-2.5 py-1 text-xs font-extrabold uppercase tracking-[0.08em] text-[oklch(0.26_0.03_60)]", children: [
131
207
  /* @__PURE__ */ jsx(Lightbulb, { className: "h-3 w-3", "aria-hidden": true }),
132
208
  labels.progress(index + 1, available.length)
133
209
  ] }),
134
- /* @__PURE__ */ jsx("p", { className: "mt-3 text-[15px] font-bold leading-snug", children: current.title }),
210
+ /* @__PURE__ */ jsx("p", { className: "mt-3 text-base font-semibold leading-snug", children: current.title }),
135
211
  /* @__PURE__ */ jsx("p", { className: "mt-1.5 text-sm leading-relaxed text-muted-foreground", children: current.body }),
136
212
  /* @__PURE__ */ jsxs("div", { className: "mt-4 flex items-center gap-2", children: [
137
- index > 0 && /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIndex(index - 1), children: labels.back }),
213
+ index > 0 && /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "pill", onClick: () => setIndex(index - 1), children: labels.back }),
138
214
  /* @__PURE__ */ jsxs(
139
215
  Button,
140
216
  {
141
217
  type: "button",
142
- size: "sm",
218
+ size: "pill",
143
219
  onClick: () => last ? onClose() : setIndex(index + 1),
144
220
  children: [
145
221
  last ? labels.done : labels.next,
@@ -147,7 +223,17 @@ function CoachMarks({ open, stops, onClose, onProgress, labels, className }) {
147
223
  ]
148
224
  }
149
225
  ),
150
- /* @__PURE__ */ jsx(Button, { type: "button", variant: "link", size: "sm", className: "ml-auto", onClick: onClose, children: labels.skip })
226
+ /* @__PURE__ */ jsx(
227
+ Button,
228
+ {
229
+ type: "button",
230
+ variant: "link",
231
+ size: "sm",
232
+ className: "ml-auto min-h-11",
233
+ onClick: onClose,
234
+ children: labels.skip
235
+ }
236
+ )
151
237
  ] })
152
238
  ]
153
239
  }
@@ -39,22 +39,25 @@ type MenuFilterChipsProps = {
39
39
  onToggle: (key: string) => void;
40
40
  /** The unit and the current total, composed by the caller, e.g. "KITCHENS · 247". */
41
41
  unitLabel?: string;
42
- /** Opens the full filter sheet. Omit for a row where every facet is a chip. */
42
+ /** Opens the full filter panel. Omit for a row where every facet is a chip. */
43
43
  onMore?: () => void;
44
44
  moreLabel?: string;
45
45
  /**
46
- * A glyph for the phone's icon-only "more filters" button.
46
+ * The glyph on the "more filters" pill.
47
47
  *
48
- * The label is the widest fixed element in the row — "More filters" is ~80px
49
- * of a 358px viewport, and it is the one thing that never scrolls, so it eats
50
- * the chips' runway on every phone. Given an icon, the button collapses to a
51
- * square below `lg` and keeps `moreLabel` as its accessible name.
48
+ * The pill is a chip like the others in the row — same border, same height —
49
+ * so the way out of the row reads as part of the row, not as a link beside
50
+ * it. The label is the widest fixed element in the row ("More filters" is
51
+ * ~80px of a 358px viewport) and the one thing that never scrolls, so below
52
+ * `lg` the pill collapses to a square carrying only the glyph, with
53
+ * `moreLabel` as its accessible name; from `lg` it shows both.
52
54
  *
53
55
  * A node rather than a name because the app owns its iconography, and this
54
56
  * one is animated on press — the DS neither knows the glyph nor drives it.
57
+ * Without a glyph the pill shows the label at every width.
55
58
  */
56
59
  moreIcon?: React.ReactNode;
57
- /** Press handler for the icon button, e.g. to replay the glyph's animation. */
60
+ /** Press handler for the pill, e.g. to replay the glyph's animation. */
58
61
  onMorePointerDown?: () => void;
59
62
  /** Locale-aware integer formatter for the counts. Defaults to `String`. */
60
63
  formatCount?: (value: number) => string;
@@ -3,7 +3,7 @@ import { Skeleton } from '../chunk-XBBEX4SF.js';
3
3
  import { cn } from '../chunk-FEK5XGZW.js';
4
4
  import '../chunk-MMUBH76A.js';
5
5
  import { X } from 'lucide-react';
6
- import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
+ import { jsxs, jsx } from 'react/jsx-runtime';
7
7
 
8
8
  function MenuFilterChips({
9
9
  chips,
@@ -39,36 +39,28 @@ function MenuFilterChips({
39
39
  )) })
40
40
  }
41
41
  ),
42
- onMore && /* @__PURE__ */ jsxs(Fragment, { children: [
43
- moreIcon && /* @__PURE__ */ jsx(
44
- "button",
45
- {
46
- type: "button",
47
- onClick: onMore,
48
- onPointerDown: onMorePointerDown,
49
- "aria-label": moreLabel,
50
- "aria-haspopup": "dialog",
51
- className: cn(
52
- "inline-flex shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden",
53
- // The one control that never scrolls keeps the chips' height.
54
- chipSize === "lg" ? "size-11" : "size-10"
55
- ),
56
- children: moreIcon
57
- }
58
- ),
59
- /* @__PURE__ */ jsx(
60
- "button",
61
- {
62
- type: "button",
63
- onClick: onMore,
64
- className: cn(
65
- "shrink-0 rounded-md pl-2 text-[12.5px] font-bold text-accent transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
66
- moreIcon && "hidden lg:inline"
67
- ),
68
- children: moreLabel
69
- }
70
- )
71
- ] })
42
+ onMore && /* @__PURE__ */ jsxs(
43
+ "button",
44
+ {
45
+ type: "button",
46
+ onClick: onMore,
47
+ onPointerDown: onMorePointerDown,
48
+ "aria-label": moreLabel,
49
+ "aria-haspopup": "dialog",
50
+ className: cn(
51
+ // The chip's own chrome, so the pinned control and the scrolling
52
+ // chips read as one row. `shrink-0` against the scrolling chips.
53
+ "inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border border-border bg-card text-sm text-foreground transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
54
+ chipSize === "lg" ? "h-11" : "h-9",
55
+ // With a glyph: a square below `lg`, the glyph and the label from it.
56
+ moreIcon ? chipSize === "lg" ? "w-11 lg:w-auto lg:px-4" : "w-9 lg:w-auto lg:px-4" : "px-4"
57
+ ),
58
+ children: [
59
+ moreIcon,
60
+ /* @__PURE__ */ jsx("span", { className: cn(moreIcon && "hidden lg:inline"), children: moreLabel })
61
+ ]
62
+ }
63
+ )
72
64
  ] });
73
65
  }
74
66
  function Chip({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bearmenu/ui",
3
- "version": "0.8.20",
3
+ "version": "0.8.22",
4
4
  "description": "BearMenu design system — shared UI tokens, primitives, and components",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -12,7 +12,7 @@ const meta: Meta<typeof CoachMarks> = {
12
12
  docs: {
13
13
  description: {
14
14
  component:
15
- "A guided tour drawn over the real page: the page dims, the current step's target is cut out in a spotlight with its step number on the corner, one callout says what the target is. Targets are `data-coach-mark` attributes on the page's own elements.",
15
+ "A guided tour drawn over the real page: the page dims, the current step's target is cut out in a spotlight with its step number on the corner, one callout says what the target is. Targets are `data-coach-mark` attributes on the page's own elements. The spotlight is a hole in the dimming: a tap on the ringed control reaches it and ends the tour. A `region` stop (a list, a rail) is cut out without the ring; a pinned control passes `scroll: false`; `inset` names the page's fixed chrome.",
16
16
  },
17
17
  },
18
18
  },
@@ -37,14 +37,23 @@ const STOPS: CoachMarkStop[] = [
37
37
  title: "Cards, or rows",
38
38
  body: "The same menu items either way: rows fit three times as many.",
39
39
  },
40
+ {
41
+ id: "results",
42
+ kind: "region",
43
+ title: "What matched",
44
+ body: "A region is cut out without the ring; tapping a row inside it navigates, and the tour steps out of the way.",
45
+ },
40
46
  ];
41
47
 
42
48
  function Demo() {
43
49
  const [open, setOpen] = useState(true);
50
+ const [taps, setTaps] = useState(0);
44
51
  return (
45
52
  <div className="min-h-[720px] bg-background p-8 text-foreground">
46
53
  <div className="flex items-center gap-3">
47
- <Button data-coach-mark="search">Search and filter</Button>
54
+ <Button data-coach-mark="search" onClick={() => setTaps((count) => count + 1)}>
55
+ Search and filter{taps > 0 ? ` · tapped ${taps}` : ""}
56
+ </Button>
48
57
  <Button variant="outline" data-coach-mark="view">
49
58
  Rows
50
59
  </Button>
@@ -57,9 +66,16 @@ function Demo() {
57
66
  <span>Centru ×</span>
58
67
  </div>
59
68
  <p className="mt-10 max-w-[60ch] text-sm text-muted-foreground">
60
- The page beneath the tour. Nothing here re-renders; the overlay measures the three targets
61
- and draws over them.
69
+ The page beneath the tour. Nothing here re-renders; the overlay measures the targets and
70
+ draws over them. Tap the spotlit button: the tap lands, and the tour ends.
62
71
  </p>
72
+ <ul data-coach-mark="results" className="mt-6 max-w-md divide-y divide-border rounded-2xl border border-border">
73
+ {["Papanași", "Ciorbă de burtă", "Sarmale"].map((name) => (
74
+ <li key={name} className="px-4 py-3 text-sm">
75
+ {name}
76
+ </li>
77
+ ))}
78
+ </ul>
63
79
  <CoachMarks
64
80
  open={open}
65
81
  stops={STOPS}
@@ -1,4 +1,5 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { useState } from "react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
3
  import { fireEvent, render, screen } from "@testing-library/react";
3
4
  import { CoachMarks, type CoachMarkStop } from "./coach-marks";
4
5
 
@@ -55,8 +56,10 @@ function pins(): string[] {
55
56
  }
56
57
 
57
58
  // jsdom has no layout: every rect is zero, which is enough to render. A
58
- // target has boxes when it is in the document, none when it is not.
59
- Element.prototype.scrollIntoView = vi.fn();
59
+ // target has boxes when it is in the document, none when it is not. The
60
+ // scroll mock sits on HTMLElement, where the test setup's stub is.
61
+ const scrollIntoView = vi.fn();
62
+ HTMLElement.prototype.scrollIntoView = scrollIntoView;
60
63
  Element.prototype.getClientRects = function () {
61
64
  return (this.isConnected ? [{}] : []) as unknown as DOMRectList;
62
65
  };
@@ -121,13 +124,185 @@ describe("CoachMarks", () => {
121
124
  expect(onClose).not.toHaveBeenCalled();
122
125
  });
123
126
 
124
- it("Skip, Escape and the dimmed page all close", () => {
127
+ it("Skip and Escape close; the dimmed page does not", () => {
125
128
  const onClose = vi.fn();
126
129
  render(<Page onClose={onClose} />);
127
130
  fireEvent.click(screen.getByText("Skip"));
128
131
  fireEvent.keyDown(window, { key: "Escape" });
129
- fireEvent.click(screen.getAllByRole("button", { name: "Skip" })[0]);
130
- expect(onClose).toHaveBeenCalledTimes(3);
132
+ expect(onClose).toHaveBeenCalledTimes(2);
133
+ expect(screen.getAllByRole("button", { name: "Skip" })).toHaveLength(1);
134
+ const dim = screen.getByRole("dialog").firstElementChild;
135
+ fireEvent.click(dim as Element);
136
+ expect(onClose).toHaveBeenCalledTimes(2);
137
+ });
138
+
139
+ it("a tap on the spotlit target reaches it and ends the tour", () => {
140
+ const onClose = vi.fn();
141
+ const onTarget = vi.fn();
142
+ render(
143
+ <>
144
+ <button type="button" data-coach-mark="a" onClick={onTarget}>
145
+ a
146
+ </button>
147
+ <button type="button" data-coach-mark="b">
148
+ b
149
+ </button>
150
+ <CoachMarks open stops={STOPS.slice(0, 2)} onClose={onClose} labels={labels} />
151
+ </>
152
+ );
153
+ fireEvent.click(screen.getByRole("button", { name: "b" }));
154
+ expect(onClose).not.toHaveBeenCalled();
155
+ fireEvent.click(screen.getByRole("button", { name: "a" }));
156
+ expect(onTarget).toHaveBeenCalledTimes(1);
157
+ expect(onClose).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it("hears a tap on a target that stops propagation", () => {
161
+ const onClose = vi.fn();
162
+ render(
163
+ <>
164
+ <div data-coach-mark="a">
165
+ <button type="button" onClick={(event) => event.stopPropagation()}>
166
+ inner
167
+ </button>
168
+ </div>
169
+ <CoachMarks open stops={STOPS.slice(0, 1)} onClose={onClose} labels={labels} />
170
+ </>
171
+ );
172
+ fireEvent.click(screen.getByRole("button", { name: "inner" }));
173
+ expect(onClose).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ it("settles when the consumer rebuilds its stops and inset on every render", () => {
177
+ const onProgress = vi.fn();
178
+ function Consumer() {
179
+ const [progress, setProgress] = useState<string>("");
180
+ return (
181
+ <>
182
+ <button type="button" data-coach-mark="a">
183
+ a
184
+ </button>
185
+ <span data-testid="progress">{progress}</span>
186
+ <CoachMarks
187
+ open
188
+ stops={STOPS.slice(0, 1).map((stop) => ({ ...stop }))}
189
+ inset={{ top: 68 }}
190
+ onClose={vi.fn()}
191
+ onProgress={(step, total, stop) => {
192
+ onProgress(step, total, stop);
193
+ setProgress(`${step}/${total}`);
194
+ }}
195
+ labels={labels}
196
+ />
197
+ </>
198
+ );
199
+ }
200
+ render(<Consumer />);
201
+ expect(screen.getByTestId("progress")).toHaveTextContent("1/1");
202
+ expect(onProgress).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it("a region is cut out without the control's ring", () => {
206
+ const { rerender } = render(<Page onClose={vi.fn()} />);
207
+ const spotlight = () => screen.getByRole("dialog").children[1];
208
+ expect(spotlight().className).toContain("ring-2");
209
+ rerender(<Page onClose={vi.fn()} stops={STOPS.map((stop) => ({ ...stop, kind: "region" }))} />);
210
+ expect(spotlight().className).not.toContain("ring-2");
211
+ });
212
+
213
+ describe("scrolling to the target", () => {
214
+ /** A page whose targets sit at `top`, `height` 40, in an 800px viewport. */
215
+ function placeTargets(top: number) {
216
+ vi.spyOn(window, "innerHeight", "get").mockReturnValue(800);
217
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
218
+ top,
219
+ bottom: top + 40,
220
+ left: 0,
221
+ right: 100,
222
+ width: 100,
223
+ height: 40,
224
+ x: 0,
225
+ y: top,
226
+ toJSON: () => ({}),
227
+ });
228
+ }
229
+
230
+ beforeEach(() => scrollIntoView.mockClear());
231
+ afterEach(() => vi.restoreAllMocks());
232
+
233
+ it("centres a target out of view", () => {
234
+ placeTargets(900);
235
+ render(<Page onClose={vi.fn()} />);
236
+ expect(scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ block: "center" }));
237
+ });
238
+
239
+ it("leaves the page where it is when the target is already in view", () => {
240
+ placeTargets(300);
241
+ render(<Page onClose={vi.fn()} />);
242
+ // Still a request — it aborts the previous stop's smooth scroll — but
243
+ // one that moves nothing.
244
+ expect(scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ block: "nearest" }));
245
+ });
246
+
247
+ it("aligns a target taller than the band to its top, clear of the chrome", () => {
248
+ vi.spyOn(window, "innerHeight", "get").mockReturnValue(800);
249
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({
250
+ top: 900,
251
+ bottom: 2400,
252
+ left: 0,
253
+ right: 100,
254
+ width: 100,
255
+ height: 1500,
256
+ x: 0,
257
+ y: 900,
258
+ toJSON: () => ({}),
259
+ });
260
+ render(
261
+ <>
262
+ <button type="button" data-coach-mark="a">
263
+ a
264
+ </button>
265
+ <CoachMarks
266
+ open
267
+ stops={STOPS.slice(0, 1)}
268
+ onClose={vi.fn()}
269
+ labels={labels}
270
+ inset={{ top: 68 }}
271
+ />
272
+ </>
273
+ );
274
+ expect(scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ block: "start" }));
275
+ expect(screen.getByRole("button", { name: "a" }).style.scrollMarginTop).toBe("76px");
276
+ });
277
+
278
+ it("counts the fixed chrome as out of view", () => {
279
+ placeTargets(20);
280
+ const { unmount } = render(<Page onClose={vi.fn()} />);
281
+ expect(scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ block: "nearest" }));
282
+ unmount();
283
+ scrollIntoView.mockClear();
284
+ render(
285
+ <>
286
+ <button type="button" data-coach-mark="a">
287
+ a
288
+ </button>
289
+ <CoachMarks
290
+ open
291
+ stops={STOPS.slice(0, 1)}
292
+ onClose={vi.fn()}
293
+ labels={labels}
294
+ inset={{ top: 68 }}
295
+ />
296
+ </>
297
+ );
298
+ expect(scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ block: "center" }));
299
+ });
300
+
301
+ it("never scrolls a stop that opted out — a pinned control", () => {
302
+ placeTargets(900);
303
+ render(<Page onClose={vi.fn()} stops={STOPS.map((stop) => ({ ...stop, scroll: false }))} />);
304
+ expect(scrollIntoView).not.toHaveBeenCalled();
305
+ });
131
306
  });
132
307
 
133
308
  it("closes at once when no stop has a target, and renders nothing while closed", () => {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useLayoutEffect, useState } from "react";
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { ArrowRight, Lightbulb } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
@@ -31,9 +31,26 @@ import { Button } from "./button";
31
31
  * side, re-measure on scroll and resize) and the keys (Escape closes, the
32
32
  * arrows step). `onProgress` reports the current stop for a toolbar label.
33
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.
34
+ * The spotlight is a hole in the dimming, in hit-testing as well as in
35
+ * paint: a tap on the ringed control reaches it. A gold ring around a real,
36
+ * un-dimmed control is an invitation, and a tour that swallowed the tap
37
+ * would be lying. Acting ends the tour — the thing the reader opened is the
38
+ * lesson, and whatever it opened would otherwise sit under or over an
39
+ * `aria-modal` dialog that promises the page beneath is inert. The consumer
40
+ * has already heard the stop through `onProgress`, so a "N new" door can
41
+ * pick the tour up later. The dimmed page itself does not close the tour: a
42
+ * thumb resting on it while reading is not a decision.
43
+ *
44
+ * A target is scrolled into view only when it is not already in it. The
45
+ * page's fixed chrome (`inset`) narrows what "in view" means, and a stop can
46
+ * opt out of scrolling altogether (`scroll: false`): `scrollIntoView` on a
47
+ * pinned rail scrolls the document to the rail's in-flow position, which is
48
+ * the top of whatever it pins over, and a reader mid-feed loses their place.
49
+ *
50
+ * Rendered into `document.body` above every drawer, sheet and dialog: a page
51
+ * wrapper with an opacity or transform transition is a stacking context, and
52
+ * the tour must sit above everything the page and its chrome draw, whatever
53
+ * wraps the consumer.
37
54
  */
38
55
 
39
56
  export type CoachMarkStop = {
@@ -41,6 +58,25 @@ export type CoachMarkStop = {
41
58
  id: string;
42
59
  title: string;
43
60
  body: string;
61
+ /**
62
+ * A `control` (a button, a field, a toggle) wears the gold ring; a `region`
63
+ * (a list, a rail, a block) is defined by the cut-out's edge alone. Stated,
64
+ * not inferred: a toggle group is a div of buttons and a results region a
65
+ * list of links. Defaults to `control`.
66
+ */
67
+ kind?: "control" | "region";
68
+ /**
69
+ * Never scroll to this target — for a control pinned to the viewport, whose
70
+ * box says where it is but whose in-flow position is somewhere else.
71
+ * Defaults to scrolling when the target is out of view.
72
+ */
73
+ scroll?: boolean;
74
+ };
75
+
76
+ /** The viewport's fixed chrome: what a target must clear to count as in view. */
77
+ export type CoachMarksInset = {
78
+ top?: number;
79
+ bottom?: number;
44
80
  };
45
81
 
46
82
  export type CoachMarksLabels = {
@@ -56,7 +92,7 @@ export type CoachMarksLabels = {
56
92
  export type CoachMarksProps = {
57
93
  open: boolean;
58
94
  stops: CoachMarkStop[];
59
- /** Skip, Done, Escape and a tap on the dimmed page all end here. */
95
+ /** Skip, Done, Escape and a tap on the spotlit target all end here. */
60
96
  onClose: () => void;
61
97
  /**
62
98
  * 1-based position, the count of stops the page actually has, and the
@@ -65,6 +101,8 @@ export type CoachMarksProps = {
65
101
  */
66
102
  onProgress?: (step: number, total: number, stop: CoachMarkStop) => void;
67
103
  labels: CoachMarksLabels;
104
+ /** The page's fixed header and bottom bar, in px; the callout keeps clear of them too. */
105
+ inset?: CoachMarksInset;
68
106
  className?: string;
69
107
  };
70
108
 
@@ -76,11 +114,28 @@ const CALLOUT_GAP = 14;
76
114
  /** The room a side needs before the callout goes there. */
77
115
  const CALLOUT_ROOM = 280;
78
116
  const EDGE = 12;
117
+ /** The pin's disc. */
118
+ const PIN_SIZE = 32;
119
+ /** A target this small has its pin on the spotlight's corner, not over its own. */
120
+ const PIN_SNAP = 56;
121
+ /** The caret's inset from the callout's ends. */
122
+ const CARET_MARGIN = 16;
79
123
 
80
124
  /** The step number, an inverted disc on the spotlight's corner: dark on the gold ring. */
81
125
  const PIN =
82
126
  "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
127
 
128
+ /**
129
+ * The dimming with the spotlight cut out of it: one polygon drawn around the
130
+ * viewport and again around the spot, even-odd, so the spot is a hole in
131
+ * hit-testing as much as in paint.
132
+ */
133
+ function dimClip(spot: Rect, viewport: { width: number; height: number }): string {
134
+ const right = spot.left + spot.width;
135
+ const bottom = spot.top + spot.height;
136
+ return `polygon(evenodd, 0 0, ${viewport.width}px 0, ${viewport.width}px ${viewport.height}px, 0 ${viewport.height}px, 0 0, ${spot.left}px ${spot.top}px, ${spot.left}px ${bottom}px, ${right}px ${bottom}px, ${right}px ${spot.top}px, ${spot.left}px ${spot.top}px)`;
137
+ }
138
+
84
139
  /** The visible element carrying the id — a `display: none` twin has no boxes. */
85
140
  function findTarget(id: string): HTMLElement | null {
86
141
  if (typeof document === "undefined") return null;
@@ -96,11 +151,31 @@ function measure(element: HTMLElement): Rect {
96
151
  return { top: box.top, left: box.left, width: box.width, height: box.height };
97
152
  }
98
153
 
99
- export function CoachMarks({ open, stops, onClose, onProgress, labels, className }: CoachMarksProps) {
154
+ /** Whole target inside the band the chrome leaves, with its breathing room. */
155
+ function inView(element: HTMLElement, inset: CoachMarksInset): boolean {
156
+ const box = element.getBoundingClientRect();
157
+ return (
158
+ box.top - PAD >= (inset.top ?? 0) &&
159
+ box.bottom + PAD <= window.innerHeight - (inset.bottom ?? 0)
160
+ );
161
+ }
162
+
163
+ const NO_INSET: CoachMarksInset = {};
164
+
165
+ export function CoachMarks({
166
+ open,
167
+ stops,
168
+ onClose,
169
+ onProgress,
170
+ labels,
171
+ inset = NO_INSET,
172
+ className,
173
+ }: CoachMarksProps) {
100
174
  // The stops the page can show, fixed when the tour opens or the itinerary
101
175
  // 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[]>([]);
176
+ // not send the tour back to the first stop. The words are read live from
177
+ // `stops`, so a consumer may reword a stop without restarting the tour.
178
+ const [available, setAvailable] = useState<string[]>([]);
104
179
  const [index, setIndex] = useState(0);
105
180
  const [rect, setRect] = useState<Rect | null>(null);
106
181
  const [viewport, setViewport] = useState({ width: 0, height: 0 });
@@ -108,7 +183,7 @@ export function CoachMarks({ open, stops, onClose, onProgress, labels, className
108
183
 
109
184
  useLayoutEffect(() => {
110
185
  if (!open) return;
111
- const present = stops.filter((stop) => findTarget(stop.id));
186
+ const present = stops.filter((stop) => findTarget(stop.id)).map((stop) => stop.id);
112
187
  setAvailable(present);
113
188
  setIndex(0);
114
189
  if (present.length === 0) onClose();
@@ -116,8 +191,13 @@ export function CoachMarks({ open, stops, onClose, onProgress, labels, className
116
191
  // eslint-disable-next-line react-hooks/exhaustive-deps
117
192
  }, [open, itinerary]);
118
193
 
119
- const current = available[index];
120
- const currentId = current?.id;
194
+ const currentId = available[index];
195
+ const current = currentId ? stops.find((stop) => stop.id === currentId) : undefined;
196
+ const scrolls = current?.scroll !== false;
197
+ // Primitives, so a consumer passing `inset={{ top: 68 }}` inline — or
198
+ // rebuilding `stops` each render — does not re-run the effects below.
199
+ const insetTop = inset.top ?? 0;
200
+ const insetBottom = inset.bottom ?? 0;
121
201
 
122
202
  const remeasure = useCallback(() => {
123
203
  const element = currentId ? findTarget(currentId) : null;
@@ -125,28 +205,54 @@ export function CoachMarks({ open, stops, onClose, onProgress, labels, className
125
205
  setViewport({ width: window.innerWidth, height: window.innerHeight });
126
206
  }, [currentId]);
127
207
 
128
- // Bring the current target into view, then measure it — again once the
129
- // smooth scroll has settled.
208
+ // Bring the current target into view when it is not, then measure it —
209
+ // again once the smooth scroll has settled.
130
210
  useLayoutEffect(() => {
131
- if (!open || !current) return;
132
- const element = findTarget(current.id);
211
+ if (!open || !currentId) return;
212
+ const element = findTarget(currentId);
133
213
  if (!element) {
134
214
  // 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));
215
+ const forward = available.findIndex((id, at) => at > index && findTarget(id));
136
216
  if (forward !== -1) setIndex(forward);
137
217
  else onClose();
138
218
  return;
139
219
  }
140
- element.scrollIntoView({ block: "center", behavior: "smooth", inline: "nearest" });
220
+ if (scrolls) {
221
+ // Always a scroll request, even for a target already in view: a new
222
+ // request aborts the previous stop's smooth scroll, which would
223
+ // otherwise carry on under this one and leave it off screen. A target
224
+ // taller than the band aligns to the band's top — centring a
225
+ // three-screen region scrolls into its middle — with `scroll-margin`
226
+ // standing in for the chrome, which `scrollIntoView` cannot see.
227
+ const band = window.innerHeight - insetTop - insetBottom;
228
+ const tall = element.getBoundingClientRect().height + PAD * 2 > band;
229
+ const visible = inView(element, { top: insetTop, bottom: insetBottom });
230
+ element.style.scrollMarginTop = `${insetTop + PAD}px`;
231
+ element.scrollIntoView({
232
+ block: visible ? "nearest" : tall ? "start" : "center",
233
+ behavior: "smooth",
234
+ inline: "nearest",
235
+ });
236
+ }
141
237
  remeasure();
142
238
  const settle = setTimeout(remeasure, 400);
143
- return () => clearTimeout(settle);
144
- }, [open, current, index, available, remeasure, onClose]);
239
+ return () => {
240
+ clearTimeout(settle);
241
+ element.style.scrollMarginTop = "";
242
+ };
243
+ }, [open, currentId, scrolls, index, available, insetTop, insetBottom, remeasure, onClose]);
145
244
 
245
+ // Reported once per stop — not once per rewording of it, nor once per
246
+ // consumer render when the callback is written inline.
247
+ const progressRef = useRef(onProgress);
248
+ useLayoutEffect(() => {
249
+ progressRef.current = onProgress;
250
+ });
146
251
  useEffect(() => {
147
252
  if (!open || !current) return;
148
- onProgress?.(index + 1, available.length, current);
149
- }, [open, current, index, available.length, onProgress]);
253
+ progressRef.current?.(index + 1, available.length, current);
254
+ // eslint-disable-next-line react-hooks/exhaustive-deps
255
+ }, [open, currentId, index, available.length]);
150
256
 
151
257
  useEffect(() => {
152
258
  if (!open) return;
@@ -157,91 +263,144 @@ export function CoachMarks({ open, stops, onClose, onProgress, labels, className
157
263
  if (event.key === "ArrowRight") setIndex((at) => Math.min(at + 1, available.length - 1));
158
264
  if (event.key === "ArrowLeft") setIndex((at) => Math.max(at - 1, 0));
159
265
  };
266
+ // The reader acted, and the tour steps out of the way. Capture phase: a
267
+ // control that stops propagation (a button nested in a larger tap
268
+ // target) would otherwise never be heard. The target's own handler still
269
+ // runs — closing the tour changes nothing under it.
270
+ const onClick = (event: MouseEvent) => {
271
+ const target = currentId ? findTarget(currentId) : null;
272
+ if (target && event.target instanceof Node && target.contains(event.target)) onClose();
273
+ };
160
274
  window.addEventListener("keydown", onKey);
275
+ window.addEventListener("click", onClick, true);
161
276
  return () => {
162
277
  window.removeEventListener("resize", remeasure);
163
278
  window.removeEventListener("scroll", remeasure, true);
164
279
  window.removeEventListener("keydown", onKey);
280
+ window.removeEventListener("click", onClick, true);
165
281
  };
166
- }, [open, remeasure, onClose, available.length]);
282
+ }, [open, currentId, remeasure, onClose, available.length]);
167
283
 
168
284
  if (!open || !current || !rect) return null;
169
285
 
170
286
  const last = index === available.length - 1;
287
+ const region = current.kind === "region";
171
288
  const spot = {
172
289
  top: rect.top - PAD,
173
290
  left: rect.left - PAD,
174
291
  width: rect.width + PAD * 2,
175
292
  height: rect.height + PAD * 2,
176
293
  };
294
+ // The pin sits on the spotlight's corner for a small target — over its own
295
+ // corner it would cover a quarter of a 44px disc — and drops to the bottom
296
+ // corner when the top one would sit under the page's chrome.
297
+ const small = rect.width <= PIN_SNAP || rect.height <= PIN_SNAP;
298
+ const pinLeft = small ? spot.left : rect.left - 2;
299
+ const pinTop =
300
+ spot.top - PIN_SIZE / 2 < insetTop + EDGE
301
+ ? spot.top + spot.height
302
+ : small
303
+ ? spot.top
304
+ : rect.top - 2;
177
305
  // 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);
306
+ // target taller than the band leaves room on neither side, and the callout
307
+ // then sits at the band's bottom edge, over the target's lower part. The
308
+ // band is the viewport less the page's fixed chrome.
309
+ const bandTop = insetTop;
310
+ const bandBottom = viewport.height - insetBottom;
311
+ const roomBelow = bandBottom - (spot.top + spot.height);
312
+ const roomAbove = spot.top - bandTop;
181
313
  const calloutLeft = Math.min(
182
314
  Math.max(spot.left, EDGE),
183
315
  Math.max(EDGE, viewport.width - CALLOUT_WIDTH - EDGE)
184
316
  );
317
+ const side: "below" | "above" | "edge" =
318
+ roomBelow > CALLOUT_ROOM ? "below" : roomAbove > CALLOUT_ROOM ? "above" : "edge";
185
319
  const calloutPlace =
186
- roomBelow > CALLOUT_ROOM
320
+ side === "below"
187
321
  ? { top: spot.top + spot.height + CALLOUT_GAP, left: calloutLeft }
188
- : spot.top > CALLOUT_ROOM
322
+ : side === "above"
189
323
  ? { bottom: viewport.height - spot.top + CALLOUT_GAP, left: calloutLeft }
190
- : { bottom: EDGE, left: calloutLeft };
324
+ : { bottom: viewport.height - bandBottom + EDGE, left: calloutLeft };
325
+ // The caret points at the spot's centre: a disc at the right edge leaves
326
+ // the callout 260px from its target with nothing between them otherwise.
327
+ const calloutWidth = Math.min(CALLOUT_WIDTH, viewport.width - EDGE * 2);
328
+ const caretLeft = Math.min(
329
+ Math.max(spot.left + spot.width / 2 - calloutLeft, CARET_MARGIN),
330
+ calloutWidth - CARET_MARGIN
331
+ );
191
332
 
192
333
  return createPortal(
193
334
  <div
194
- className={cn("fixed inset-0 z-[80]", className)}
335
+ className={cn("pointer-events-none fixed inset-0 z-[1100]", className)}
195
336
  role="dialog"
196
337
  aria-modal
197
338
  aria-label={current.title}
198
339
  >
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"
340
+ {/* The dimming: one layer blocking the page, with the spotlight cut out
341
+ of it, so the target stays fully visible AND tappable. The scrim is
342
+ drawn by the spotlight's shadow beneath. */}
343
+ <div
344
+ aria-hidden
345
+ className="pointer-events-auto absolute inset-0"
346
+ style={{ clipPath: dimClip(spot, viewport) }}
207
347
  />
208
348
  <div
209
349
  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"
350
+ className={cn(
351
+ "pointer-events-none absolute shadow-[0_0_0_9999px_oklch(0.16_0.012_55_/_0.55)] transition-[top,left,width,height] duration-300 motion-reduce:transition-none",
352
+ region ? "rounded-2xl" : "rounded-[18px] ring-2 ring-[oklch(0.82_0.15_85)]"
353
+ )}
211
354
  style={spot}
212
355
  />
213
356
 
214
357
  {/* 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 }}>
358
+ <span aria-hidden className={PIN} style={{ top: pinTop, left: pinLeft }}>
216
359
  {index + 1}
217
360
  </span>
218
361
 
219
362
  {/* The callout. */}
220
363
  <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"
364
+ className="pointer-events-auto absolute z-[2] w-[min(320px,calc(100vw-24px))] rounded-2xl border border-border bg-card p-4 text-card-foreground shadow-[var(--shadow-overlay)]"
222
365
  style={calloutPlace}
223
366
  >
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)]">
367
+ {side !== "edge" && (
368
+ <span
369
+ aria-hidden
370
+ className={cn(
371
+ "absolute h-3 w-3 -translate-x-1/2 rotate-45 border-border bg-card",
372
+ side === "below" ? "-top-[7px] border-l border-t" : "-bottom-[7px] border-b border-r"
373
+ )}
374
+ style={{ left: caretLeft }}
375
+ />
376
+ )}
377
+ <span className="inline-flex items-center gap-1.5 rounded-full bg-[oklch(0.82_0.15_85)] px-2.5 py-1 text-xs font-extrabold uppercase tracking-[0.08em] text-[oklch(0.26_0.03_60)]">
225
378
  <Lightbulb className="h-3 w-3" aria-hidden />
226
379
  {labels.progress(index + 1, available.length)}
227
380
  </span>
228
- <p className="mt-3 text-[15px] font-bold leading-snug">{current.title}</p>
381
+ <p className="mt-3 text-base font-semibold leading-snug">{current.title}</p>
229
382
  <p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{current.body}</p>
230
383
  <div className="mt-4 flex items-center gap-2">
231
384
  {index > 0 && (
232
- <Button type="button" variant="ghost" size="sm" onClick={() => setIndex(index - 1)}>
385
+ <Button type="button" variant="ghost" size="pill" onClick={() => setIndex(index - 1)}>
233
386
  {labels.back}
234
387
  </Button>
235
388
  )}
236
389
  <Button
237
390
  type="button"
238
- size="sm"
391
+ size="pill"
239
392
  onClick={() => (last ? onClose() : setIndex(index + 1))}
240
393
  >
241
394
  {last ? labels.done : labels.next}
242
395
  {!last && <ArrowRight aria-hidden />}
243
396
  </Button>
244
- <Button type="button" variant="link" size="sm" className="ml-auto" onClick={onClose}>
397
+ <Button
398
+ type="button"
399
+ variant="link"
400
+ size="sm"
401
+ className="ml-auto min-h-11"
402
+ onClick={onClose}
403
+ >
245
404
  {labels.skip}
246
405
  </Button>
247
406
  </div>
@@ -2,6 +2,7 @@ import { useState } from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
  import { Clock, Leaf, MapPin, Moon, Sun, Wallet } from "lucide-react";
4
4
  import { MenuFilterChips, MenuFilterSummary } from "./menu-filter-chips";
5
+ import { MotionSliders } from "./motion-icon-controls";
5
6
  import { mockFilterChips } from "./menu-mocks";
6
7
 
7
8
  const icons: Record<string, React.ReactNode> = {
@@ -30,7 +31,7 @@ const meta: Meta<typeof MenuFilterChips> = {
30
31
  docs: {
31
32
  description: {
32
33
  component:
33
- "Every chip counts KITCHENS, whatever facet is behind it — a dietary chip reads *has a vegetarian dish* and counts venues, not dishes. The unit is stated once at the head of the row and never again; the earlier version mixed venue counts and item counts and you could not tell what a number meant without re-reading the label that produced it. One line in every state: the chips scroll under a `ScrollFade`, and *More filters* is pinned opposite the unit label rather than wrapping.",
34
+ "Every chip counts KITCHENS, whatever facet is behind it — a dietary chip reads *has a vegetarian dish* and counts venues, not dishes. The unit is stated once at the head of the row and never again; the earlier version mixed venue counts and item counts and you could not tell what a number meant without re-reading the label that produced it. One line in every state: the chips scroll under a `ScrollFade`, and *More filters* is a chip-shaped pill pinned opposite the unit label rather than wrapping — a square carrying only its glyph below `lg`, glyph and label from it.",
34
35
  },
35
36
  },
36
37
  },
@@ -41,6 +42,7 @@ type Story = StoryObj<typeof MenuFilterChips>;
41
42
 
42
43
  function Interactive({ initial }: { initial: typeof chips }) {
43
44
  const [state, setState] = useState(initial);
45
+ const [replayToken, setReplayToken] = useState(0);
44
46
  const active = state.filter((chip) => chip.active).length;
45
47
 
46
48
  return (
@@ -49,6 +51,8 @@ function Interactive({ initial }: { initial: typeof chips }) {
49
51
  chips={state}
50
52
  unitLabel="KITCHENS · 247"
51
53
  moreLabel="More filters"
54
+ moreIcon={<MotionSliders replayToken={replayToken} className="size-4" />}
55
+ onMorePointerDown={() => setReplayToken((n) => n + 1)}
52
56
  onMore={() => {}}
53
57
  ariaLabel="Filter kitchens"
54
58
  onToggle={(key) =>
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { MenuFilterChips } from "./menu-filter-chips";
4
+ import { mockFilterChips } from "./menu-mocks";
5
+
6
+ describe("MenuFilterChips", () => {
7
+ /**
8
+ * The "more filters" control is ONE button at every width, shaped like the
9
+ * chips beside it. It used to be two elements — an icon square below `lg`
10
+ * and a bare text link from it — and the link read as a footnote rather
11
+ * than as the row's last chip.
12
+ */
13
+ it("renders the more control as a single chip-shaped pill carrying the glyph and the label", () => {
14
+ const onMore = vi.fn();
15
+ const onMorePointerDown = vi.fn();
16
+ render(
17
+ <MenuFilterChips
18
+ chips={mockFilterChips}
19
+ onToggle={() => {}}
20
+ moreLabel="More filters"
21
+ moreIcon={<svg data-testid="glyph" />}
22
+ onMore={onMore}
23
+ onMorePointerDown={onMorePointerDown}
24
+ />
25
+ );
26
+
27
+ const pills = screen.getAllByRole("button", { name: "More filters" });
28
+ expect(pills).toHaveLength(1);
29
+ const [pill] = pills;
30
+ expect(pill).toHaveAttribute("aria-haspopup", "dialog");
31
+ expect(pill.className).toContain("rounded-full");
32
+ expect(pill.className).toContain("border-border");
33
+ expect(pill.className).toContain("h-9");
34
+ expect(pill.className).toContain("lg:px-4");
35
+ expect(pill.querySelector("[data-testid='glyph']")).not.toBeNull();
36
+ expect(pill.querySelector("span")?.className).toContain("hidden lg:inline");
37
+
38
+ fireEvent.pointerDown(pill);
39
+ fireEvent.click(pill);
40
+ expect(onMorePointerDown).toHaveBeenCalledTimes(1);
41
+ expect(onMore).toHaveBeenCalledTimes(1);
42
+ });
43
+
44
+ it("shows the label at every width when no glyph is given", () => {
45
+ render(
46
+ <MenuFilterChips
47
+ chips={mockFilterChips}
48
+ onToggle={() => {}}
49
+ moreLabel="More filters"
50
+ onMore={() => {}}
51
+ />
52
+ );
53
+
54
+ const pill = screen.getByRole("button", { name: "More filters" });
55
+ expect(pill.className).toContain("px-4");
56
+ expect(pill.querySelector("span")?.className ?? "").not.toContain("hidden");
57
+ });
58
+
59
+ it("renders no more control without a handler", () => {
60
+ render(<MenuFilterChips chips={mockFilterChips} onToggle={() => {}} moreLabel="More filters" />);
61
+ expect(screen.queryByRole("button", { name: "More filters" })).toBeNull();
62
+ });
63
+ });
@@ -40,22 +40,25 @@ export type MenuFilterChipsProps = {
40
40
  onToggle: (key: string) => void;
41
41
  /** The unit and the current total, composed by the caller, e.g. "KITCHENS · 247". */
42
42
  unitLabel?: string;
43
- /** Opens the full filter sheet. Omit for a row where every facet is a chip. */
43
+ /** Opens the full filter panel. Omit for a row where every facet is a chip. */
44
44
  onMore?: () => void;
45
45
  moreLabel?: string;
46
46
  /**
47
- * A glyph for the phone's icon-only "more filters" button.
47
+ * The glyph on the "more filters" pill.
48
48
  *
49
- * The label is the widest fixed element in the row — "More filters" is ~80px
50
- * of a 358px viewport, and it is the one thing that never scrolls, so it eats
51
- * the chips' runway on every phone. Given an icon, the button collapses to a
52
- * square below `lg` and keeps `moreLabel` as its accessible name.
49
+ * The pill is a chip like the others in the row — same border, same height —
50
+ * so the way out of the row reads as part of the row, not as a link beside
51
+ * it. The label is the widest fixed element in the row ("More filters" is
52
+ * ~80px of a 358px viewport) and the one thing that never scrolls, so below
53
+ * `lg` the pill collapses to a square carrying only the glyph, with
54
+ * `moreLabel` as its accessible name; from `lg` it shows both.
53
55
  *
54
56
  * A node rather than a name because the app owns its iconography, and this
55
57
  * one is animated on press — the DS neither knows the glyph nor drives it.
58
+ * Without a glyph the pill shows the label at every width.
56
59
  */
57
60
  moreIcon?: React.ReactNode;
58
- /** Press handler for the icon button, e.g. to replay the glyph's animation. */
61
+ /** Press handler for the pill, e.g. to replay the glyph's animation. */
59
62
  onMorePointerDown?: () => void;
60
63
  /** Locale-aware integer formatter for the counts. Defaults to `String`. */
61
64
  formatCount?: (value: number) => string;
@@ -117,39 +120,24 @@ export function MenuFilterChips({
117
120
  </ScrollFade>
118
121
 
119
122
  {onMore && (
120
- <>
121
- {/* One control, two shapes. On a phone it is a 40px square carrying
122
- the caller's glyph, with `moreLabel` as its accessible name; from
123
- `lg` it is the text link it has always been. Two elements rather
124
- than one so neither has to carry the other's padding, and both
125
- stay `shrink-0` against the scrolling chips. */}
126
- {moreIcon && (
127
- <button
128
- type="button"
129
- onClick={onMore}
130
- onPointerDown={onMorePointerDown}
131
- aria-label={moreLabel}
132
- aria-haspopup="dialog"
133
- className={cn(
134
- "inline-flex shrink-0 items-center justify-center rounded-full border border-border text-accent transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none lg:hidden",
135
- // The one control that never scrolls keeps the chips' height.
136
- chipSize === "lg" ? "size-11" : "size-10"
137
- )}
138
- >
139
- {moreIcon}
140
- </button>
123
+ <button
124
+ type="button"
125
+ onClick={onMore}
126
+ onPointerDown={onMorePointerDown}
127
+ aria-label={moreLabel}
128
+ aria-haspopup="dialog"
129
+ className={cn(
130
+ // The chip's own chrome, so the pinned control and the scrolling
131
+ // chips read as one row. `shrink-0` against the scrolling chips.
132
+ "inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-full border border-border bg-card text-sm text-foreground transition-colors hover:border-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
133
+ chipSize === "lg" ? "h-11" : "h-9",
134
+ // With a glyph: a square below `lg`, the glyph and the label from it.
135
+ moreIcon ? (chipSize === "lg" ? "w-11 lg:w-auto lg:px-4" : "w-9 lg:w-auto lg:px-4") : "px-4"
141
136
  )}
142
- <button
143
- type="button"
144
- onClick={onMore}
145
- className={cn(
146
- "shrink-0 rounded-md pl-2 text-[12.5px] font-bold text-accent transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
147
- moreIcon && "hidden lg:inline"
148
- )}
149
- >
150
- {moreLabel}
151
- </button>
152
- </>
137
+ >
138
+ {moreIcon}
139
+ <span className={cn(moreIcon && "hidden lg:inline")}>{moreLabel}</span>
140
+ </button>
153
141
  )}
154
142
  </div>
155
143
  );