@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.
- package/dist/components/coach-marks.d.ts +43 -6
- package/dist/components/coach-marks.js +116 -30
- package/dist/components/menu-filter-chips.d.ts +10 -7
- package/dist/components/menu-filter-chips.js +23 -31
- package/package.json +1 -1
- package/src/components/coach-marks.stories.tsx +20 -4
- package/src/components/coach-marks.test.tsx +181 -6
- package/src/components/coach-marks.tsx +204 -45
- package/src/components/menu-filter-chips.stories.tsx +5 -1
- package/src/components/menu-filter-chips.test.tsx +63 -0
- package/src/components/menu-filter-chips.tsx +27 -39
|
@@ -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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
|
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
|
|
43
|
-
const
|
|
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 || !
|
|
51
|
-
const element = findTarget(
|
|
76
|
+
if (!open || !currentId) return;
|
|
77
|
+
const element = findTarget(currentId);
|
|
52
78
|
if (!element) {
|
|
53
|
-
const forward = available.findIndex((
|
|
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
|
-
|
|
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 () =>
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
}, [open,
|
|
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
|
|
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
|
|
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-[
|
|
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
|
-
"
|
|
170
|
+
"div",
|
|
108
171
|
{
|
|
109
|
-
|
|
110
|
-
"
|
|
111
|
-
|
|
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:
|
|
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:
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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: "
|
|
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: "
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
46
|
+
* The glyph on the "more filters" pill.
|
|
47
47
|
*
|
|
48
|
-
* The
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
@@ -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"
|
|
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
|
|
61
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
130
|
-
expect(
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
120
|
-
const
|
|
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 —
|
|
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 || !
|
|
132
|
-
const element = findTarget(
|
|
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((
|
|
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
|
-
|
|
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 () =>
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
179
|
-
//
|
|
180
|
-
|
|
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
|
-
|
|
320
|
+
side === "below"
|
|
187
321
|
? { top: spot.top + spot.height + CALLOUT_GAP, left: calloutLeft }
|
|
188
|
-
:
|
|
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-[
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
<
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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=
|
|
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:
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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="
|
|
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="
|
|
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
|
|
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
|
|
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
|
-
*
|
|
47
|
+
* The glyph on the "more filters" pill.
|
|
48
48
|
*
|
|
49
|
-
* The
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
);
|