@lovett/ui 0.0.9 → 0.0.11
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/index.d.ts +823 -135
- package/dist/index.js +2048 -358
- package/dist/index.js.map +1 -1
- package/dist/styles.css +44 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +123 -8
- package/package.json +1 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/dropdown-menu.test.tsx +418 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/modal.test.tsx +180 -6
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/code-block.tsx +15 -26
- package/src/combobox.tsx +796 -0
- package/src/dropdown-menu.tsx +142 -152
- package/src/icons/brand.tsx +81 -2
- package/src/index.ts +111 -0
- package/src/lib/anchor.ts +427 -0
- package/src/lib/focus.ts +32 -0
- package/src/lib/layer-stack.ts +188 -0
- package/src/lib/refs.ts +31 -0
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +149 -9
- package/src/page-shell.tsx +91 -2
- package/src/popover.tsx +407 -0
- package/src/segmented-pill.tsx +33 -10
- package/src/select.tsx +646 -0
- package/src/stat-row.tsx +108 -70
- package/src/styles.css +44 -2
- package/src/theme-v2.css +7 -245
- package/src/tokens.css +123 -8
- package/src/tooltip.tsx +297 -0
- package/src/react-syntax-highlighter-prism.d.ts +0 -34
- package/src/v2/README.md +0 -208
- package/src/v2/__demo__/showcase.tsx +0 -1045
- package/src/v2/action.tsx +0 -91
- package/src/v2/callout.tsx +0 -76
- package/src/v2/document-section.tsx +0 -82
- package/src/v2/document-shell.tsx +0 -0
- package/src/v2/field-row.tsx +0 -113
- package/src/v2/icons.tsx +0 -165
- package/src/v2/index.ts +0 -147
- package/src/v2/layout.tsx +0 -293
- package/src/v2/progress-track.tsx +0 -89
- package/src/v2/stat-tile.tsx +0 -129
- package/src/v2/states.tsx +0 -271
- package/src/v2/status-pill.tsx +0 -74
- package/src/v2/theme.css +0 -1861
- package/src/v2/timeline.tsx +0 -81
- package/src/v2/tokens.ts +0 -228
package/src/tokens.css
CHANGED
|
@@ -49,6 +49,9 @@
|
|
|
49
49
|
* colored backgrounds — switches,
|
|
50
50
|
* sliders, badges-on-accent)
|
|
51
51
|
* --muted (muted fill for ghost UI)
|
|
52
|
+
* --surface-subtle (lightest structural tint — header bands
|
|
53
|
+
* and zebra on an already-bordered
|
|
54
|
+
* surface; 1.04:1, matched across themes)
|
|
52
55
|
* --header-height (sticky AppHeader height — referenced
|
|
53
56
|
* by sticky chip bars; same value lives
|
|
54
57
|
* in src/lib/layout.ts for JS callsites)
|
|
@@ -92,6 +95,49 @@
|
|
|
92
95
|
--accent-muted: 207 14 15 / 0.12;
|
|
93
96
|
--accent-subtle: 207 14 15 / 0.06;
|
|
94
97
|
--accent-glow: 207 14 15 / 0.25;
|
|
98
|
+
/* Text-selection highlight. PUBLIC, and a full colour (not a triple) so
|
|
99
|
+
`::selection { background: var(--selection-bg) }` is valid at :root.
|
|
100
|
+
`@lovett/shell` ships that exact global rule while defining the
|
|
101
|
+
variable only inside `.cs-frame`; unset at the root, `background`
|
|
102
|
+
computed to transparent and every selection in the app was invisible
|
|
103
|
+
(ADR-145 amendment, 2026-09-04). Neutral blue like a native selection,
|
|
104
|
+
not the accent — the accent earns impact from scarcity. */
|
|
105
|
+
--selection-bg: rgb(var(--info) / 0.34);
|
|
106
|
+
/* Ink ON an accent fill. PUBLIC, and theme-independent because --accent is:
|
|
107
|
+
the brand red does not change between light and dark, so its foreground
|
|
108
|
+
must not either. Measures 5.64:1 on --accent.
|
|
109
|
+
Added 2026-08-29 — needs a token ADR (CLAUDE.md §4). It closes a real gap:
|
|
110
|
+
app code had three different answers for "text on accent" (a `text-white`
|
|
111
|
+
literal, the INTERNAL --primary-foreground, and a reference to this very
|
|
112
|
+
name with an inline fallback), and one of them — `rgb(var(--foreground))`
|
|
113
|
+
— inverted to near-black on red at 3.34:1 in light mode. The public
|
|
114
|
+
--accent family had every value EXCEPT its foreground, so lens code had
|
|
115
|
+
nothing correct to reach for. */
|
|
116
|
+
--accent-foreground: 255 255 255;
|
|
117
|
+
/* Accent as TEXT on a normal surface — a different problem from
|
|
118
|
+
--accent-foreground (ink ON an accent fill). PUBLIC, and unlike --accent it
|
|
119
|
+
IS theme-tuned: the brand red measures 5.64:1 on a light card but only
|
|
120
|
+
3.05:1 on a dark one, so an accent-coloured LABEL is legible in light and
|
|
121
|
+
fails AA in dark. The dark value is the lifted red that clears it (4.57:1).
|
|
122
|
+
Use for selected-state labels, accent links, accent metric values — never
|
|
123
|
+
as a fill. */
|
|
124
|
+
--accent-ink: 239 68 68;
|
|
125
|
+
|
|
126
|
+
/* SEMANTIC INK — PUBLIC. Same problem as --accent-ink, same shape of answer.
|
|
127
|
+
`--success` / `--warning` are sized to work as FILLS and as icon strokes;
|
|
128
|
+
used as TEXT ON THEIR OWN 10-12% TINT they are only legible in one theme.
|
|
129
|
+
Measured over the card: success reads 5.64:1 dark but 3.33:1 light, and
|
|
130
|
+
warning 6.46:1 dark but 2.86:1 light — both fail AA for body text in light
|
|
131
|
+
mode, which is exactly where the generate lens's tip panels and word-count
|
|
132
|
+
chips live. Dark keeps the base colour; light gets a darkened ink that
|
|
133
|
+
clears 4.5:1 while still reading green and amber rather than near-black.
|
|
134
|
+
Text only — never a fill, and never a border. */
|
|
135
|
+
--success-ink: var(--success);
|
|
136
|
+
--warning-ink: var(--warning);
|
|
137
|
+
/* Destructive is the one that fails in BOTH themes as text on its own tint
|
|
138
|
+
(3.53:1 light, 3.67:1 dark), so unlike the other two the dark value is not
|
|
139
|
+
the base — it is lifted 18% toward white to clear 5.11:1 / 4.51:1. */
|
|
140
|
+
--destructive-ink: 242 102 102;
|
|
95
141
|
|
|
96
142
|
/* ============================================================
|
|
97
143
|
HIERARCHY MAP PALETTE — PUBLIC, theme-INDEPENDENT.
|
|
@@ -167,6 +213,17 @@
|
|
|
167
213
|
--folder-slate: 148 163 184;
|
|
168
214
|
--folder-ink: 203 213 225;
|
|
169
215
|
|
|
216
|
+
/* OPAQUE categorical tints — the chip/pill FILL for a --folder-* hue.
|
|
217
|
+
Pre-composited (hue at 0.12 over --surface-card) so a chip needs no
|
|
218
|
+
alpha and therefore does not change appearance when the surface
|
|
219
|
+
under it changes. Only the two hues with consumers are defined;
|
|
220
|
+
extend per hue as consumers land rather than all nine up front.
|
|
221
|
+
NOTE: the hue itself is a FILL/GRAPHIC value, not an ink — e.g.
|
|
222
|
+
--folder-amber on its own tint measures 2.86:1 in light. Chip TEXT
|
|
223
|
+
is --foreground; the tint + border carry the category. */
|
|
224
|
+
--folder-amber-tint: 53 43 28;
|
|
225
|
+
--folder-blue-tint: 31 39 56;
|
|
226
|
+
|
|
170
227
|
/* ============================================================
|
|
171
228
|
SIDEBAR — INTERNAL (only <Sidebar/> reads these)
|
|
172
229
|
============================================================ */
|
|
@@ -298,8 +355,18 @@
|
|
|
298
355
|
--shadow-xl: 0 16px 40px rgb(0 0 0 / 0.55);
|
|
299
356
|
--shadow-2xl: 0 24px 60px rgb(0 0 0 / 0.60);
|
|
300
357
|
|
|
301
|
-
|
|
302
|
-
|
|
358
|
+
/* Thin and SOLID, not a soft halo — and this is a legibility fix as much as
|
|
359
|
+
a look. The old 3px @ 0.25/0.20 composited to rgb(245 207 207) on a light
|
|
360
|
+
card and rgb(63 24 27) on a dark one: 1.43:1 and 1.11:1 against the surface
|
|
361
|
+
it sits on, against the 3:1 that WCAG 2.2 SC 2.4.11 (Focus Appearance)
|
|
362
|
+
asks of a focus indicator. Alpha was measured across the range and NOTHING
|
|
363
|
+
below fully-opaque clears 3:1 in dark mode, so the ring is solid: 5.64:1
|
|
364
|
+
light, 3.05:1 dark. 2px keeps the >=2px perimeter the same SC requires
|
|
365
|
+
while reading crisper than the 3px glow it replaces.
|
|
366
|
+
Both resolve through the theme-aware tokens, so the light block needs no
|
|
367
|
+
override — var() resolves at use time. */
|
|
368
|
+
--ring-focus: 0 0 0 2px rgb(var(--accent));
|
|
369
|
+
--ring-error: 0 0 0 2px rgb(var(--destructive));
|
|
303
370
|
|
|
304
371
|
--radius-xs: 6px;
|
|
305
372
|
|
|
@@ -339,6 +406,18 @@
|
|
|
339
406
|
--surface-card: 27 27 30;
|
|
340
407
|
--surface-inset: 45 45 48;
|
|
341
408
|
--surface-hover: 55 55 59;
|
|
409
|
+
/* THE LIGHTEST STRUCTURAL TINT — PUBLIC. A header band or zebra stripe on a
|
|
410
|
+
surface that ALREADY HAS A BORDER, where the fill only has to hint that a
|
|
411
|
+
row is chrome rather than content.
|
|
412
|
+
Measured: the border alone is 1.25:1 in dark and 1.32:1 in light, stronger
|
|
413
|
+
than any fill step in the neutral ramp — so the fill is decoration on top
|
|
414
|
+
of structure that already reads, and it can go very light indeed.
|
|
415
|
+
Sized by OKLCH lightness so the two themes are the SAME design: ΔL 0.017
|
|
416
|
+
dark, 0.014 light (1.045:1 / 1.042:1). That symmetry is the point. Using
|
|
417
|
+
--muted for this job gave ΔL 0.075 in dark against 0.038 in light — twice
|
|
418
|
+
the step in one theme, which is why a header that looked right in dark read
|
|
419
|
+
as a grey slab in light. */
|
|
420
|
+
--surface-subtle: 31 31 34;
|
|
342
421
|
/* A control you can act on — input, select, option tile, chip-button.
|
|
343
422
|
Sits at CARD level on purpose: in light that is white against the
|
|
344
423
|
frame behind it, which reads "type here"; in dark it is raised above
|
|
@@ -389,6 +468,21 @@
|
|
|
389
468
|
[data-theme="light"], [data-theme="combo"] {
|
|
390
469
|
/* Brand accent — base stays, hover darkens, glow softens */
|
|
391
470
|
--accent-hover: 178 8 9;
|
|
471
|
+
--selection-bg: rgb(var(--info) / 0.24);
|
|
472
|
+
/* Brand red passes on a light card (5.64:1) — see the :root note. */
|
|
473
|
+
--accent-ink: var(--accent);
|
|
474
|
+
/* Solved against the DARKEST tint these inks land on, not the lightest.
|
|
475
|
+
The lens mixes success at both 10% (tip panels) and 20% (score chips); an
|
|
476
|
+
ink tuned only to the 10% tint cleared 4.61:1 there and still failed at
|
|
477
|
+
4.09:1 on the 20% chip. This value clears BOTH — 5.12:1 and 4.54:1 — and
|
|
478
|
+
at 77% of --success it is still unmistakably green. */
|
|
479
|
+
--success-ink: 4 116 81;
|
|
480
|
+
/* Same reasoning as --success-ink: solved against the 20% tint as well as
|
|
481
|
+
the 10% one (5.03:1 / 4.52:1, up from 2.86:1 / 4.05:1). 72% of --warning,
|
|
482
|
+
still unmistakably amber. */
|
|
483
|
+
--warning-ink: 156 86 4;
|
|
484
|
+
/* 5.28:1 / 4.51:1 on the 10% and 20% tints, up from 3.53:1. */
|
|
485
|
+
--destructive-ink: 189 33 33;
|
|
392
486
|
--accent-muted: 207 14 15 / 0.10;
|
|
393
487
|
--accent-subtle: 207 14 15 / 0.05;
|
|
394
488
|
--accent-glow: 207 14 15 / 0.20;
|
|
@@ -406,9 +500,28 @@
|
|
|
406
500
|
--card-foreground: 17 17 19;
|
|
407
501
|
--popover-foreground: 17 17 19;
|
|
408
502
|
--muted-foreground: 82 82 91;
|
|
409
|
-
--
|
|
410
|
-
|
|
411
|
-
|
|
503
|
+
/* Byte-identical to --muted-foreground in both themes since they were
|
|
504
|
+
introduced. Aliased rather than duplicated so the two names cannot
|
|
505
|
+
drift apart; prefer --muted-foreground in new code. */
|
|
506
|
+
--text-secondary: var(--muted-foreground);
|
|
507
|
+
/* Was "stays from :root" — but :root IS the dark theme, so light
|
|
508
|
+
inherited the dark-tuned 132 132 141, which measures 3.71:1 on a
|
|
509
|
+
255 card and fails AA for the 13px body text that consumes it.
|
|
510
|
+
Re-picked by measurement, not by eye: 110 110 119 is the first rung
|
|
511
|
+
with headroom on every light surface it can land on —
|
|
512
|
+
5.05:1 on --surface-card/page (255), 4.72:1 on --surface-frame (247),
|
|
513
|
+
4.52:1 on --surface-inset (242). Keeps the ramp's 286° cool cast.
|
|
514
|
+
Side effect: it also repairs the ink ramp's shape. The tiers were
|
|
515
|
+
L 0.442 / 0.616 / 0.633 — a wide gap then a collision, tertiary and
|
|
516
|
+
muted less than half a surface-step apart. They are now
|
|
517
|
+
0.442 / 0.541 / 0.633: three evenly-spread rungs. */
|
|
518
|
+
--text-tertiary: 110 110 119;
|
|
519
|
+
/* Lifted 161->137 — was 2.56:1 on a white card. Still only 3.47:1 on
|
|
520
|
+
255, i.e. it clears the 3:1 non-text floor and NOT the 4.5:1 text
|
|
521
|
+
floor. That is deliberate: --text-muted is the quietest rung and is
|
|
522
|
+
for NON-TEXT and DISABLED chrome only — quiet icons, chevrons,
|
|
523
|
+
disabled labels. Real text one tier up (--text-tertiary) or higher.
|
|
524
|
+
Lifting this to pass 4.5:1 would collapse it into --text-tertiary. */
|
|
412
525
|
--text-muted: 137 137 146;
|
|
413
526
|
--secondary-foreground: 17 17 19;
|
|
414
527
|
|
|
@@ -446,9 +559,6 @@
|
|
|
446
559
|
--shadow-2xl: 0 24px 60px rgb(0 0 0 / 0.16);
|
|
447
560
|
--shadow-glow: 0 0 20px rgb(207 14 15 / 0.20);
|
|
448
561
|
|
|
449
|
-
--ring-focus: 0 0 0 3px rgb(207 14 15 / 0.20);
|
|
450
|
-
--ring-error: 0 0 0 3px rgb(220 38 38 / 0.18);
|
|
451
|
-
|
|
452
562
|
/* Match --page-bg (254/254/254). Was 241/245/249 (slate) — that's
|
|
453
563
|
* what was actually painting "the steely background" the owner
|
|
454
564
|
* flagged; --page-bg is the body, but MainLayout's content area
|
|
@@ -508,6 +618,8 @@
|
|
|
508
618
|
--surface-frame: 247 247 249;
|
|
509
619
|
--surface-inset: 242 242 244;
|
|
510
620
|
--surface-hover: 238 238 240;
|
|
621
|
+
/* 1.042:1 against the card — a band, not a slab. See the :root note. */
|
|
622
|
+
--surface-subtle: 250 250 252;
|
|
511
623
|
/* A control you can act on — input, select, option tile, chip-button.
|
|
512
624
|
Sits at CARD level on purpose: in light that is white against the
|
|
513
625
|
frame behind it, which reads "type here"; in dark it is raised above
|
|
@@ -606,6 +718,9 @@
|
|
|
606
718
|
--folder-pink: 219 39 119;
|
|
607
719
|
--folder-slate: 71 85 105;
|
|
608
720
|
--folder-ink: 15 23 42;
|
|
721
|
+
/* Opaque categorical tints — hue at 0.10 over the 255 light card. */
|
|
722
|
+
--folder-amber-tint: 251 241 230;
|
|
723
|
+
--folder-blue-tint: 232 237 251;
|
|
609
724
|
}
|
|
610
725
|
|
|
611
726
|
/* ============================================================
|
package/src/tooltip.tsx
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooltip — a short, non-interactive label that appears on hover or focus.
|
|
3
|
+
*
|
|
4
|
+
* Wraps ONE trigger element (always "asChild": the child is cloned with the
|
|
5
|
+
* ref, the hover / focus handlers and `aria-describedby`; no wrapper node is
|
|
6
|
+
* added). The tip is `role="tooltip"`, portals to `document.body`, positions
|
|
7
|
+
* through `useAnchoredPosition` (default: above, centred; flips below when
|
|
8
|
+
* there is no room), enters with `.ds-enter-pop` once positioned, and is
|
|
9
|
+
* `pointer-events: none` so it never blocks the thing under it.
|
|
10
|
+
*
|
|
11
|
+
* Timing: hover opens after `openDelay` (300 ms) and closes after
|
|
12
|
+
* `closeDelay` (0 ms) — the delay is what keeps a mouse sweeping across a
|
|
13
|
+
* toolbar from flashing every label. Keyboard focus opens IMMEDIATELY (a
|
|
14
|
+
* keyboard user has already committed to the control); a focus that arrives
|
|
15
|
+
* by pointer (clicking a button) does not open it, and pressing the trigger
|
|
16
|
+
* dismisses it. Escape dismisses through the shared layer stack, so it never
|
|
17
|
+
* steals the keypress from a Modal underneath — a tooltip is pushed above
|
|
18
|
+
* the modal only while it is showing.
|
|
19
|
+
*
|
|
20
|
+
* Never traps or moves focus: the tip is not focusable and the trigger keeps
|
|
21
|
+
* whatever focus it had. Only non-interactive content belongs in it — the
|
|
22
|
+
* trigger should still have its own accessible name (`aria-label`) when it
|
|
23
|
+
* is icon-only; the tooltip DESCRIBES, it does not name.
|
|
24
|
+
*
|
|
25
|
+
* Promoted per ADR-0030 Decision G (meta-ads-audit-dashboard task manager:
|
|
26
|
+
* truncated cells, icon-only row actions, relative timestamps). Second
|
|
27
|
+
* consumer is the workspace app, which hand-rolls two today —
|
|
28
|
+
* `tools/_shared/calc/info-tip.tsx` and
|
|
29
|
+
* `components/ui/sidebar/rail-tooltip.tsx` — and leans on 900+ `title`
|
|
30
|
+
* attributes elsewhere.
|
|
31
|
+
*
|
|
32
|
+
* Token discipline: token-only inline surface like Kbd — INTERNAL
|
|
33
|
+
* `--popover` / `--popover-foreground` (allowed inside packages/ui) with
|
|
34
|
+
* public `--border`, `--shadow-md`, `--radius-sm`, `--space-*`. The same
|
|
35
|
+
* chrome family as Popover / DropdownMenu, so a tip and a menu read as one
|
|
36
|
+
* system. Geometry from the hook; motion from `.ds-enter-pop`, which
|
|
37
|
+
* honours prefers-reduced-motion in styles.css.
|
|
38
|
+
*
|
|
39
|
+
* Usage:
|
|
40
|
+
*
|
|
41
|
+
* <Tooltip content="Archive task">
|
|
42
|
+
* <Button variant="secondary" aria-label="Archive task"><Archive /></Button>
|
|
43
|
+
* </Tooltip>
|
|
44
|
+
*
|
|
45
|
+
* <Tooltip content={fullTitle} side="bottom" align="start">
|
|
46
|
+
* <span className="truncate">{fullTitle}</span>
|
|
47
|
+
* </Tooltip>
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import {
|
|
51
|
+
cloneElement,
|
|
52
|
+
isValidElement,
|
|
53
|
+
useCallback,
|
|
54
|
+
useEffect,
|
|
55
|
+
useId,
|
|
56
|
+
useMemo,
|
|
57
|
+
useRef,
|
|
58
|
+
useState,
|
|
59
|
+
type CSSProperties,
|
|
60
|
+
type FocusEvent as ReactFocusEvent,
|
|
61
|
+
type HTMLAttributes,
|
|
62
|
+
type PointerEvent as ReactPointerEvent,
|
|
63
|
+
type ReactElement,
|
|
64
|
+
type ReactNode,
|
|
65
|
+
type Ref,
|
|
66
|
+
} from 'react'
|
|
67
|
+
import { createPortal } from 'react-dom'
|
|
68
|
+
import { cn } from './lib/utils'
|
|
69
|
+
import { composeRefs } from './lib/refs'
|
|
70
|
+
import { useLayer } from './lib/layer-stack'
|
|
71
|
+
import { useAnchoredPosition, type AnchorAlign, type AnchorSide } from './lib/anchor'
|
|
72
|
+
|
|
73
|
+
export interface TooltipProps {
|
|
74
|
+
/** The tip's content. Nothing renders (children pass through untouched) when null. */
|
|
75
|
+
content: ReactNode
|
|
76
|
+
/** Exactly one element — it becomes the trigger. */
|
|
77
|
+
children: ReactElement<TooltipTriggerProps>
|
|
78
|
+
/** Default `top`. Flips when it cannot fit. */
|
|
79
|
+
side?: AnchorSide
|
|
80
|
+
/** Default `center`. */
|
|
81
|
+
align?: AnchorAlign
|
|
82
|
+
/** Gap from the trigger, px. Default 6. */
|
|
83
|
+
offset?: number
|
|
84
|
+
/** Hover-open delay, ms. Default 300. Focus opens immediately. */
|
|
85
|
+
openDelay?: number
|
|
86
|
+
/** Close delay, ms. Default 0. */
|
|
87
|
+
closeDelay?: number
|
|
88
|
+
/** Controlled open state. Omit for internal state. */
|
|
89
|
+
open?: boolean
|
|
90
|
+
defaultOpen?: boolean
|
|
91
|
+
onOpenChange?: (open: boolean) => void
|
|
92
|
+
/** Extra classes on the tip surface. */
|
|
93
|
+
className?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What the cloned trigger element must accept. */
|
|
97
|
+
export type TooltipTriggerProps = HTMLAttributes<HTMLElement> & {
|
|
98
|
+
ref?: Ref<HTMLElement>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const TOOLTIP_SURFACE_STYLE: CSSProperties = {
|
|
102
|
+
background: 'rgb(var(--popover))',
|
|
103
|
+
color: 'rgb(var(--popover-foreground))',
|
|
104
|
+
border: '1px solid rgb(var(--border))',
|
|
105
|
+
boxShadow: 'var(--shadow-md)',
|
|
106
|
+
borderRadius: 'var(--radius-sm)',
|
|
107
|
+
padding: 'var(--space-1) var(--space-2)',
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function Tooltip({
|
|
111
|
+
content,
|
|
112
|
+
children,
|
|
113
|
+
side = 'top',
|
|
114
|
+
align = 'center',
|
|
115
|
+
offset = 6,
|
|
116
|
+
openDelay = 300,
|
|
117
|
+
closeDelay = 0,
|
|
118
|
+
open: openProp,
|
|
119
|
+
defaultOpen,
|
|
120
|
+
onOpenChange,
|
|
121
|
+
className,
|
|
122
|
+
}: TooltipProps) {
|
|
123
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(Boolean(defaultOpen))
|
|
124
|
+
const isControlled = openProp !== undefined
|
|
125
|
+
const open = isControlled ? Boolean(openProp) : uncontrolledOpen
|
|
126
|
+
const setOpen = useCallback(
|
|
127
|
+
(next: boolean) => {
|
|
128
|
+
if (!isControlled) setUncontrolledOpen(next)
|
|
129
|
+
onOpenChange?.(next)
|
|
130
|
+
},
|
|
131
|
+
[isControlled, onOpenChange],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const triggerRef = useRef<HTMLElement | null>(null)
|
|
135
|
+
const tipRef = useRef<HTMLDivElement | null>(null)
|
|
136
|
+
const timerRef = useRef<number | null>(null)
|
|
137
|
+
// Set on pointerdown, cleared on the next pointerup / pointercancel
|
|
138
|
+
// anywhere: a focus that arrives during that window came from the pointer,
|
|
139
|
+
// not the keyboard. The document listener is held in a ref so an
|
|
140
|
+
// interrupted press (unmount mid-press, a second pointerdown) removes it
|
|
141
|
+
// rather than leaving a stale closure on document.
|
|
142
|
+
const pointerDownRef = useRef(false)
|
|
143
|
+
const pressEndListenerRef = useRef<(() => void) | null>(null)
|
|
144
|
+
const clearPressEndListener = useCallback(() => {
|
|
145
|
+
const listener = pressEndListenerRef.current
|
|
146
|
+
if (listener === null) return
|
|
147
|
+
pressEndListenerRef.current = null
|
|
148
|
+
document.removeEventListener('pointerup', listener)
|
|
149
|
+
document.removeEventListener('pointercancel', listener)
|
|
150
|
+
}, [])
|
|
151
|
+
useEffect(() => clearPressEndListener, [clearPressEndListener])
|
|
152
|
+
|
|
153
|
+
const tipId = `tooltip-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
|
|
154
|
+
|
|
155
|
+
const clearTimer = useCallback(() => {
|
|
156
|
+
if (timerRef.current !== null) {
|
|
157
|
+
window.clearTimeout(timerRef.current)
|
|
158
|
+
timerRef.current = null
|
|
159
|
+
}
|
|
160
|
+
}, [])
|
|
161
|
+
useEffect(() => clearTimer, [clearTimer])
|
|
162
|
+
|
|
163
|
+
const scheduleOpen = useCallback(
|
|
164
|
+
(delay: number) => {
|
|
165
|
+
clearTimer()
|
|
166
|
+
if (delay <= 0) {
|
|
167
|
+
setOpen(true)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
timerRef.current = window.setTimeout(() => {
|
|
171
|
+
timerRef.current = null
|
|
172
|
+
setOpen(true)
|
|
173
|
+
}, delay)
|
|
174
|
+
},
|
|
175
|
+
[clearTimer, setOpen],
|
|
176
|
+
)
|
|
177
|
+
const scheduleClose = useCallback(
|
|
178
|
+
(delay: number) => {
|
|
179
|
+
clearTimer()
|
|
180
|
+
if (delay <= 0) {
|
|
181
|
+
setOpen(false)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
timerRef.current = window.setTimeout(() => {
|
|
185
|
+
timerRef.current = null
|
|
186
|
+
setOpen(false)
|
|
187
|
+
}, delay)
|
|
188
|
+
},
|
|
189
|
+
[clearTimer, setOpen],
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const {
|
|
193
|
+
ref: positionRef,
|
|
194
|
+
style: positionStyle,
|
|
195
|
+
placement,
|
|
196
|
+
positioned,
|
|
197
|
+
} = useAnchoredPosition<HTMLDivElement>({
|
|
198
|
+
anchorRef: triggerRef,
|
|
199
|
+
side,
|
|
200
|
+
align,
|
|
201
|
+
offset,
|
|
202
|
+
enabled: open,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
useLayer({
|
|
206
|
+
enabled: open,
|
|
207
|
+
kind: 'popover',
|
|
208
|
+
elementRef: tipRef,
|
|
209
|
+
onEscape: () => {
|
|
210
|
+
clearTimer()
|
|
211
|
+
setOpen(false)
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const tipRefs = useMemo(
|
|
216
|
+
() => composeRefs<HTMLDivElement>(tipRef, positionRef),
|
|
217
|
+
[positionRef],
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if (!isValidElement<TooltipTriggerProps>(children)) {
|
|
221
|
+
throw new Error('Tooltip expects exactly one element child to use as its trigger.')
|
|
222
|
+
}
|
|
223
|
+
if (content === null || content === undefined || content === false) return children
|
|
224
|
+
|
|
225
|
+
const childProps = children.props
|
|
226
|
+
const describedBy = open
|
|
227
|
+
? [childProps['aria-describedby'], tipId].filter(Boolean).join(' ')
|
|
228
|
+
: childProps['aria-describedby']
|
|
229
|
+
|
|
230
|
+
const trigger = cloneElement(children, {
|
|
231
|
+
ref: composeRefs<HTMLElement>(childProps.ref, triggerRef),
|
|
232
|
+
'aria-describedby': describedBy,
|
|
233
|
+
onPointerEnter: (event: ReactPointerEvent<HTMLElement>) => {
|
|
234
|
+
childProps.onPointerEnter?.(event)
|
|
235
|
+
// Touch has no hover; a long-press tooltip is a different pattern.
|
|
236
|
+
if (event.pointerType === 'touch') return
|
|
237
|
+
scheduleOpen(openDelay)
|
|
238
|
+
},
|
|
239
|
+
onPointerLeave: (event: ReactPointerEvent<HTMLElement>) => {
|
|
240
|
+
childProps.onPointerLeave?.(event)
|
|
241
|
+
scheduleClose(closeDelay)
|
|
242
|
+
},
|
|
243
|
+
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
|
|
244
|
+
childProps.onPointerDown?.(event)
|
|
245
|
+
pointerDownRef.current = true
|
|
246
|
+
clearPressEndListener()
|
|
247
|
+
const onPressEnd = () => {
|
|
248
|
+
pointerDownRef.current = false
|
|
249
|
+
clearPressEndListener()
|
|
250
|
+
}
|
|
251
|
+
pressEndListenerRef.current = onPressEnd
|
|
252
|
+
document.addEventListener('pointerup', onPressEnd)
|
|
253
|
+
document.addEventListener('pointercancel', onPressEnd)
|
|
254
|
+
// Pressing the trigger is the user acting on it; the label is in the way.
|
|
255
|
+
clearTimer()
|
|
256
|
+
setOpen(false)
|
|
257
|
+
},
|
|
258
|
+
onFocus: (event: ReactFocusEvent<HTMLElement>) => {
|
|
259
|
+
childProps.onFocus?.(event)
|
|
260
|
+
if (pointerDownRef.current) return
|
|
261
|
+
scheduleOpen(0)
|
|
262
|
+
},
|
|
263
|
+
onBlur: (event: ReactFocusEvent<HTMLElement>) => {
|
|
264
|
+
childProps.onBlur?.(event)
|
|
265
|
+
scheduleClose(0)
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<>
|
|
271
|
+
{trigger}
|
|
272
|
+
{open && typeof document !== 'undefined'
|
|
273
|
+
? createPortal(
|
|
274
|
+
<div
|
|
275
|
+
ref={tipRefs}
|
|
276
|
+
id={tipId}
|
|
277
|
+
role="tooltip"
|
|
278
|
+
data-slot="tooltip"
|
|
279
|
+
data-state="open"
|
|
280
|
+
data-side={placement?.side}
|
|
281
|
+
data-align={placement?.align}
|
|
282
|
+
data-positioned={positioned ? 'true' : 'false'}
|
|
283
|
+
className={cn(
|
|
284
|
+
'pointer-events-none fixed z-[110] max-w-xs text-xs font-medium leading-snug',
|
|
285
|
+
positioned && 'ds-enter-pop',
|
|
286
|
+
className,
|
|
287
|
+
)}
|
|
288
|
+
style={{ ...positionStyle, ...TOOLTIP_SURFACE_STYLE }}
|
|
289
|
+
>
|
|
290
|
+
{content}
|
|
291
|
+
</div>,
|
|
292
|
+
document.body,
|
|
293
|
+
)
|
|
294
|
+
: null}
|
|
295
|
+
</>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// Bridges a conflict between two resolvers, neither of which is wrong.
|
|
2
|
-
//
|
|
3
|
-
// `@types/react-syntax-highlighter` declares the DIRECTORY module
|
|
4
|
-
// `react-syntax-highlighter/dist/esm/styles/prism`. Node's ESM resolver refuses
|
|
5
|
-
// a directory import and needs the explicit `.../prism/index.js`. So the
|
|
6
|
-
// specifier TypeScript can type is the one Node rejects, and the specifier Node
|
|
7
|
-
// accepts is the one TypeScript cannot find — see the note in `code-block.tsx`
|
|
8
|
-
// for what that cost.
|
|
9
|
-
//
|
|
10
|
-
// This declares the file path by RE-EXPORTING the real types from the directory
|
|
11
|
-
// module, so the values stay fully typed. The alternative — a `@ts-expect-error`
|
|
12
|
-
// or an `any` import — would silence the error by discarding `oneLight` and
|
|
13
|
-
// `vscDarkPlus`'s types, which is trading a build failure for a typing hole at
|
|
14
|
-
// the one site that already proved it can ship broken.
|
|
15
|
-
//
|
|
16
|
-
// ── AND THE SAME APPLIES TO THE 13 LANGUAGE IMPORTS ─────────────────────────
|
|
17
|
-
// Fixing only the styles directory shipped `0.0.8`, which failed on
|
|
18
|
-
// `.../languages/prism/bash` — a FILE imported without its extension, which
|
|
19
|
-
// Node's ESM resolver also refuses. The directory import was merely the FIRST
|
|
20
|
-
// failure, not the defect. Caught by loading the published artifact under Node
|
|
21
|
-
// rather than trusting the local build, which is the whole of PN-015.
|
|
22
|
-
//
|
|
23
|
-
// Removable when upstream's types declare the file paths directly.
|
|
24
|
-
declare module 'react-syntax-highlighter/dist/esm/styles/prism/index.js' {
|
|
25
|
-
export * from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
declare module 'react-syntax-highlighter/dist/esm/languages/prism/*.js' {
|
|
29
|
-
// Upstream types these as `any`-shaped syntax definitions passed straight to
|
|
30
|
-
// `registerLanguage`; the wildcard keeps that contract rather than inventing
|
|
31
|
-
// a stricter one this package cannot verify.
|
|
32
|
-
const language: unknown;
|
|
33
|
-
export default language;
|
|
34
|
-
}
|