@lovett/ui 0.0.10 → 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 +666 -13
- package/dist/index.js +1835 -344
- package/dist/index.js.map +1 -1
- package/dist/styles.css +16 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +9 -0
- 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__/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/combobox.tsx +796 -0
- package/src/dropdown-menu.tsx +142 -152
- package/src/index.ts +106 -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 +31 -48
- package/src/popover.tsx +407 -0
- package/src/select.tsx +646 -0
- package/src/stat-row.tsx +108 -70
- package/src/styles.css +16 -2
- package/src/tokens.css +9 -0
- package/src/tooltip.tsx +297 -0
package/src/stat-row.tsx
CHANGED
|
@@ -19,12 +19,26 @@
|
|
|
19
19
|
* applied here). Inner panels carry a fixed `min-height` so a tile never
|
|
20
20
|
* resizes as its value's length changes (ADR-076 D5 zero-layout-shift).
|
|
21
21
|
*
|
|
22
|
+
* Responsive (ADR-145 amendment, 2026-09-04): below the `sm` breakpoint the
|
|
23
|
+
* row becomes a two-column grid and the compounding arrows hide — four
|
|
24
|
+
* tiles abreast on a phone crushed the figures to nothing. Order is
|
|
25
|
+
* preserved, so the chain still reads left-to-right, top-to-bottom.
|
|
26
|
+
*
|
|
27
|
+
* `copyable` (ADR-145 amendment, 2026-09-04): every inner panel becomes a
|
|
28
|
+
* button that copies its figure (the `copyValue` override when a step sets
|
|
29
|
+
* one, else the displayed `value`) and toasts "Copied <label>". Same
|
|
30
|
+
* affordance as ValueChip: a hover lift on the panel and the focus ring.
|
|
31
|
+
* Without it the panel stays inert, so a read-only row never advertises an
|
|
32
|
+
* interaction it doesn't have.
|
|
33
|
+
*
|
|
22
34
|
* Promoted to @lovett/ui under ADR-076 D2 — the catalog of calculator
|
|
23
35
|
* consumers clears ADR-008 D3's 2+ consumer gate.
|
|
24
36
|
*/
|
|
25
37
|
|
|
26
38
|
import { ArrowRight } from 'lucide-react'
|
|
27
|
-
import { Fragment, type ReactNode } from 'react'
|
|
39
|
+
import { Fragment, type CSSProperties, type ReactNode } from 'react'
|
|
40
|
+
import { copyText } from './lib/clipboard'
|
|
41
|
+
import { cn } from './lib/utils'
|
|
28
42
|
|
|
29
43
|
export interface StatRowStep {
|
|
30
44
|
/** Lucide glyph (~16px). Inherits the dark-frame label colour. */
|
|
@@ -36,97 +50,121 @@ export interface StatRowStep {
|
|
|
36
50
|
/** The single pivotal tile — rendered in `--accent` instead of
|
|
37
51
|
* `--brand-ink`. At most one step should set this. */
|
|
38
52
|
pivotal?: boolean
|
|
53
|
+
/** What a copyable tile writes to the clipboard. Defaults to `value`;
|
|
54
|
+
* set it when the display is rounded (e.g. "833.3K") and the exact
|
|
55
|
+
* figure is what the user wants. */
|
|
56
|
+
copyValue?: string
|
|
39
57
|
}
|
|
40
58
|
|
|
41
59
|
export interface StatRowProps {
|
|
42
60
|
steps: StatRowStep[]
|
|
61
|
+
/** Make every tile's figure a copy-to-clipboard button. */
|
|
62
|
+
copyable?: boolean
|
|
43
63
|
className?: string
|
|
44
64
|
}
|
|
45
65
|
|
|
46
|
-
export function StatRow({ steps, className }: StatRowProps) {
|
|
66
|
+
export function StatRow({ steps, copyable = false, className }: StatRowProps) {
|
|
47
67
|
return (
|
|
48
|
-
<div
|
|
49
|
-
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
data-pivotal={s.pivotal ? 'true' : undefined}
|
|
68
|
+
<div
|
|
69
|
+
className={cn('grid grid-cols-2 gap-2 sm:flex sm:items-stretch sm:gap-0', className)}
|
|
70
|
+
>
|
|
71
|
+
{steps.map((s, i) => {
|
|
72
|
+
const panelStyle: CSSProperties = {
|
|
73
|
+
background: 'rgb(var(--bg-card))',
|
|
74
|
+
borderRadius: 10,
|
|
75
|
+
padding: '12px 10px',
|
|
76
|
+
minHeight: 46,
|
|
77
|
+
}
|
|
78
|
+
const figure = (
|
|
79
|
+
<span
|
|
80
|
+
className="font-extrabold tabular-nums"
|
|
62
81
|
style={{
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
background: s.pivotal
|
|
82
|
+
fontSize: 22,
|
|
83
|
+
letterSpacing: '-0.025em',
|
|
84
|
+
lineHeight: 1,
|
|
85
|
+
color: s.pivotal
|
|
68
86
|
? 'rgb(var(--accent))'
|
|
69
|
-
: 'rgb(var(--
|
|
87
|
+
: 'rgb(var(--foreground))',
|
|
70
88
|
}}
|
|
71
89
|
>
|
|
72
|
-
{
|
|
90
|
+
{s.value}
|
|
91
|
+
</span>
|
|
92
|
+
)
|
|
93
|
+
return (
|
|
94
|
+
<Fragment key={i}>
|
|
95
|
+
{i > 0 && (
|
|
96
|
+
<div
|
|
97
|
+
className="hidden sm:flex items-center shrink-0"
|
|
98
|
+
style={{ padding: '0 2px', color: 'rgb(var(--text-muted))' }}
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
>
|
|
101
|
+
<ArrowRight size={14} strokeWidth={2.2} />
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
73
104
|
<div
|
|
74
|
-
|
|
105
|
+
data-pivotal={s.pivotal ? 'true' : undefined}
|
|
75
106
|
style={{
|
|
76
|
-
|
|
77
|
-
padding: '4px 7px 6px',
|
|
78
|
-
color: 'rgb(255 255 255 / 0.7)',
|
|
107
|
+
flex: 1,
|
|
79
108
|
minWidth: 0,
|
|
109
|
+
borderRadius: 'var(--radius-lg)',
|
|
110
|
+
padding: 5,
|
|
111
|
+
background: s.pivotal
|
|
112
|
+
? 'rgb(var(--accent))'
|
|
113
|
+
: 'rgb(var(--brand-ink))',
|
|
80
114
|
}}
|
|
81
115
|
>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
second line and unbalancing the tile row. Keep labels short. */}
|
|
86
|
-
<span
|
|
87
|
-
className="font-bold uppercase"
|
|
88
|
-
title={s.label}
|
|
116
|
+
{/* label row sits on the dark frame */}
|
|
117
|
+
<div
|
|
118
|
+
className="flex items-center"
|
|
89
119
|
style={{
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
overflow: 'hidden',
|
|
94
|
-
textOverflow: 'ellipsis',
|
|
120
|
+
gap: 5,
|
|
121
|
+
padding: '4px 7px 6px',
|
|
122
|
+
color: 'rgb(255 255 255 / 0.7)',
|
|
95
123
|
minWidth: 0,
|
|
96
|
-
flex: 1,
|
|
97
|
-
}}
|
|
98
|
-
>
|
|
99
|
-
{s.label}
|
|
100
|
-
</span>
|
|
101
|
-
</div>
|
|
102
|
-
{/* recessed white inner panel — fixed min-height so the tile
|
|
103
|
-
never resizes as the value's length changes */}
|
|
104
|
-
<div
|
|
105
|
-
className="grid place-items-center text-center"
|
|
106
|
-
style={{
|
|
107
|
-
background: 'rgb(var(--bg-card))',
|
|
108
|
-
borderRadius: 10,
|
|
109
|
-
padding: '12px 10px',
|
|
110
|
-
minHeight: 46,
|
|
111
|
-
}}
|
|
112
|
-
>
|
|
113
|
-
<span
|
|
114
|
-
className="font-extrabold tabular-nums"
|
|
115
|
-
style={{
|
|
116
|
-
fontSize: 22,
|
|
117
|
-
letterSpacing: '-0.025em',
|
|
118
|
-
lineHeight: 1,
|
|
119
|
-
color: s.pivotal
|
|
120
|
-
? 'rgb(var(--accent))'
|
|
121
|
-
: 'rgb(var(--foreground))',
|
|
122
124
|
}}
|
|
123
125
|
>
|
|
124
|
-
{s.
|
|
125
|
-
|
|
126
|
+
<span className="inline-flex shrink-0">{s.icon}</span>
|
|
127
|
+
{/* Single-line cap: long labels truncate with an ellipsis
|
|
128
|
+
(full text on hover via title) instead of wrapping to a
|
|
129
|
+
second line and unbalancing the tile row. Keep labels short. */}
|
|
130
|
+
<span
|
|
131
|
+
className="font-bold uppercase"
|
|
132
|
+
title={s.label}
|
|
133
|
+
style={{
|
|
134
|
+
fontSize: '9.5px',
|
|
135
|
+
letterSpacing: '0.05em',
|
|
136
|
+
whiteSpace: 'nowrap',
|
|
137
|
+
overflow: 'hidden',
|
|
138
|
+
textOverflow: 'ellipsis',
|
|
139
|
+
minWidth: 0,
|
|
140
|
+
flex: 1,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{s.label}
|
|
144
|
+
</span>
|
|
145
|
+
</div>
|
|
146
|
+
{/* recessed white inner panel — fixed min-height so the tile
|
|
147
|
+
never resizes as the value's length changes */}
|
|
148
|
+
{copyable ? (
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
onClick={() => void copyText(s.copyValue ?? s.value, s.label)}
|
|
152
|
+
title={`Copy ${s.label}`}
|
|
153
|
+
aria-label={`Copy ${s.label}: ${s.value}`}
|
|
154
|
+
className="grid w-full place-items-center text-center cursor-pointer transition-[box-shadow,transform] hover:-translate-y-px hover:[box-shadow:var(--shadow-sm)] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]"
|
|
155
|
+
style={{ ...panelStyle, border: 'none' }}
|
|
156
|
+
>
|
|
157
|
+
{figure}
|
|
158
|
+
</button>
|
|
159
|
+
) : (
|
|
160
|
+
<div className="grid place-items-center text-center" style={panelStyle}>
|
|
161
|
+
{figure}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
126
164
|
</div>
|
|
127
|
-
</
|
|
128
|
-
|
|
129
|
-
)
|
|
165
|
+
</Fragment>
|
|
166
|
+
)
|
|
167
|
+
})}
|
|
130
168
|
</div>
|
|
131
169
|
)
|
|
132
170
|
}
|
package/src/styles.css
CHANGED
|
@@ -31,10 +31,24 @@
|
|
|
31
31
|
*
|
|
32
32
|
* Out of scope for Phase 1: .empty, .skel, .field*, .pg-header, .search-bar,
|
|
33
33
|
* .breadcrumbs, .topbar, .checkbox, .radio, .switch, .reasoning-pulse,
|
|
34
|
-
* .chat-md-list, .selection-reply-btn
|
|
35
|
-
*
|
|
34
|
+
* .chat-md-list, .selection-reply-btn. Those land when their consumers
|
|
35
|
+
* (Field, EmptyState, PageHeader, etc.) arrive in later phases.
|
|
36
|
+
* `::selection` landed 2026-09-04 (ADR-145 amendment) — see TEXT SELECTION.
|
|
36
37
|
*/
|
|
37
38
|
|
|
39
|
+
/* ---- TEXT SELECTION ---- */
|
|
40
|
+
/**
|
|
41
|
+
* Explicit, token-backed highlight. Without this, the only `::selection`
|
|
42
|
+
* rule in the bundle came from `@lovett/shell` (`background:
|
|
43
|
+
* var(--selection-bg)`), whose variable is scoped to `.cs-frame` — so at
|
|
44
|
+
* the root it resolved to nothing and highlighted text was invisible.
|
|
45
|
+
* `--selection-bg` is now a public token in tokens.css (light + dark).
|
|
46
|
+
*/
|
|
47
|
+
::selection {
|
|
48
|
+
background: var(--selection-bg);
|
|
49
|
+
color: inherit;
|
|
50
|
+
}
|
|
51
|
+
|
|
38
52
|
/* ---- PANE MASK ---- */
|
|
39
53
|
/**
|
|
40
54
|
* Opaque fill matching the MainLayout content pane, for a sticky element that
|
package/src/tokens.css
CHANGED
|
@@ -95,6 +95,14 @@
|
|
|
95
95
|
--accent-muted: 207 14 15 / 0.12;
|
|
96
96
|
--accent-subtle: 207 14 15 / 0.06;
|
|
97
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);
|
|
98
106
|
/* Ink ON an accent fill. PUBLIC, and theme-independent because --accent is:
|
|
99
107
|
the brand red does not change between light and dark, so its foreground
|
|
100
108
|
must not either. Measures 5.64:1 on --accent.
|
|
@@ -460,6 +468,7 @@
|
|
|
460
468
|
[data-theme="light"], [data-theme="combo"] {
|
|
461
469
|
/* Brand accent — base stays, hover darkens, glow softens */
|
|
462
470
|
--accent-hover: 178 8 9;
|
|
471
|
+
--selection-bg: rgb(var(--info) / 0.24);
|
|
463
472
|
/* Brand red passes on a light card (5.64:1) — see the :root note. */
|
|
464
473
|
--accent-ink: var(--accent);
|
|
465
474
|
/* Solved against the DARKEST tint these inks land on, not the lightest.
|
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
|
+
}
|