react-x11 1.2.0 → 2.0.0
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/README.md +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
|
@@ -2,15 +2,27 @@
|
|
|
2
2
|
// support needed. Plain createElement (no JSX) so the library stays
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
|
-
import React, {
|
|
6
|
-
|
|
5
|
+
import React, {
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useLayoutEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { useApp, useSupports } from '../appcontext.js';
|
|
14
|
+
import { interpolate } from '../styles.js';
|
|
15
|
+
import { capTrim, ThemeProvider, useTheme } from './theme.js';
|
|
7
16
|
import {
|
|
8
17
|
DEFAULT_LABEL_SIZE,
|
|
18
|
+
anchorArea,
|
|
9
19
|
measureLabel,
|
|
10
20
|
movingToward,
|
|
11
21
|
SAFE_HOVER_DELAY,
|
|
12
22
|
screenPoint,
|
|
23
|
+
screenRect,
|
|
13
24
|
useAnchor,
|
|
25
|
+
useAnchorTracking,
|
|
14
26
|
} from './anchor.js';
|
|
15
27
|
|
|
16
28
|
const h = React.createElement;
|
|
@@ -19,43 +31,299 @@ const TOOLTIP_PADDING_X = 8;
|
|
|
19
31
|
|
|
20
32
|
const TOOLTIP_PADDING_Y = 4;
|
|
21
33
|
|
|
34
|
+
// The arrow, across the bubble's edge and out from it. Small on purpose: it
|
|
35
|
+
// is there to say *which* control the hint belongs to, and a big one starts
|
|
36
|
+
// to look like a speech balloon.
|
|
37
|
+
const ARROW_WIDTH = 12;
|
|
38
|
+
const ARROW_DEPTH = 6;
|
|
39
|
+
|
|
40
|
+
// The arrow's base sits this far *inside* the bubble. The two shapes are
|
|
41
|
+
// painted separately and antialiased separately, so meeting them exactly on
|
|
42
|
+
// the edge leaves a hairline of half-covered pixels between them.
|
|
43
|
+
const ARROW_OVERLAP = 1;
|
|
44
|
+
|
|
45
|
+
// There is no default size for a `label` that is not text: the popup is
|
|
46
|
+
// rendered once **hidden** at its natural size — a real layout of the real
|
|
47
|
+
// content, never on screen — and placed from what that measured. See
|
|
48
|
+
// `surface()` for the two lives, and the measuring effect for the order.
|
|
49
|
+
|
|
50
|
+
const isText = (label) =>
|
|
51
|
+
typeof label === 'string' || typeof label === 'number';
|
|
52
|
+
|
|
53
|
+
// Which sides `direction="auto"` tries, in order. Above first, because a
|
|
54
|
+
// hint above the thing it describes covers nothing you are about to click;
|
|
55
|
+
// then below, then out to the sides, which is where a control near the top
|
|
56
|
+
// or bottom of a screen has to put it.
|
|
57
|
+
const AUTO_SIDES = ['top', 'bottom', 'right', 'left'];
|
|
58
|
+
|
|
59
|
+
// Room the popup needs beyond its own size for a side to count as fitting:
|
|
60
|
+
// `anchorRect`'s own 2px gap, and enough left over that the hint is not
|
|
61
|
+
// jammed flush against the edge of the screen.
|
|
62
|
+
const AUTO_MARGIN = 8;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The palette **inside** the bubble, which is the one outside it upside
|
|
66
|
+
* down: a tooltip is drawn in the palette's ink so that it reads as a label
|
|
67
|
+
* over the desktop rather than as another panel of the app, and that makes
|
|
68
|
+
* its surface `text` and its own ink `background`.
|
|
69
|
+
*
|
|
70
|
+
* Content has to be told, or it cannot be written once and stay legible in
|
|
71
|
+
* both schemes — a card that hard-codes light text is invisible on the light
|
|
72
|
+
* bubble a dark palette gives it, which is what a naive component label
|
|
73
|
+
* does. Published through `ThemeProvider`, so both routes agree: a `$token`
|
|
74
|
+
* in the content and a `useTheme()` in it describe the surface the content
|
|
75
|
+
* is actually on.
|
|
76
|
+
*
|
|
77
|
+
* `textMuted` is derived rather than swapped, because the palette's own is a mid
|
|
78
|
+
* grey chosen against the *app's* background. Mixing the bubble's ink
|
|
79
|
+
* towards its surface lands on the muted version of whichever ink this
|
|
80
|
+
* turned out to be.
|
|
81
|
+
*/
|
|
82
|
+
const MUTED = 0.38;
|
|
83
|
+
const BORDERISH = 0.6;
|
|
84
|
+
|
|
85
|
+
// The provider's box fills the bubble and centres what is in it, so a
|
|
86
|
+
// string label still sits in the middle and an element with `flexGrow: 1`
|
|
87
|
+
// still gets the whole rectangle.
|
|
88
|
+
const BUBBLE_CONTENT = Object.freeze({
|
|
89
|
+
flexGrow: 1,
|
|
90
|
+
justifyContent: 'center',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function invertedSurface(theme) {
|
|
94
|
+
return {
|
|
95
|
+
background: theme.text,
|
|
96
|
+
// The bubble is both at once for what is inside it: there is nothing
|
|
97
|
+
// raised off a tooltip, and a card in one that reached for `$surface`
|
|
98
|
+
// would otherwise take the *app's* surface and light up.
|
|
99
|
+
surface: theme.text,
|
|
100
|
+
text: theme.background,
|
|
101
|
+
textMuted:
|
|
102
|
+
interpolate(theme.background, theme.text, MUTED) ?? theme.textMuted,
|
|
103
|
+
border:
|
|
104
|
+
interpolate(theme.background, theme.text, BORDERISH) ?? theme.border,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
22
108
|
/**
|
|
23
|
-
*
|
|
109
|
+
* The one hint that is up, per connection.
|
|
110
|
+
*
|
|
111
|
+
* A tooltip belongs to where the pointer is, and there is one pointer — so
|
|
112
|
+
* two of them on screen at once is never a state anything meant to produce.
|
|
113
|
+
* It happens anyway without a rule like this, because each `Tooltip` only
|
|
114
|
+
* watches its own trigger: the safe-polygon grace that lets a hint with
|
|
115
|
+
* content in it be *reached* (docs/components.md) is exactly a window where
|
|
116
|
+
* one stays up while the pointer has already moved on, and moving on can
|
|
117
|
+
* mean arriving somewhere that shows another.
|
|
118
|
+
*
|
|
119
|
+
* So the trigger taking the hover dismisses whatever else is showing, and
|
|
120
|
+
* showing claims the slot. Keyed by connection, not module-global: one
|
|
121
|
+
* process can drive several roots on several displays, and each display has
|
|
122
|
+
* its own pointer. A WeakMap, so a closed connection takes its entry with
|
|
123
|
+
* it.
|
|
124
|
+
*/
|
|
125
|
+
const showing = new WeakMap();
|
|
126
|
+
|
|
127
|
+
/** Hide the hint that is up, unless it is this one. */
|
|
128
|
+
function dismissOthers(app, self) {
|
|
129
|
+
const current = showing.get(app);
|
|
130
|
+
if (!current || current === self) return;
|
|
131
|
+
showing.delete(app);
|
|
132
|
+
current();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The side to hang a hint off when the caller has not named one.
|
|
137
|
+
*
|
|
138
|
+
* Measured against the **screen**, not the owner window: a popup is a real
|
|
139
|
+
* X window, so the room a tooltip has is the room the display has — the
|
|
140
|
+
* usable part of the monitor the trigger is on (`anchorArea`). The first
|
|
141
|
+
* side it fits on wins, in the order above, and if it fits nowhere the
|
|
142
|
+
* roomiest side does — there the placement is going to be clamped whatever
|
|
143
|
+
* we pick, and the most room is the least clamping.
|
|
144
|
+
*
|
|
145
|
+
* With no display to ask at all this is `'top'`, which is where a tooltip
|
|
146
|
+
* went before there was anything to ask.
|
|
147
|
+
*/
|
|
148
|
+
function autoDirection(node, size) {
|
|
149
|
+
const trigger = screenRect(node);
|
|
150
|
+
const area = anchorArea(node);
|
|
151
|
+
if (!trigger || !area) return 'top';
|
|
152
|
+
const room = {
|
|
153
|
+
top: trigger.y - area.y,
|
|
154
|
+
bottom: area.y + area.height - (trigger.y + trigger.height),
|
|
155
|
+
left: trigger.x - area.x,
|
|
156
|
+
right: area.x + area.width - (trigger.x + trigger.width),
|
|
157
|
+
};
|
|
158
|
+
const needed = (side) =>
|
|
159
|
+
(side === 'top' || side === 'bottom' ? size.height : size.width) +
|
|
160
|
+
AUTO_MARGIN;
|
|
161
|
+
const fits = AUTO_SIDES.find((side) => room[side] >= needed(side));
|
|
162
|
+
if (fits) return fits;
|
|
163
|
+
return AUTO_SIDES.reduce((best, side) =>
|
|
164
|
+
room[side] > room[best] ? side : best,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Where the arrow goes **inside the popup window**, and which way it points.
|
|
170
|
+
*
|
|
171
|
+
* Along the bubble's edge it wants the middle of the trigger, not the middle
|
|
172
|
+
* of the bubble — the two are the same until a screen edge slides the popup
|
|
173
|
+
* and leaves the trigger where it was. Clamped so the triangle stays on the
|
|
174
|
+
* straight part of the edge: pushed into a rounded corner it would grow a
|
|
175
|
+
* notch where the two curves cross.
|
|
176
|
+
*/
|
|
177
|
+
function arrowPlacement(side, rect, trigger, radius) {
|
|
178
|
+
const vertical = side === 'top' || side === 'bottom';
|
|
179
|
+
const span = vertical ? rect.width : rect.height;
|
|
180
|
+
// the middle of the trigger, in the popup's own coordinates
|
|
181
|
+
const wanted = !trigger
|
|
182
|
+
? span / 2
|
|
183
|
+
: vertical
|
|
184
|
+
? trigger.x - rect.x + trigger.width / 2
|
|
185
|
+
: trigger.y - rect.y + trigger.height / 2;
|
|
186
|
+
const margin = radius + ARROW_WIDTH / 2 + 1;
|
|
187
|
+
const center = Math.round(Math.max(margin, Math.min(span - margin, wanted)));
|
|
188
|
+
// how far along the popup the bubble's outer edge is, on the axis the
|
|
189
|
+
// arrow sticks out along
|
|
190
|
+
const reach = (vertical ? rect.height : rect.width) - ARROW_DEPTH;
|
|
191
|
+
|
|
192
|
+
const across = center - ARROW_WIDTH / 2;
|
|
193
|
+
const out = reach - ARROW_OVERLAP;
|
|
194
|
+
const thick = ARROW_DEPTH + ARROW_OVERLAP;
|
|
195
|
+
if (side === 'top') {
|
|
196
|
+
return { left: across, top: out, width: ARROW_WIDTH, height: thick, side };
|
|
197
|
+
}
|
|
198
|
+
if (side === 'bottom') {
|
|
199
|
+
return { left: across, top: 0, width: ARROW_WIDTH, height: thick, side };
|
|
200
|
+
}
|
|
201
|
+
if (side === 'left') {
|
|
202
|
+
return { left: out, top: across, width: thick, height: ARROW_WIDTH, side };
|
|
203
|
+
}
|
|
204
|
+
return { left: 0, top: across, width: thick, height: ARROW_WIDTH, side };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The triangle itself, pointing away from the bubble it grows out of. */
|
|
208
|
+
function paintArrow(ctx, { width, height }, side, color) {
|
|
209
|
+
ctx.fillStyle = color;
|
|
210
|
+
ctx.beginPath();
|
|
211
|
+
if (side === 'top') {
|
|
212
|
+
ctx.moveTo(0, 0);
|
|
213
|
+
ctx.lineTo(width, 0);
|
|
214
|
+
ctx.lineTo(width / 2, height);
|
|
215
|
+
} else if (side === 'bottom') {
|
|
216
|
+
ctx.moveTo(0, height);
|
|
217
|
+
ctx.lineTo(width, height);
|
|
218
|
+
ctx.lineTo(width / 2, 0);
|
|
219
|
+
} else if (side === 'left') {
|
|
220
|
+
ctx.moveTo(0, 0);
|
|
221
|
+
ctx.lineTo(0, height);
|
|
222
|
+
ctx.lineTo(width, height / 2);
|
|
223
|
+
} else {
|
|
224
|
+
ctx.moveTo(width, 0);
|
|
225
|
+
ctx.lineTo(width, height);
|
|
226
|
+
ctx.lineTo(0, height / 2);
|
|
227
|
+
}
|
|
228
|
+
ctx.closePath();
|
|
229
|
+
ctx.fill();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* <Tooltip label direction delay>…</Tooltip> — a hover hint in a `<popup>`,
|
|
24
234
|
* so it can extend past the owner window's bounds.
|
|
25
235
|
*
|
|
26
236
|
* Wraps its children in a row box that carries the hover handlers and the
|
|
27
237
|
* anchor ref. Shows after `delay` ms of hover, hides immediately on leave
|
|
28
238
|
* (and on mousedown — a tooltip lingering over a menu you just opened is
|
|
29
|
-
* the classic annoyance).
|
|
30
|
-
*
|
|
239
|
+
* the classic annoyance).
|
|
240
|
+
*
|
|
241
|
+
* `direction` is which side of the trigger it goes on, and it defaults to
|
|
242
|
+
* `'auto'`: the first side the hint fits on, preferring above. Naming one —
|
|
243
|
+
* `'top'`, `'bottom'`, `'left'`, `'right'` — is a preference rather than a
|
|
244
|
+
* promise either way, since a named side still flips to its opposite rather
|
|
245
|
+
* than opening off-screen, via the same `useAnchor` math `Select` uses.
|
|
246
|
+
* (`placement` is the older name for the same thing and still wins where it
|
|
247
|
+
* is given, so that nothing that named a side has to change.)
|
|
31
248
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
249
|
+
* `label` is usually a string, and then the popup is sized from the
|
|
250
|
+
* **measured** text — synchronously, because `anchorRect` needs the size to
|
|
251
|
+
* decide which side of the trigger the hint fits on, and that is decided
|
|
252
|
+
* during the render that opens it. It can equally be an element — a swatch
|
|
253
|
+
* and a hex code, a shortcut in its own type, a whole card — and then the
|
|
254
|
+
* content is **measured too**: the popup is rendered once `hidden` at its
|
|
255
|
+
* natural size (a real layout of the real content, capped at the screen and
|
|
256
|
+
* at `maxWidth`/`maxHeight`), the size is read back, and the same placement
|
|
257
|
+
* math runs before anything is mapped — so a card whose height depends on
|
|
258
|
+
* what is in it gets the bubble it needs, not a guess (issue #368). Both
|
|
259
|
+
* commits land in the same task and the popup maps last, so it is only ever
|
|
260
|
+
* on screen placed and at its final size. `width`/`height` still pin an
|
|
261
|
+
* axis exactly — give both and the measuring pass is skipped entirely; give
|
|
262
|
+
* one and the other is measured for it, so `width={340}` is a fixed column
|
|
263
|
+
* whose height fits the message. The element fills the bubble and draws its
|
|
264
|
+
* own padding; a string gets the bubble's. The size is settled at open —
|
|
265
|
+
* content that changes size while the hint is up is clipped or short until
|
|
266
|
+
* the next open, which is a hint's lifetime away.
|
|
267
|
+
*
|
|
268
|
+
* On a display that composites, the popup is a real ARGB window: a rounded
|
|
269
|
+
* bubble with a small arrow pointing back at the trigger, and everything the
|
|
270
|
+
* two do not cover left empty. Without a compositor it degrades to the
|
|
271
|
+
* square opaque rectangle it has always been — an arrow there would be a
|
|
272
|
+
* black triangle in a black notch, which is worse than no arrow.
|
|
34
273
|
*/
|
|
35
274
|
export function Tooltip({
|
|
36
275
|
label,
|
|
37
276
|
children,
|
|
38
|
-
|
|
277
|
+
direction = 'auto',
|
|
278
|
+
placement,
|
|
39
279
|
delay = 500,
|
|
40
280
|
fontSize = DEFAULT_LABEL_SIZE,
|
|
281
|
+
width,
|
|
282
|
+
height,
|
|
283
|
+
maxWidth,
|
|
284
|
+
maxHeight,
|
|
285
|
+
style,
|
|
41
286
|
...boxProps
|
|
42
287
|
}) {
|
|
43
288
|
const theme = useTheme();
|
|
289
|
+
const app = useApp();
|
|
44
290
|
const ref = useRef(null);
|
|
291
|
+
const popup = useRef(null);
|
|
45
292
|
const measureAnchor = useAnchor(ref);
|
|
46
293
|
const [rect, setRect] = useState(null);
|
|
294
|
+
// An element label's measured size — what the hidden pass below produced,
|
|
295
|
+
// and what placement reads for as long as the hint is up. Text is
|
|
296
|
+
// measured synchronously instead and never lands here.
|
|
297
|
+
const [bubble, setBubble] = useState(null);
|
|
298
|
+
const [measuring, setMeasuring] = useState(false);
|
|
47
299
|
const timer = useRef(null);
|
|
300
|
+
// Whether there is an arrow at all has to be settled *before* the popup
|
|
301
|
+
// exists — it is part of how big the window is — so this is the display
|
|
302
|
+
// question (`useSupports`) rather than the per-window style block.
|
|
303
|
+
const composited = useSupports('transparency');
|
|
304
|
+
// memoized for identity, not for the arithmetic: a fresh palette every
|
|
305
|
+
// render would re-resolve every `$token` under it
|
|
306
|
+
const surfacePalette = useMemo(() => invertedSurface(theme), [theme]);
|
|
48
307
|
|
|
49
|
-
|
|
308
|
+
// `cancel` and `hide` touch nothing but refs and a state setter, so they
|
|
309
|
+
// can hold still across renders — and `hide` has to: it is this tooltip's
|
|
310
|
+
// identity in the `showing` registry, and one that changed every render
|
|
311
|
+
// would leave the registry holding a stale closure.
|
|
312
|
+
const cancel = useCallback(() => {
|
|
50
313
|
if (timer.current) {
|
|
51
314
|
clearTimeout(timer.current);
|
|
52
315
|
timer.current = null;
|
|
53
316
|
}
|
|
54
|
-
};
|
|
55
|
-
const hide = () => {
|
|
317
|
+
}, []);
|
|
318
|
+
const hide = useCallback(() => {
|
|
56
319
|
cancel();
|
|
320
|
+
if (showing.get(app) === hide) showing.delete(app);
|
|
57
321
|
setRect(null);
|
|
58
|
-
|
|
322
|
+
// the measurement goes with the hint: the next open re-measures, so a
|
|
323
|
+
// label that changed while nothing showed opens at its new size
|
|
324
|
+
setBubble(null);
|
|
325
|
+
setMeasuring(false);
|
|
326
|
+
}, [app, cancel]);
|
|
59
327
|
|
|
60
328
|
// safe-polygon hover (docs/components.md): leaving the trigger *toward*
|
|
61
329
|
// the tooltip keeps it up, so a tooltip with content in it can be
|
|
@@ -65,7 +333,7 @@ export function Tooltip({
|
|
|
65
333
|
cancel();
|
|
66
334
|
timer.current = setTimeout(() => {
|
|
67
335
|
timer.current = null;
|
|
68
|
-
|
|
336
|
+
hide();
|
|
69
337
|
}, SAFE_HOVER_DELAY);
|
|
70
338
|
};
|
|
71
339
|
const onMouseMove = (ev) => {
|
|
@@ -76,20 +344,119 @@ export function Tooltip({
|
|
|
76
344
|
else hide();
|
|
77
345
|
};
|
|
78
346
|
|
|
79
|
-
// a pending timer
|
|
80
|
-
|
|
347
|
+
// neither a pending timer nor a claim on the one visible hint may outlive
|
|
348
|
+
// the component
|
|
349
|
+
useEffect(() => {
|
|
350
|
+
return () => {
|
|
351
|
+
cancel();
|
|
352
|
+
if (showing.get(app) === hide) showing.delete(app);
|
|
353
|
+
};
|
|
354
|
+
}, [app, cancel, hide]);
|
|
81
355
|
|
|
82
|
-
|
|
356
|
+
// `!label` would have thrown away `0`, which is a hint like any other;
|
|
357
|
+
// an empty string is not one
|
|
358
|
+
const hasLabel = !(label == null || label === false || label === '');
|
|
359
|
+
|
|
360
|
+
// The bubble, which is the popup minus whatever the arrow takes: measured
|
|
361
|
+
// text for a string, the caller's numbers where both were given, and the
|
|
362
|
+
// hidden pass's answer for an element — null until that pass has run.
|
|
363
|
+
const bubbleSize = (node) => {
|
|
364
|
+
if (isText(label)) {
|
|
365
|
+
const text = measureLabel(node, label, { size: fontSize });
|
|
366
|
+
return {
|
|
367
|
+
width: width ?? Math.ceil(text.width) + TOOLTIP_PADDING_X * 2,
|
|
368
|
+
height: height ?? Math.ceil(text.height) + TOOLTIP_PADDING_Y * 2,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
if (width != null && height != null) return { width, height };
|
|
372
|
+
return bubble;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// The anchor options for a bubble of this size: which side of the trigger,
|
|
376
|
+
// and the popup grown by the arrow along the axis that faces it.
|
|
377
|
+
const optionsFor = (node, size) => {
|
|
378
|
+
const grow = composited ? ARROW_DEPTH : 0;
|
|
379
|
+
const want = placement ?? direction;
|
|
380
|
+
const side =
|
|
381
|
+
want === 'auto'
|
|
382
|
+
? autoDirection(node, {
|
|
383
|
+
width: size.width + grow,
|
|
384
|
+
height: size.height + grow,
|
|
385
|
+
})
|
|
386
|
+
: want;
|
|
387
|
+
const vertical = side === 'top' || side === 'bottom';
|
|
388
|
+
return {
|
|
389
|
+
placement: side,
|
|
390
|
+
align: 'center',
|
|
391
|
+
width: size.width + (vertical ? 0 : grow),
|
|
392
|
+
height: size.height + (vertical ? grow : 0),
|
|
393
|
+
};
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const tooltipAnchorOptions = () => {
|
|
83
397
|
const node = ref.current;
|
|
84
|
-
if (!node || !
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const next = measureAnchor({ placement, align: 'center', width, height });
|
|
89
|
-
if (next) setRect(next);
|
|
398
|
+
if (!node || !hasLabel) return null;
|
|
399
|
+
const size = bubbleSize(node);
|
|
400
|
+
if (!size) return null;
|
|
401
|
+
return optionsFor(node, size);
|
|
90
402
|
};
|
|
91
403
|
|
|
404
|
+
const place = (options) => {
|
|
405
|
+
const next = measureAnchor(options);
|
|
406
|
+
if (!next) return;
|
|
407
|
+
// claim the slot at the moment there is something to see, so a hint
|
|
408
|
+
// that never made it past its delay never took anything away
|
|
409
|
+
dismissOthers(app, hide);
|
|
410
|
+
showing.set(app, hide);
|
|
411
|
+
setRect(next);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const show = () => {
|
|
415
|
+
if (!ref.current || !hasLabel) return;
|
|
416
|
+
const options = tooltipAnchorOptions();
|
|
417
|
+
// No options with a node and a label means an element label whose size
|
|
418
|
+
// is not known yet: render the popup hidden at its natural size, and
|
|
419
|
+
// the measuring effect below finishes the job.
|
|
420
|
+
if (options) place(options);
|
|
421
|
+
else setMeasuring(true);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// The second half of opening an element label: the commit that set
|
|
425
|
+
// `measuring` rendered the popup hidden — realized, laid out, never
|
|
426
|
+
// mapped — so its natural size is now a fact. Read it back, run the same
|
|
427
|
+
// placement `show()` runs, and re-render placed; a layout effect, so both
|
|
428
|
+
// commits land in the same task, nothing paints in between, and the popup
|
|
429
|
+
// maps only once it is the right size in the right place.
|
|
430
|
+
useLayoutEffect(() => {
|
|
431
|
+
if (!measuring) return;
|
|
432
|
+
setMeasuring(false);
|
|
433
|
+
const wnd = popup.current;
|
|
434
|
+
const node = ref.current;
|
|
435
|
+
if (!wnd || !node) return;
|
|
436
|
+
const size = { width: wnd.width, height: wnd.height };
|
|
437
|
+
setBubble(size);
|
|
438
|
+
place(optionsFor(node, size));
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// keeps the tooltip pinned to its trigger for as long as it is shown: a
|
|
442
|
+
// scrolled ancestor, the trigger's own layout moving it, or the owner
|
|
443
|
+
// window being nudged by the window manager or a script would otherwise
|
|
444
|
+
// leave it pointing at empty space. If the trigger scrolls out of view
|
|
445
|
+
// entirely, the tooltip hides instead of following it there — unlike a
|
|
446
|
+
// web tooltip it is a real window and can drift past the edge of the
|
|
447
|
+
// document it annotates, which reads as a stray popup rather than a hint.
|
|
448
|
+
useAnchorTracking(ref, Boolean(rect), tooltipAnchorOptions, setRect, hide);
|
|
449
|
+
|
|
92
450
|
const onMouseEnter = () => {
|
|
451
|
+
// The pointer has arrived somewhere else, so whatever is still up
|
|
452
|
+
// belongs to where it *was* — drop it now rather than at the end of
|
|
453
|
+
// this one's delay, which would leave two on screen for half a second
|
|
454
|
+
// saying different things about the same pointer.
|
|
455
|
+
//
|
|
456
|
+
// This does not fight the safe polygon. A trigger under an open hint
|
|
457
|
+
// cannot be hovered — the popup is a window above it — so reaching for
|
|
458
|
+
// a hint never crosses the trigger this would fire for.
|
|
459
|
+
dismissOthers(app, hide);
|
|
93
460
|
cancel();
|
|
94
461
|
timer.current = setTimeout(() => {
|
|
95
462
|
timer.current = null;
|
|
@@ -97,49 +464,156 @@ export function Tooltip({
|
|
|
97
464
|
}, delay);
|
|
98
465
|
};
|
|
99
466
|
|
|
467
|
+
/**
|
|
468
|
+
* The popup, in its two lives.
|
|
469
|
+
*
|
|
470
|
+
* **Measuring** (no `rect` yet): `hidden` — realized and laid out, never
|
|
471
|
+
* mapped — with the bubble in flow and no arrow, so the popup's natural
|
|
472
|
+
* size *is* the bubble's. One commit later the effect above has read it
|
|
473
|
+
* back and placed it; nothing is ever on screen unplaced.
|
|
474
|
+
*
|
|
475
|
+
* **Placed**: two absolutely-positioned pieces, the bubble and the arrow
|
|
476
|
+
* beside it. Absolute rather than a flex column, because the two are
|
|
477
|
+
* placed by the same arithmetic that decided how big the window is, and
|
|
478
|
+
* that arithmetic has to run before the window is on screen. Whatever
|
|
479
|
+
* neither covers is the transparent margin the arrow needs to point
|
|
480
|
+
* through.
|
|
481
|
+
*
|
|
482
|
+
* One `<popup>` element for both, so the transition is a configure and a
|
|
483
|
+
* map on the same X window — never a second window — and the label's
|
|
484
|
+
* component instances (their state, their subscriptions) survive from the
|
|
485
|
+
* layout they were measured in to the one that shows them.
|
|
486
|
+
*/
|
|
487
|
+
const surface = () => {
|
|
488
|
+
// the side `anchorRect` settled on, which is the one it was asked for
|
|
489
|
+
// unless a screen edge flipped it
|
|
490
|
+
const side = rect ? (rect.placement ?? 'top') : null;
|
|
491
|
+
const vertical = side === 'top' || side === 'bottom';
|
|
492
|
+
const arrow =
|
|
493
|
+
rect && composited
|
|
494
|
+
? arrowPlacement(
|
|
495
|
+
side,
|
|
496
|
+
rect,
|
|
497
|
+
screenRect(ref.current),
|
|
498
|
+
theme.radiusTooltip,
|
|
499
|
+
)
|
|
500
|
+
: null;
|
|
501
|
+
const inset = arrow ? ARROW_DEPTH : 0;
|
|
502
|
+
const bubbleRect = rect && {
|
|
503
|
+
// the arrow is always on the side facing the trigger, so the bubble is
|
|
504
|
+
// pushed off that edge and fills the rest
|
|
505
|
+
left: side === 'right' ? inset : 0,
|
|
506
|
+
top: side === 'bottom' ? inset : 0,
|
|
507
|
+
width: rect.width - (vertical ? 0 : inset),
|
|
508
|
+
height: rect.height - (vertical ? inset : 0),
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
return h(
|
|
512
|
+
'popup',
|
|
513
|
+
{
|
|
514
|
+
ref: popup,
|
|
515
|
+
theme,
|
|
516
|
+
windowType: 'tooltip',
|
|
517
|
+
transparent: true,
|
|
518
|
+
...(rect
|
|
519
|
+
? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
|
520
|
+
: {
|
|
521
|
+
// measuring: never mapped, so the position is nobody's; the
|
|
522
|
+
// axes the caller pinned stay pinned and the rest are
|
|
523
|
+
// measured, which is what makes `width` alone a fixed column
|
|
524
|
+
// whose height fits the content
|
|
525
|
+
hidden: true,
|
|
526
|
+
x: 0,
|
|
527
|
+
y: 0,
|
|
528
|
+
width: width ?? 'auto',
|
|
529
|
+
height: height ?? 'auto',
|
|
530
|
+
}),
|
|
531
|
+
// in both lives, so the props do not churn between them — inert on
|
|
532
|
+
// an exact axis, the measurement's cap on an auto one
|
|
533
|
+
...(maxWidth != null ? { maxWidth } : null),
|
|
534
|
+
...(maxHeight != null ? { maxHeight } : null),
|
|
535
|
+
style: {
|
|
536
|
+
backgroundColor: theme.text,
|
|
537
|
+
'@supports transparency': { backgroundColor: 'transparent' },
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
h(
|
|
541
|
+
'box',
|
|
542
|
+
{
|
|
543
|
+
role: 'tooltip',
|
|
544
|
+
onMouseEnter: cancel,
|
|
545
|
+
onMouseLeave: hide,
|
|
546
|
+
style: [
|
|
547
|
+
bubbleRect
|
|
548
|
+
? {
|
|
549
|
+
position: 'absolute',
|
|
550
|
+
left: bubbleRect.left,
|
|
551
|
+
top: bubbleRect.top,
|
|
552
|
+
width: bubbleRect.width,
|
|
553
|
+
height: bubbleRect.height,
|
|
554
|
+
}
|
|
555
|
+
: // measuring: in flow, so the popup takes the bubble's
|
|
556
|
+
// natural size
|
|
557
|
+
null,
|
|
558
|
+
{
|
|
559
|
+
justifyContent: 'center',
|
|
560
|
+
backgroundColor: theme.text,
|
|
561
|
+
// No border. On a hint this small a 1px outline in a colour
|
|
562
|
+
// close to the fill is a smudge on the corners and nothing
|
|
563
|
+
// anywhere else; the arrow is what tells you where it belongs.
|
|
564
|
+
...(isText(label)
|
|
565
|
+
? {
|
|
566
|
+
paddingLeft: TOOLTIP_PADDING_X,
|
|
567
|
+
paddingRight: TOOLTIP_PADDING_X,
|
|
568
|
+
}
|
|
569
|
+
: null),
|
|
570
|
+
'@supports transparency': { borderRadius: theme.radiusTooltip },
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
},
|
|
574
|
+
// the content is on the inverted surface, and is given the palette
|
|
575
|
+
// that says so — both routes at once, which is what `ThemeProvider`
|
|
576
|
+
// is for. The filling box it plants keeps a string label centred.
|
|
577
|
+
h(
|
|
578
|
+
ThemeProvider,
|
|
579
|
+
{ value: surfacePalette, style: BUBBLE_CONTENT },
|
|
580
|
+
isText(label)
|
|
581
|
+
? h(
|
|
582
|
+
'text',
|
|
583
|
+
{ style: [capTrim, { color: '$text', fontSize }] },
|
|
584
|
+
label,
|
|
585
|
+
)
|
|
586
|
+
: label,
|
|
587
|
+
),
|
|
588
|
+
),
|
|
589
|
+
arrow &&
|
|
590
|
+
h('canvas', {
|
|
591
|
+
style: {
|
|
592
|
+
position: 'absolute',
|
|
593
|
+
left: arrow.left,
|
|
594
|
+
top: arrow.top,
|
|
595
|
+
width: arrow.width,
|
|
596
|
+
height: arrow.height,
|
|
597
|
+
},
|
|
598
|
+
onDraw: (ctx, info) => paintArrow(ctx, info, arrow.side, theme.text),
|
|
599
|
+
}),
|
|
600
|
+
);
|
|
601
|
+
};
|
|
602
|
+
|
|
100
603
|
return h(
|
|
101
604
|
'box',
|
|
102
605
|
{
|
|
606
|
+
theme,
|
|
103
607
|
ref,
|
|
104
|
-
flexDirection: 'row',
|
|
105
|
-
alignItems: 'center',
|
|
106
608
|
onMouseEnter,
|
|
107
609
|
onMouseMove,
|
|
108
610
|
onMouseLeave,
|
|
109
611
|
onMouseDown: hide,
|
|
110
612
|
...boxProps,
|
|
613
|
+
style: [{ flexDirection: 'row', alignItems: 'center' }, style],
|
|
111
614
|
},
|
|
112
615
|
children,
|
|
113
|
-
rect &&
|
|
114
|
-
h(
|
|
115
|
-
'popup',
|
|
116
|
-
{
|
|
117
|
-
x: rect.x,
|
|
118
|
-
y: rect.y,
|
|
119
|
-
width: rect.width,
|
|
120
|
-
height: rect.height,
|
|
121
|
-
windowType: 'tooltip',
|
|
122
|
-
backgroundColor: theme.text,
|
|
123
|
-
},
|
|
124
|
-
h(
|
|
125
|
-
'box',
|
|
126
|
-
{
|
|
127
|
-
// the pointer reaching the tooltip keeps it up; leaving it
|
|
128
|
-
// dismisses, as if the trigger had been left
|
|
129
|
-
onMouseEnter: cancel,
|
|
130
|
-
onMouseLeave: hide,
|
|
131
|
-
flexGrow: 1,
|
|
132
|
-
borderWidth: 1,
|
|
133
|
-
borderColor: theme.text,
|
|
134
|
-
borderRadius: 3,
|
|
135
|
-
backgroundColor: theme.text,
|
|
136
|
-
justifyContent: 'center',
|
|
137
|
-
paddingLeft: TOOLTIP_PADDING_X,
|
|
138
|
-
paddingRight: TOOLTIP_PADDING_X,
|
|
139
|
-
},
|
|
140
|
-
h('text', { color: theme.background, fontSize }, label),
|
|
141
|
-
),
|
|
142
|
-
),
|
|
616
|
+
(rect || measuring) && surface(),
|
|
143
617
|
);
|
|
144
618
|
}
|
|
145
619
|
|