@bearmenu/ui 0.8.21 → 0.8.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-DJD3EMPK.js → chunk-YE4ZQUTI.js} +1 -1
- package/dist/components/alert.d.ts +2 -2
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/coach-marks.d.ts +43 -6
- package/dist/components/coach-marks.js +116 -30
- package/dist/components/command.d.ts +7 -7
- package/dist/components/concept-compare.d.ts +1 -1
- package/dist/components/doorway-tiles.d.ts +1 -1
- package/dist/components/grade-atom.d.ts +1 -1
- package/dist/components/grade-atom.js +1 -1
- package/dist/components/kitchen-dossier.d.ts +1 -1
- package/dist/components/menu-dead-end.d.ts +1 -1
- package/dist/components/menu-family-block.d.ts +1 -1
- package/dist/components/menu-family-card.d.ts +1 -1
- package/dist/components/menu-filter-chips.d.ts +1 -1
- package/dist/components/menu-group-rows.d.ts +1 -1
- package/dist/components/menu-index-header.d.ts +1 -1
- package/dist/components/menu-index-list.d.ts +1 -1
- package/dist/components/menu-intent-hero.d.ts +1 -1
- package/dist/components/menu-item.d.ts +1 -1
- package/dist/components/menu-kitchen-list.d.ts +1 -1
- package/dist/components/menu-mocks.d.ts +1 -1
- package/dist/components/menu-types.d.ts +1 -1
- package/dist/components/open-now-list.d.ts +1 -1
- package/dist/components/place-about-tab.d.ts +1 -1
- package/dist/components/place-card.d.ts +1 -1
- package/dist/components/place-card.js +1 -1
- package/dist/components/place-details-mobile.d.ts +1 -1
- package/dist/components/place-schedule-week.d.ts +1 -1
- package/dist/components/place-types.d.ts +1 -1
- package/dist/components/qa-block.d.ts +1 -1
- package/dist/components/spinner.d.ts +2 -2
- package/dist/components/tag.d.ts +2 -2
- package/dist/components/text.d.ts +3 -3
- package/dist/components/top-comparison.d.ts +1 -1
- package/dist/components/top-cross-link.d.ts +1 -1
- package/dist/components/top-ranking-entry.d.ts +1 -1
- package/dist/components/top-ranking-hero.d.ts +1 -1
- package/dist/components/top-types.d.ts +1 -1
- package/dist/components/venue-grade-dimensions.d.ts +1 -1
- package/dist/components/venue-quotes.d.ts +1 -1
- package/dist/components/venue-wall.d.ts +1 -1
- package/dist/{place-types-BHdeMMiG.d.ts → place-types-C4rR3LTs.d.ts} +7 -0
- 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/grade-atom.test.tsx +32 -0
- package/src/components/grade-atom.tsx +8 -1
|
@@ -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>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { GradeAtom, type GradeAtomLabels } from "./grade-atom";
|
|
4
|
+
|
|
5
|
+
const labels: GradeAtomLabels = {
|
|
6
|
+
soft: "Worth exploring",
|
|
7
|
+
bands: { exceptional: "Exceptional", great: "Great", good: "Good", decent: "Decent", weak: "Weak" },
|
|
8
|
+
aria: (value) => `Grade ${value} out of 10`,
|
|
9
|
+
capped: "Capped for low confidence",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("GradeAtom", () => {
|
|
13
|
+
it("prints the grade with a dot and no trailing .0 by default", () => {
|
|
14
|
+
render(<GradeAtom grade={{ overall: 8.74 }} labels={labels} size="md" />);
|
|
15
|
+
expect(screen.getByLabelText("Grade 8.7 out of 10")).toHaveTextContent("8.7");
|
|
16
|
+
render(<GradeAtom grade={{ overall: 9 }} labels={labels} size="md" />);
|
|
17
|
+
expect(screen.getByLabelText("Grade 9 out of 10")).toHaveTextContent("9");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("lets the labels write the number their locale's way, in the pill and in the aria name", () => {
|
|
21
|
+
const ro: GradeAtomLabels = { ...labels, value: (n) => n.toFixed(1).replace(".", ","), aria: (v) => `Nota ${v} din 10` };
|
|
22
|
+
render(<GradeAtom grade={{ overall: 8.74 }} labels={ro} size="md" />);
|
|
23
|
+
expect(screen.getByLabelText("Nota 8,7 din 10")).toHaveTextContent("8,7");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("hands the formatter the rounded, clamped grade", () => {
|
|
27
|
+
const seen: number[] = [];
|
|
28
|
+
const spy: GradeAtomLabels = { ...labels, value: (n) => (seen.push(n), String(n)) };
|
|
29
|
+
render(<GradeAtom grade={{ overall: 12.26 }} labels={spy} size="md" />);
|
|
30
|
+
expect(seen).toEqual([10]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -41,6 +41,13 @@ export type GradeAtomLabels = {
|
|
|
41
41
|
aria: (value: string) => string;
|
|
42
42
|
/** Title + aria suffix when `grade.capped`, e.g. "Capped for low confidence". */
|
|
43
43
|
capped: string;
|
|
44
|
+
/**
|
|
45
|
+
* The number as the locale writes it, e.g. `8,7` in Romanian. Receives the
|
|
46
|
+
* grade already rounded to one decimal; return the glyphs to print. Defaults
|
|
47
|
+
* to `formatGrade` (`8.7`, a trailing `.0` dropped). It is a label because the
|
|
48
|
+
* decimal mark is translation, not styling — and `aria` gets the same string.
|
|
49
|
+
*/
|
|
50
|
+
value?: (overall: number) => string;
|
|
44
51
|
};
|
|
45
52
|
|
|
46
53
|
export type GradeAtomProps = {
|
|
@@ -221,7 +228,7 @@ export function GradeAtom({
|
|
|
221
228
|
}
|
|
222
229
|
|
|
223
230
|
const band = resolveGradeBand(overall);
|
|
224
|
-
const value = formatGrade(overall);
|
|
231
|
+
const value = labels.value ? labels.value(Math.round(Math.min(10, Math.max(1, overall)) * 10) / 10) : formatGrade(overall);
|
|
225
232
|
const descriptor = labels.bands[band];
|
|
226
233
|
const showDesc = sz.showDesc && !hideDescriptor;
|
|
227
234
|
const photo = onPhoto ? BAND_CLASSES_PHOTO[band] : null;
|