@jxsuite/ui 0.0.0 → 0.2.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.
Files changed (105) hide show
  1. package/components/jx-accordion-item.json +281 -0
  2. package/components/jx-accordion.json +33 -0
  3. package/components/jx-action-button.json +268 -34
  4. package/components/jx-action-group.json +131 -0
  5. package/components/jx-button.json +141 -26
  6. package/components/jx-checkbox.json +222 -0
  7. package/components/jx-color-area.json +263 -0
  8. package/components/jx-color-field.json +565 -0
  9. package/components/jx-color-slider.json +216 -0
  10. package/components/jx-combobox.json +551 -0
  11. package/components/jx-dialog.json +416 -0
  12. package/components/jx-divider.json +52 -0
  13. package/components/jx-dot.json +38 -0
  14. package/components/jx-field.json +162 -0
  15. package/components/jx-kbd.json +24 -0
  16. package/components/jx-listbox.json +76 -0
  17. package/components/jx-menu-item.json +6 -13
  18. package/components/jx-menu.json +45 -6
  19. package/components/jx-number-field.json +441 -0
  20. package/components/jx-option.json +350 -0
  21. package/components/jx-popover.json +227 -0
  22. package/components/jx-select.json +637 -0
  23. package/components/jx-spinner.json +129 -0
  24. package/components/jx-split.json +221 -0
  25. package/components/jx-swatch-group.json +107 -0
  26. package/components/jx-swatch.json +262 -0
  27. package/components/jx-switch.json +331 -0
  28. package/components/jx-tab-panel.json +58 -0
  29. package/components/jx-tab.json +296 -0
  30. package/components/jx-tabs.json +124 -0
  31. package/components/jx-textfield.json +645 -0
  32. package/components/jx-toast-host.json +121 -0
  33. package/components/jx-toast.json +255 -0
  34. package/components/jx-toolbar.json +81 -0
  35. package/components/jx-tooltip.json +207 -0
  36. package/components/jx-tree-item.json +325 -0
  37. package/components/jx-tree.json +202 -0
  38. package/icons/list.json +4 -0
  39. package/icons/manifest.json +12 -0
  40. package/package.json +27 -5
  41. package/project.json +164 -153
  42. package/src/behaviors/action-group.ts +255 -0
  43. package/src/behaviors/color-area.ts +303 -0
  44. package/src/behaviors/color-field.ts +465 -0
  45. package/src/behaviors/color-slider.ts +154 -0
  46. package/src/behaviors/combobox.ts +474 -0
  47. package/src/behaviors/dialog.ts +137 -0
  48. package/src/behaviors/listbox.ts +118 -0
  49. package/src/behaviors/menu.ts +80 -48
  50. package/src/behaviors/number-field.ts +161 -0
  51. package/src/behaviors/popover.ts +399 -0
  52. package/src/behaviors/select.ts +154 -0
  53. package/src/behaviors/split.ts +478 -0
  54. package/src/behaviors/swatch-group.ts +297 -0
  55. package/src/behaviors/tabs.ts +319 -0
  56. package/src/behaviors/textfield.ts +164 -0
  57. package/src/behaviors/toast-host.ts +241 -0
  58. package/src/behaviors/toast.ts +261 -0
  59. package/src/behaviors/toolbar.ts +413 -0
  60. package/src/behaviors/tooltip.ts +719 -0
  61. package/src/behaviors/tree.ts +830 -0
  62. package/src/color.ts +433 -0
  63. package/src/documents.ts +91 -1
  64. package/src/index.ts +41 -1
  65. package/src/loaders.ts +47 -0
  66. package/src/testing/anchor-support.ts +28 -0
  67. package/src/testing/popover-shim.ts +39 -6
  68. package/src/theme.ts +30 -6
  69. package/stylebook/jx-accordion-item.json +195 -0
  70. package/stylebook/jx-accordion.json +220 -0
  71. package/stylebook/jx-action-button.json +191 -23
  72. package/stylebook/jx-action-group.json +335 -0
  73. package/stylebook/jx-button.json +129 -26
  74. package/stylebook/jx-checkbox.json +245 -0
  75. package/stylebook/jx-color-area.json +272 -0
  76. package/stylebook/jx-color-field.json +261 -0
  77. package/stylebook/jx-color-slider.json +220 -0
  78. package/stylebook/jx-combobox.json +332 -0
  79. package/stylebook/jx-dialog.json +186 -0
  80. package/stylebook/jx-divider.json +125 -0
  81. package/stylebook/jx-dot.json +92 -0
  82. package/stylebook/jx-field.json +313 -0
  83. package/stylebook/jx-icon.json +4 -0
  84. package/stylebook/jx-kbd.json +90 -0
  85. package/stylebook/jx-listbox.json +337 -0
  86. package/stylebook/jx-menu.json +0 -1
  87. package/stylebook/jx-number-field.json +203 -0
  88. package/stylebook/jx-option.json +285 -0
  89. package/stylebook/jx-popover.json +123 -0
  90. package/stylebook/jx-select.json +407 -0
  91. package/stylebook/jx-spinner.json +111 -0
  92. package/stylebook/jx-split.json +170 -0
  93. package/stylebook/jx-swatch-group.json +175 -0
  94. package/stylebook/jx-swatch.json +249 -0
  95. package/stylebook/jx-switch.json +240 -0
  96. package/stylebook/jx-tab-panel.json +147 -0
  97. package/stylebook/jx-tab.json +201 -0
  98. package/stylebook/jx-tabs.json +278 -0
  99. package/stylebook/jx-textfield.json +309 -0
  100. package/stylebook/jx-toast-host.json +157 -0
  101. package/stylebook/jx-toast.json +266 -0
  102. package/stylebook/jx-toolbar.json +435 -0
  103. package/stylebook/jx-tooltip.json +162 -0
  104. package/stylebook/jx-tree-item.json +258 -0
  105. package/stylebook/jx-tree.json +429 -0
@@ -0,0 +1,719 @@
1
+ /**
2
+ * `jx-tooltip`'s behaviour sidecar — deliberately the SMALL half of a split contract.
3
+ *
4
+ * HTML now has a declarative answer to "show this thing when the reader takes an interest in that
5
+ * control": `interestfor` on the invoker, `popover="hint"` on the target, and
6
+ * `interest-delay-start` / `interest-delay-end` in CSS. Chrome 152 ships all of it — measured, not
7
+ * assumed: `interestForElement` is on `HTMLButtonElement.prototype` and resolves the attribute to
8
+ * its target, `InterestEvent` is a constructor, and all three delay properties pass `CSS.supports`.
9
+ * Firefox and Safari have not shipped it, and `@jxsuite/ui` publishes to site authors rather than
10
+ * to one browser. So the DECLARATIVE PATH IS THE PRIMARY ONE and this module is what stands in
11
+ * where it is missing.
12
+ *
13
+ * The split is decided per PAIR rather than per engine. {@link bindTooltip} binds nothing when the
14
+ * engine knows interest invokers AND the trigger actually declares `interestfor` at this tip —
15
+ * there the timers, the hover grace and Escape are all the platform's. Anything else (an older
16
+ * engine, or a consumer who wired the tip up with `for` instead) gets them from here. So
17
+ * {@link onTooltipReady} resolves EITHER wiring: an `interestfor` control is the shape the kit tells
18
+ * authors to prefer, and on Firefox and Safari it is the shape that shows nothing at all without
19
+ * this module.
20
+ *
21
+ * "AN OLDER ENGINE" INCLUDES THE DESKTOP APP. Studio ships inside Electrobun's CEF, and the pinned
22
+ * release is Chromium 147 (`vendor/electrobun/package/src/shared/cef-version.ts`) — five releases
23
+ * short of the attribute. So until that bump reaches 152 the ONLY end-user path to Studio runs the
24
+ * fallback for every hinted button in its chrome, several hundred of them, and this module is not a
25
+ * corner for other people's browsers but the behaviour a user sees. Three consequences are designed
26
+ * for rather than tolerated: focus opens the tip only when it is KEYBOARD focus, the way the
27
+ * platform's interest-on-focus does, so a click does not pin a tip open; Escape is ONE listener per
28
+ * root shared by every bound tip, not one per tip on the document, so a keystroke in Studio walks
29
+ * one handler rather than one per button; and a tip that is MOVED — a keyed `$map` reordering its
30
+ * rows, a button re-parented into a toolbar — keeps its binding, because the runtime fires
31
+ * `disconnectedCallback` for a move and never re-runs `onMount`, so an unmount that disposed at
32
+ * once would strip a reordered row of its tooltip for good.
33
+ *
34
+ * IT ADDS LISTENERS TO AN ELEMENT THE KIT DOES NOT OWN, and that is said out loud rather than left
35
+ * to be discovered in review. specs/ui.md §2 principle 5 forbids WRITING ATTRIBUTES or styles on
36
+ * another element; `behaviors/menu.ts` already sets `row.tabIndex` on a slotted row, so a listener
37
+ * — which changes nothing about the element and is taken off again by the disposer
38
+ * {@link bindTooltip} returns — is inside the rule as written. Nothing here writes an attribute, a
39
+ * property or a style on a trigger.
40
+ *
41
+ * The explicit hide is not belt and braces either. On an engine that does not know `popover="hint"`
42
+ * the value is INVALID, and `popover`'s invalid-value default is `manual`: no light dismiss, no
43
+ * Escape, and a tip that sticks until reload.
44
+ *
45
+ * The one thing here that is not about `jx-tooltip`'s own element is {@link mintHintId}: the id stem
46
+ * a CONTROL that renders a tip for its `hint` needs, and cannot mint for itself. It lives with the
47
+ * tip rather than with the buttons because it is the pair's contract — the tip's id, the control's
48
+ * id, and the three attributes that name one from the other — and one module is what keeps the two
49
+ * elements that use it from ever minting the same stem.
50
+ *
51
+ * @docs extending/ui-kit
52
+ */
53
+
54
+ import { close, openAt, supportsAnchorPositioning } from "./popover.ts";
55
+ import type { PopoverElement } from "./popover.ts";
56
+
57
+ /** The reactive scope a `jx-tooltip` document hands its handlers. */
58
+ export interface TooltipState {
59
+ open?: boolean;
60
+ x?: number;
61
+ y?: number;
62
+ delay?: number;
63
+ arrow?: boolean;
64
+ flipped?: boolean;
65
+ /** A `position-area` value the anchored rule reads. */
66
+ placement?: string;
67
+ /** Whether the platform is placing the tip against the control it was shown from. */
68
+ anchored?: boolean;
69
+ [key: string]: unknown;
70
+ }
71
+
72
+ /**
73
+ * How long a tip stays up after the pointer leaves the control, in milliseconds.
74
+ *
75
+ * SC 1.4.13's "hoverable" is the reason there is a grace at all: a reader with a pointer-driven
76
+ * magnifier has to be able to travel from the control onto the tip without it vanishing on the way,
77
+ * and the two are separated by the arrow's own offset. Entering the tip cancels the pending hide,
78
+ * so once the pointer is on it the tip is persistent.
79
+ */
80
+ export const TOOLTIP_HIDE_GRACE_MS = 150;
81
+
82
+ /** How far below the control the tip sits, in pixels — the gap the arrow fills. */
83
+ const TOOLTIP_OFFSET = 6;
84
+
85
+ /** How close to the viewport edge the tip may come, in pixels. */
86
+ const MARGIN = 4;
87
+
88
+ /**
89
+ * What each bound instance is holding, keyed by its own reactive scope: the tip, so that
90
+ * {@link onTooltipUnmount} can tell a move from a removal, and the disposer.
91
+ */
92
+ const bindings = new WeakMap<object, { tip: HTMLElement; dispose: () => void }>();
93
+
94
+ /**
95
+ * The one Escape listener each root carries, and the dismissers of every tip bound within it.
96
+ *
97
+ * Per ROOT rather than per tip, because the listener has to be on an ancestor that sees the
98
+ * keystroke wherever focus is — a tip hides on Escape whether or not its control is focused — and a
99
+ * capture listener on the document per tip is one handler per hinted button on every keystroke. The
100
+ * set is what Escape walks instead, and only a dismisser whose tip is showing does anything.
101
+ */
102
+ const escapes = new WeakMap<Node, { listener: EventListener; dismiss: Set<() => void> }>();
103
+
104
+ /**
105
+ * Watch `root` for Escape on behalf of one binding, sharing the listener with every other binding
106
+ * in that root, and hand back the un-watch.
107
+ *
108
+ * The listener goes on with the first dismisser and comes off with the last, so a document with no
109
+ * bound tip carries nothing.
110
+ */
111
+ function watchEscape(root: Node, dismiss: () => void): () => void {
112
+ let entry = escapes.get(root);
113
+ if (!entry) {
114
+ const set = new Set<() => void>();
115
+ const listener = (event: Event): void => {
116
+ if ((event as KeyboardEvent).key !== "Escape") {
117
+ return;
118
+ }
119
+ for (const fn of set) {
120
+ fn();
121
+ }
122
+ };
123
+ root.addEventListener("keydown", listener, true);
124
+ entry = { dismiss: set, listener };
125
+ escapes.set(root, entry);
126
+ }
127
+ const { dismiss: set, listener } = entry;
128
+ set.add(dismiss);
129
+ return () => {
130
+ set.delete(dismiss);
131
+ if (set.size === 0 && escapes.get(root)?.listener === listener) {
132
+ root.removeEventListener("keydown", listener, true);
133
+ escapes.delete(root);
134
+ }
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Whether the platform is showing this tip, for an engine whose selector knows the answer.
140
+ *
141
+ * The binding's own `visible` flag covers what the binding showed; this covers a tip a host showed
142
+ * in code, so that Escape still dismisses it. Guarded, because `:popover-open` is a parse error on
143
+ * an engine without the popover API and a selector that throws would take Escape down with it.
144
+ */
145
+ function popoverOpen(tip: HTMLElement): boolean {
146
+ try {
147
+ return tip.matches(":popover-open");
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Whether the trigger's focus is the kind a tip should answer at once: keyboard focus.
155
+ *
156
+ * The platform's interest-on-focus is keyboard-only — a click gives a control focus too, and a tip
157
+ * that opened on every click and stayed until focus left would be pinned open under the pointer on
158
+ * the fallback path while the declarative path showed nothing. `:focus-visible` is the platform's
159
+ * own verdict on that and is asked first, of the element that actually took the focus (the event's
160
+ * target, which for a kit button is the trigger itself and for a consumer's wrapper is a
161
+ * descendant); `pointerHeld` — a pointer is down on the trigger, so whatever focus arrives now is
162
+ * the click's — is the answer where the selector is not known, and the tie-break on an engine whose
163
+ * `:focus-visible` is merely `:focus`.
164
+ */
165
+ function keyboardFocus(focused: EventTarget | null, pointerHeld: boolean): boolean {
166
+ if (pointerHeld) {
167
+ return false;
168
+ }
169
+ if (!(focused instanceof Element)) {
170
+ return true;
171
+ }
172
+ try {
173
+ return focused.matches(":focus-visible");
174
+ } catch {
175
+ return true;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The re-measure each ANCHORED, SHOWING tip is running, keyed by its own reactive scope.
181
+ *
182
+ * Separate from {@link disposers} because the two have different lifetimes: a binding lives from
183
+ * mount to unmount, and a watch lives from one toggle to the next. {@link onTooltipToggle} starts it
184
+ * on an anchored open and stops it on close; {@link onTooltipUnmount} stops it for a tip removed
185
+ * while it was showing, which is the one close the platform never announces.
186
+ */
187
+ const watchers = new WeakMap<object, () => void>();
188
+
189
+ /**
190
+ * Whether this engine implements interest invokers.
191
+ *
192
+ * Probed on `HTMLButtonElement.prototype` rather than by `CSS.supports`, because the CSS delay
193
+ * properties and the HTML attribute have shipped separately before and it is the ATTRIBUTE that
194
+ * decides whether anything shows at all.
195
+ *
196
+ * @returns True when `interestfor` resolves to an element on this engine.
197
+ */
198
+ export function supportsInterestInvokers(): boolean {
199
+ return (
200
+ typeof HTMLButtonElement === "function" && "interestForElement" in HTMLButtonElement.prototype
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Whether the platform will show this tip from this trigger without any help.
206
+ *
207
+ * Both halves are required: an engine with interest invokers still shows nothing for a trigger that
208
+ * never declared `interestfor`, which is exactly the shape a consumer writes when they wire the tip
209
+ * up through the element's own `for` instead.
210
+ */
211
+ function platformOwns(tip: HTMLElement, trigger: Element): boolean {
212
+ return (
213
+ supportsInterestInvokers() && tip.id !== "" && trigger.getAttribute("interestfor") === tip.id
214
+ );
215
+ }
216
+
217
+ /** Show the tip from its control, so the platform knows what the hint belongs to. */
218
+ function showTip(tip: HTMLElement, source: Element | null): void {
219
+ openAt(tip as PopoverElement, source);
220
+ }
221
+
222
+ /** Hide the tip. `open` and `data-open` follow from the platform's own toggle. */
223
+ function hideTip(tip: HTMLElement): void {
224
+ close(tip as PopoverElement);
225
+ }
226
+
227
+ /**
228
+ * Bind a tip to the control it describes, and hand back the disposer that unbinds it.
229
+ *
230
+ * The two events `title=` never handled are the point of this function. `focusin` shows the tip
231
+ * IMMEDIATELY — a keyboard reader who tabbed to the control has already committed, and a delay
232
+ * there is only a stutter. `pointerenter` shows it after the element's own `delay`, because a
233
+ * pointer crossing a toolbar passes over every control in it. Focus that a CLICK gave the control
234
+ * counts for neither: the hover already scheduled the tip, and the focus is not one the reader is
235
+ * reading — {@link keyboardFocus} is what tells the two apart.
236
+ *
237
+ * @param tip The `jx-tooltip` element.
238
+ * @param trigger The control it describes. Nothing is written on it.
239
+ * @returns A disposer that removes every listener this added.
240
+ */
241
+ export function bindTooltip(tip: HTMLElement, trigger: Element): () => void {
242
+ if (platformOwns(tip, trigger)) {
243
+ return () => {
244
+ /* The platform owns this pair; nothing was bound, so there is nothing to unbind. */
245
+ };
246
+ }
247
+ const root = tip.getRootNode();
248
+ let showTimer: ReturnType<typeof setTimeout> | null = null;
249
+ let hideTimer: ReturnType<typeof setTimeout> | null = null;
250
+ /*
251
+ * SC 1.4.13 "persistent": the content stays until the hover OR focus trigger is removed, so the
252
+ * two are counted separately rather than treated as one channel. A pointer that merely crosses a
253
+ * control the reader has TABBED to removes the hover trigger and no more, and a blur while the
254
+ * pointer rests on the tip removes the focus trigger and no more — either one alone hiding the
255
+ * tip is the criterion failing.
256
+ */
257
+ let onTrigger = false;
258
+ let onTip = false;
259
+ let focused = false;
260
+ /** A pointer is down on the trigger, so a focus that arrives now is the click's. */
261
+ let pointerHeld = false;
262
+ /** Whether this binding showed the tip and has not hidden it since. */
263
+ let visible = false;
264
+
265
+ const cancel = (): void => {
266
+ if (showTimer !== null) {
267
+ clearTimeout(showTimer);
268
+ showTimer = null;
269
+ }
270
+ if (hideTimer !== null) {
271
+ clearTimeout(hideTimer);
272
+ hideTimer = null;
273
+ }
274
+ };
275
+ const show = (): void => {
276
+ cancel();
277
+ visible = true;
278
+ showTip(tip, trigger);
279
+ };
280
+ const hide = (): void => {
281
+ visible = false;
282
+ hideTip(tip);
283
+ };
284
+ /** Hide, unless a trigger the reader has not removed is still holding the tip up. */
285
+ const hideIfIdle = (): void => {
286
+ cancel();
287
+ if (onTrigger || onTip || focused) {
288
+ return;
289
+ }
290
+ hide();
291
+ };
292
+ const showLater = (): void => {
293
+ cancel();
294
+ const delay = Number((tip as { delay?: unknown }).delay) || 0;
295
+ showTimer = setTimeout(show, delay);
296
+ };
297
+ const hideLater = (): void => {
298
+ cancel();
299
+ hideTimer = setTimeout(hideIfIdle, TOOLTIP_HIDE_GRACE_MS);
300
+ };
301
+ const onPointerEnter = (): void => {
302
+ onTrigger = true;
303
+ showLater();
304
+ };
305
+ const onPointerLeave = (): void => {
306
+ onTrigger = false;
307
+ // A pointer dragged off the control releases nowhere the trigger can hear; this is the release.
308
+ pointerHeld = false;
309
+ hideLater();
310
+ };
311
+ const onPointerDown = (): void => {
312
+ pointerHeld = true;
313
+ };
314
+ const onPointerUp = (): void => {
315
+ pointerHeld = false;
316
+ };
317
+ const onFocusIn = (event: Event): void => {
318
+ /*
319
+ * Only KEYBOARD focus is a trigger, matching the platform's interest-on-focus. A click's focus
320
+ * neither shows the tip nor holds it up: the hover is what shows it, after `delay`, and the
321
+ * hover leaving is what hides it — so a clicked button is not left with a tip pinned to it for
322
+ * as long as it keeps the focus.
323
+ */
324
+ const keyboard = keyboardFocus(event.target, pointerHeld);
325
+ pointerHeld = false;
326
+ if (!keyboard) {
327
+ return;
328
+ }
329
+ focused = true;
330
+ show();
331
+ };
332
+ const onFocusOut = (): void => {
333
+ focused = false;
334
+ hideIfIdle();
335
+ };
336
+ const onTipEnter = (): void => {
337
+ onTip = true;
338
+ cancel();
339
+ };
340
+ const onTipLeave = (): void => {
341
+ onTip = false;
342
+ hideLater();
343
+ };
344
+ /**
345
+ * Escape is DISMISSAL, which outranks every live trigger — SC 1.4.13 asks for both. Reached
346
+ * through the root's one shared listener, and a no-op for a tip that is not showing, so an Escape
347
+ * in a chrome of several hundred hinted buttons hides the one tip that is up.
348
+ */
349
+ const onEscape = (): void => {
350
+ if (!visible && !popoverOpen(tip)) {
351
+ return;
352
+ }
353
+ cancel();
354
+ hide();
355
+ };
356
+
357
+ const listeners: [EventTarget, string, EventListener][] = [
358
+ [trigger, "pointerenter", onPointerEnter],
359
+ [trigger, "pointerleave", onPointerLeave],
360
+ [trigger, "pointerdown", onPointerDown],
361
+ [trigger, "pointerup", onPointerUp],
362
+ [trigger, "focusin", onFocusIn],
363
+ [trigger, "focusout", onFocusOut],
364
+ // SC 1.4.13 hoverable: the pointer may rest on the tip itself, and the tip stays.
365
+ [tip, "pointerenter", onTipEnter],
366
+ [tip, "pointerleave", onTipLeave],
367
+ ];
368
+ for (const [target, type, listener] of listeners) {
369
+ target.addEventListener(type, listener, true);
370
+ }
371
+ const unwatch = watchEscape(root, onEscape);
372
+ return () => {
373
+ cancel();
374
+ unwatch();
375
+ for (const [target, type, listener] of listeners) {
376
+ target.removeEventListener(type, listener, true);
377
+ }
378
+ };
379
+ }
380
+
381
+ /**
382
+ * The control this tip is WIRED to, whichever of the two wirings the consumer used.
383
+ *
384
+ * The element's own `for` first, then a trigger that declares `interestfor` at it. Both are a
385
+ * wiring rather than a mention, which is why the fallback binds either of them: on an engine
386
+ * without interest invokers `interestfor` shows nothing by itself, and that engine is the whole
387
+ * reason this module exists. A tip whose id is not a plain identifier takes only the first route —
388
+ * the second is an attribute selector, and an id carrying a quote would make it throw rather than
389
+ * miss.
390
+ */
391
+ function triggerOf(state: TooltipState, tip: HTMLElement): HTMLElement | null {
392
+ const root = tip.getRootNode() as Document | ShadowRoot;
393
+ const named = typeof state["for"] === "string" ? state["for"] : "";
394
+ const byFor = named === "" ? null : (root.getElementById?.(named) ?? null);
395
+ if (byFor) {
396
+ return byFor;
397
+ }
398
+ const own = tip.id;
399
+ if (!/^[\w-]+$/.test(own)) {
400
+ return null;
401
+ }
402
+ return root.querySelector<HTMLElement>(`[interestfor="${own}"]`);
403
+ }
404
+
405
+ /**
406
+ * The control this tip is placed against, which is a wider question than which control wired it.
407
+ *
408
+ * {@link triggerOf}'s two routes first, then a control that merely describes itself with the tip —
409
+ * the naming the element cannot write for itself, and the one shape where the tip is placed against
410
+ * a control that never asked for it (a host showing the tip in code). Same identifier guard.
411
+ */
412
+ function anchorOf(state: TooltipState, tip: HTMLElement): HTMLElement | null {
413
+ const wired = triggerOf(state, tip);
414
+ if (wired) {
415
+ return wired;
416
+ }
417
+ const root = tip.getRootNode() as Document | ShadowRoot;
418
+ const own = tip.id;
419
+ if (!/^[\w-]+$/.test(own)) {
420
+ return null;
421
+ }
422
+ return root.querySelector<HTMLElement>(`[aria-describedby~="${own}"]`);
423
+ }
424
+
425
+ /**
426
+ * Put the tip under the control it describes, flipping above it when there is no room below.
427
+ *
428
+ * This is NOT `clampIntoViewport`, and the difference is the whole reason it is written out: a
429
+ * panel that would run off the bottom slides up until it fits, which for a tip means parking over
430
+ * the control the reader is pointing at. A tip flips to the other side of its control instead.
431
+ *
432
+ * Measured after a frame, because an unlaid-out tip is zero high and would never appear to overflow
433
+ * anything; a zero-size control (a test DOM with no layout) is left alone entirely.
434
+ *
435
+ * The flip is written back as `flipped`, because the arrow has to follow it. The part is pinned to
436
+ * one edge in CSS and cannot know which side of the control the tip landed on; a tip above its
437
+ * control with an arrow still on its block-start edge points at the ceiling.
438
+ *
439
+ * @param state The tip's reactive state, whose `x`, `y` and `flipped` this writes.
440
+ * @param tip The `jx-tooltip` element.
441
+ */
442
+ export function placeTooltip(state: TooltipState, tip: HTMLElement): void {
443
+ const anchor = anchorOf(state, tip);
444
+ if (!anchor) {
445
+ return;
446
+ }
447
+ const box = anchor.getBoundingClientRect();
448
+ if (box.width === 0 && box.height === 0) {
449
+ return;
450
+ }
451
+ state.x = Math.round(box.left);
452
+ state.y = Math.round(box.bottom + TOOLTIP_OFFSET);
453
+ state.flipped = false;
454
+ const measure = (): void => {
455
+ const own = tip.getBoundingClientRect();
456
+ if (own.width === 0 && own.height === 0) {
457
+ return;
458
+ }
459
+ if (own.right > window.innerWidth - MARGIN) {
460
+ state.x = Math.max(MARGIN, Math.round(window.innerWidth - own.width - MARGIN));
461
+ }
462
+ if (own.bottom > window.innerHeight - MARGIN) {
463
+ state.y = Math.max(MARGIN, Math.round(box.top - own.height - TOOLTIP_OFFSET));
464
+ state.flipped = true;
465
+ }
466
+ };
467
+ if (typeof requestAnimationFrame === "function") {
468
+ requestAnimationFrame(measure);
469
+ } else {
470
+ measure();
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Which side of its control an ANCHORED tip is on, kept current for as long as it is showing, so
476
+ * the arrow can follow a flip the element did not make — including one the platform makes LATER.
477
+ *
478
+ * `flipped` is the platform's answer read back — a tip whose box sits above its control's — and
479
+ * nothing here moves the tip. It is measured a frame after the tip shows, because an unlaid-out tip
480
+ * is zero high and has no side yet, and then again on every event that can move an anchored box
481
+ * while it is open: the platform re-places the tip against its control through scroll and resize
482
+ * and re-runs the try options when room runs out, and the arrow pinned in CSS cannot hear any of
483
+ * that. One measurement was right for the frame the tip appeared in and wrong for every flip after
484
+ * it (#310).
485
+ *
486
+ * **The causes, and why each is listened to where it is.** `scroll` is taken at the window in the
487
+ * CAPTURE phase because scroll events do not bubble, and the tip's control may sit in a nested
488
+ * scroller — an inspector panel, a menu — whose scrolling the window would otherwise never hear.
489
+ * `resize` is the window's own. A `ResizeObserver` on the tip and its control covers the rest — the
490
+ * tip's text arriving late, or the control growing — and is cheap at two elements, so it is taken
491
+ * where the engine has one and simply not taken elsewhere. Every cause SCHEDULES rather than
492
+ * measures: a scroll fires per pixel and a measurement forces layout, so they are coalesced to one
493
+ * `getBoundingClientRect` pair per animation frame.
494
+ *
495
+ * The watch is torn down on close, and the disposer is returned as well as kept, so a host that
496
+ * called this itself can stop it. Calling it again for the same state replaces the watch rather
497
+ * than doubling it. A tip with no control has no side to be on and is not watched; an engine with
498
+ * no animation frames (a DOM with no layout) measures once, synchronously, and watches nothing: it
499
+ * has no frames to coalesce to and nothing that scrolls.
500
+ *
501
+ * @param state The tip's reactive state, whose `flipped` this writes.
502
+ * @param tip The `jx-tooltip` element.
503
+ * @returns A disposer that stops the watch and cancels a measurement it had scheduled.
504
+ */
505
+ export function measureAnchoredFlip(state: TooltipState, tip: HTMLElement): () => void {
506
+ watchers.get(state)?.();
507
+ const anchor = anchorOf(state, tip);
508
+ const measure = (): void => {
509
+ if (!anchor || state.anchored !== true) {
510
+ return;
511
+ }
512
+ const own = tip.getBoundingClientRect();
513
+ const box = anchor.getBoundingClientRect();
514
+ if (own.width === 0 && own.height === 0) {
515
+ return;
516
+ }
517
+ state.flipped = own.bottom <= box.top;
518
+ };
519
+ if (!anchor || typeof requestAnimationFrame !== "function") {
520
+ /* No control to be on a side of, or no frames to coalesce to: one synchronous reading, which
521
+ is nothing at all in the first case, and no watch. */
522
+ measure();
523
+ return () => {
524
+ /* Measured once and watched nothing, so there is nothing to stop. */
525
+ };
526
+ }
527
+ let frame: number | null = null;
528
+ const run = (): void => {
529
+ frame = null;
530
+ measure();
531
+ };
532
+ /** Ask for one measurement in the next frame, however many causes ask before it arrives. */
533
+ const schedule = (): void => {
534
+ if (frame === null) {
535
+ frame = requestAnimationFrame(run);
536
+ }
537
+ };
538
+ /* Passive, because nothing here ever prevents a scroll, so the engine need not wait for the
539
+ listener before it scrolls. Removal keys on `capture` alone, which is why it is spelled twice. */
540
+ window.addEventListener("scroll", schedule, { capture: true, passive: true });
541
+ window.addEventListener("resize", schedule);
542
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(schedule) : null;
543
+ if (observer) {
544
+ observer.observe(tip);
545
+ observer.observe(anchor);
546
+ }
547
+ const stop = (): void => {
548
+ if (frame !== null) {
549
+ cancelAnimationFrame(frame);
550
+ frame = null;
551
+ }
552
+ window.removeEventListener("scroll", schedule, { capture: true });
553
+ window.removeEventListener("resize", schedule);
554
+ observer?.disconnect();
555
+ if (watchers.get(state) === stop) {
556
+ watchers.delete(state);
557
+ }
558
+ };
559
+ watchers.set(state, stop);
560
+ schedule();
561
+ return stop;
562
+ }
563
+
564
+ /**
565
+ * The platform's `beforetoggle`: where `anchored` is decided, before the tip is laid out.
566
+ *
567
+ * An opening from a control the platform names (`source` on the event, which an `interestfor`
568
+ * trigger and `showPopover({ source })` both set) or that the binding knows is anchored; a closing
569
+ * clears it. A tip shown from nothing — a host that placed it itself — keeps its coordinates.
570
+ *
571
+ * @param state The tip's reactive state.
572
+ * @param event The platform's ToggleEvent.
573
+ */
574
+ export function onTooltipBeforeToggle(state: TooltipState, event: Event): void {
575
+ const tip = event.currentTarget;
576
+ if (!(tip instanceof HTMLElement)) {
577
+ return;
578
+ }
579
+ const opening = (event as { newState?: string }).newState === "open";
580
+ const { source } = event as Event & { source?: unknown };
581
+ const from = source instanceof Element ? source : anchorOf(state, tip);
582
+ state.anchored = opening && from !== null;
583
+ if (!opening) {
584
+ state.flipped = false;
585
+ }
586
+ }
587
+
588
+ /**
589
+ * The platform's toggle: the one source of truth for `open`, and where a shown tip is placed.
590
+ *
591
+ * Placing on toggle rather than on the gesture is what makes the declarative path work at all — an
592
+ * `interestfor` trigger shows the tip without telling the element anything, and this is the first
593
+ * moment the element hears about it. An anchored tip on an engine that positions by anchor is not
594
+ * placed here at all: the platform has it, and the element only reads back which side it chose, for
595
+ * as long as it is showing. The closing toggle is where that reading stops.
596
+ *
597
+ * @param state The tip's reactive state.
598
+ * @param event The platform's ToggleEvent.
599
+ */
600
+ export function onTooltipToggle(state: TooltipState, event: Event): void {
601
+ const tip = event.currentTarget;
602
+ if (!(tip instanceof HTMLElement)) {
603
+ return;
604
+ }
605
+ const opening = (event as { newState?: string }).newState === "open";
606
+ state.open = opening;
607
+ if (!opening) {
608
+ // A closed tip has no side, and the watch that was following it has nothing left to follow.
609
+ watchers.get(state)?.();
610
+ return;
611
+ }
612
+ if (state.anchored === true && supportsAnchorPositioning()) {
613
+ measureAnchoredFlip(state, tip);
614
+ } else {
615
+ placeTooltip(state, tip);
616
+ }
617
+ }
618
+
619
+ /**
620
+ * Bind the tip to the control it is wired to, once the element has rendered.
621
+ *
622
+ * EITHER WIRING, because the split is per pair rather than per spelling: the control `for` names,
623
+ * or the control that declares `interestfor` at this tip. The second is the shape the element tells
624
+ * authors to prefer, and on an engine without interest invokers it is the shape that shows nothing
625
+ * at all unless it is bound here — {@link bindTooltip} is what decides, and it binds nothing when
626
+ * the platform already owns the pair.
627
+ *
628
+ * The wiring is read at mount and not watched: an element cannot hear its own attribute change from
629
+ * a sidecar, and a host that wires a tip up later calls {@link bindTooltip} itself.
630
+ *
631
+ * @param state The tip's reactive state.
632
+ * @param event The `jx-ready` the element dispatches on itself when it has rendered.
633
+ */
634
+ export function onTooltipReady(state: TooltipState, event: Event): void {
635
+ const tip = event.currentTarget;
636
+ if (!(tip instanceof HTMLElement)) {
637
+ return;
638
+ }
639
+ const trigger = triggerOf(state, tip);
640
+ if (!trigger) {
641
+ return;
642
+ }
643
+ bindings.get(state)?.dispose();
644
+ bindings.set(state, { dispose: bindTooltip(tip, trigger), tip });
645
+ }
646
+
647
+ /**
648
+ * Take the listeners off the control when the tip leaves the document — and only then.
649
+ *
650
+ * They are on an element the kit does not own and that outlives the tip, so without this a removed
651
+ * tip keeps a control alive and keeps answering its pointer.
652
+ *
653
+ * A MOVE is not a removal, and the runtime cannot tell the element which one it is having: a keyed
654
+ * `$map` reorders its kept rows with `before()`, which fires `disconnectedCallback` and then
655
+ * `connectedCallback` on every element in the row, and the second is a no-op for an element that
656
+ * has already initialised — `onMount` and the `jx-ready` that binds the control never run again. So
657
+ * the disposal waits a microtask and is skipped when the tip is back in a document by then, which a
658
+ * synchronous move always is. A tip taken out for good is disposed as before, one tick later; a tip
659
+ * that was rebound in the meantime (a re-mount, which changes the entry) is not touched.
660
+ *
661
+ * @param state The tip's reactive state.
662
+ */
663
+ export function onTooltipUnmount(state: TooltipState): void {
664
+ /* A tip removed while showing gets no toggle from the platform, so its flip watch is stopped
665
+ here or not at all — a watch is a window listener, which would hold the removed tip forever.
666
+ Stopped at once rather than a tick later: a MOVED tip that was open loses nothing but its
667
+ re-measure until its next open re-arms it, and the binding below is the thing a move must keep. */
668
+ watchers.get(state)?.();
669
+ const bound = bindings.get(state);
670
+ if (!bound) {
671
+ return;
672
+ }
673
+ queueMicrotask(() => {
674
+ if (bindings.get(state) !== bound) {
675
+ return;
676
+ }
677
+ if (bound.tip.isConnected) {
678
+ return;
679
+ }
680
+ bound.dispose();
681
+ bindings.delete(state);
682
+ });
683
+ }
684
+
685
+ /** How many hint stems have been minted, so no two hinted controls share one. */
686
+ let mintedHints = 0;
687
+
688
+ /**
689
+ * Mint the id stem a control that draws its `hint` as a `jx-tooltip` needs, and write it into
690
+ * `uid`.
691
+ *
692
+ * `jx-action-button`, `jx-button` and `jx-switch` render their tip as a child of their own element,
693
+ * and the pair is wired by id in both directions: `interestfor` on the control naming the tip,
694
+ * which is what shows it with nothing bound on an engine that has interest invokers (Chrome 152 —
695
+ * and NOT the desktop app's Chromium 147, which is on the fallback path until the CEF bump reaches
696
+ * it); `for` on the tip naming the control, which is what {@link onTooltipReady} resolves by id
697
+ * rather than by an attribute scan of the document; and `aria-describedby` on the control naming
698
+ * the tip again, which is what makes the hint a DESCRIPTION beside the name rather than the name
699
+ * itself. So a consumer writes none of the three, and two hinted controls in one toolbar cannot
700
+ * collide. The switch writes only the last two: its trigger is the `<label>` wrapping the input,
701
+ * and interest invokers are supported on `a`, `area` and `button` alone, so an `interestfor` there
702
+ * would show nothing on Chrome 152 while telling {@link bindTooltip} the platform had the pair — it
703
+ * is wired by `for` and bound on every engine instead. A document cannot mint the stem: the closed
704
+ * operator set has no counter and no identity, which is the sidecar case specs/ui.md §3.2
705
+ * sanctions. The stem carries the host's own tag, so an id read off the inspector says what it
706
+ * belongs to.
707
+ *
708
+ * The write lands after the first render, because `onMount` runs a microtask after it — so the tip
709
+ * and the three attributes appear on the second pass, and every binding that reads the stem guards
710
+ * on it being there. A control disabled at mount never gets a tip at all; its `hint` is the native
711
+ * `title` until it is enabled.
712
+ *
713
+ * @param state The control's reactive scope, whose `uid` this writes.
714
+ * @param host The `jx-action-button`, `jx-button` or `jx-switch` element.
715
+ */
716
+ export function mintHintId(state: Record<string, unknown>, host: HTMLElement): void {
717
+ mintedHints += 1;
718
+ state["uid"] = `${host.localName}-${mintedHints}`;
719
+ }