@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.
Files changed (50) hide show
  1. package/dist/{chunk-DJD3EMPK.js → chunk-YE4ZQUTI.js} +1 -1
  2. package/dist/components/alert.d.ts +2 -2
  3. package/dist/components/badge.d.ts +1 -1
  4. package/dist/components/button.d.ts +1 -1
  5. package/dist/components/coach-marks.d.ts +43 -6
  6. package/dist/components/coach-marks.js +116 -30
  7. package/dist/components/command.d.ts +7 -7
  8. package/dist/components/concept-compare.d.ts +1 -1
  9. package/dist/components/doorway-tiles.d.ts +1 -1
  10. package/dist/components/grade-atom.d.ts +1 -1
  11. package/dist/components/grade-atom.js +1 -1
  12. package/dist/components/kitchen-dossier.d.ts +1 -1
  13. package/dist/components/menu-dead-end.d.ts +1 -1
  14. package/dist/components/menu-family-block.d.ts +1 -1
  15. package/dist/components/menu-family-card.d.ts +1 -1
  16. package/dist/components/menu-filter-chips.d.ts +1 -1
  17. package/dist/components/menu-group-rows.d.ts +1 -1
  18. package/dist/components/menu-index-header.d.ts +1 -1
  19. package/dist/components/menu-index-list.d.ts +1 -1
  20. package/dist/components/menu-intent-hero.d.ts +1 -1
  21. package/dist/components/menu-item.d.ts +1 -1
  22. package/dist/components/menu-kitchen-list.d.ts +1 -1
  23. package/dist/components/menu-mocks.d.ts +1 -1
  24. package/dist/components/menu-types.d.ts +1 -1
  25. package/dist/components/open-now-list.d.ts +1 -1
  26. package/dist/components/place-about-tab.d.ts +1 -1
  27. package/dist/components/place-card.d.ts +1 -1
  28. package/dist/components/place-card.js +1 -1
  29. package/dist/components/place-details-mobile.d.ts +1 -1
  30. package/dist/components/place-schedule-week.d.ts +1 -1
  31. package/dist/components/place-types.d.ts +1 -1
  32. package/dist/components/qa-block.d.ts +1 -1
  33. package/dist/components/spinner.d.ts +2 -2
  34. package/dist/components/tag.d.ts +2 -2
  35. package/dist/components/text.d.ts +3 -3
  36. package/dist/components/top-comparison.d.ts +1 -1
  37. package/dist/components/top-cross-link.d.ts +1 -1
  38. package/dist/components/top-ranking-entry.d.ts +1 -1
  39. package/dist/components/top-ranking-hero.d.ts +1 -1
  40. package/dist/components/top-types.d.ts +1 -1
  41. package/dist/components/venue-grade-dimensions.d.ts +1 -1
  42. package/dist/components/venue-quotes.d.ts +1 -1
  43. package/dist/components/venue-wall.d.ts +1 -1
  44. package/dist/{place-types-BHdeMMiG.d.ts → place-types-C4rR3LTs.d.ts} +7 -0
  45. package/package.json +1 -1
  46. package/src/components/coach-marks.stories.tsx +20 -4
  47. package/src/components/coach-marks.test.tsx +181 -6
  48. package/src/components/coach-marks.tsx +204 -45
  49. package/src/components/grade-atom.test.tsx +32 -0
  50. 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
- * 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>
@@ -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;