@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.
- package/components/jx-accordion-item.json +281 -0
- package/components/jx-accordion.json +33 -0
- package/components/jx-action-button.json +268 -34
- package/components/jx-action-group.json +131 -0
- package/components/jx-button.json +141 -26
- package/components/jx-checkbox.json +222 -0
- package/components/jx-color-area.json +263 -0
- package/components/jx-color-field.json +565 -0
- package/components/jx-color-slider.json +216 -0
- package/components/jx-combobox.json +551 -0
- package/components/jx-dialog.json +416 -0
- package/components/jx-divider.json +52 -0
- package/components/jx-dot.json +38 -0
- package/components/jx-field.json +162 -0
- package/components/jx-kbd.json +24 -0
- package/components/jx-listbox.json +76 -0
- package/components/jx-menu-item.json +6 -13
- package/components/jx-menu.json +45 -6
- package/components/jx-number-field.json +441 -0
- package/components/jx-option.json +350 -0
- package/components/jx-popover.json +227 -0
- package/components/jx-select.json +637 -0
- package/components/jx-spinner.json +129 -0
- package/components/jx-split.json +221 -0
- package/components/jx-swatch-group.json +107 -0
- package/components/jx-swatch.json +262 -0
- package/components/jx-switch.json +331 -0
- package/components/jx-tab-panel.json +58 -0
- package/components/jx-tab.json +296 -0
- package/components/jx-tabs.json +124 -0
- package/components/jx-textfield.json +645 -0
- package/components/jx-toast-host.json +121 -0
- package/components/jx-toast.json +255 -0
- package/components/jx-toolbar.json +81 -0
- package/components/jx-tooltip.json +207 -0
- package/components/jx-tree-item.json +325 -0
- package/components/jx-tree.json +202 -0
- package/icons/list.json +4 -0
- package/icons/manifest.json +12 -0
- package/package.json +27 -5
- package/project.json +164 -153
- package/src/behaviors/action-group.ts +255 -0
- package/src/behaviors/color-area.ts +303 -0
- package/src/behaviors/color-field.ts +465 -0
- package/src/behaviors/color-slider.ts +154 -0
- package/src/behaviors/combobox.ts +474 -0
- package/src/behaviors/dialog.ts +137 -0
- package/src/behaviors/listbox.ts +118 -0
- package/src/behaviors/menu.ts +80 -48
- package/src/behaviors/number-field.ts +161 -0
- package/src/behaviors/popover.ts +399 -0
- package/src/behaviors/select.ts +154 -0
- package/src/behaviors/split.ts +478 -0
- package/src/behaviors/swatch-group.ts +297 -0
- package/src/behaviors/tabs.ts +319 -0
- package/src/behaviors/textfield.ts +164 -0
- package/src/behaviors/toast-host.ts +241 -0
- package/src/behaviors/toast.ts +261 -0
- package/src/behaviors/toolbar.ts +413 -0
- package/src/behaviors/tooltip.ts +719 -0
- package/src/behaviors/tree.ts +830 -0
- package/src/color.ts +433 -0
- package/src/documents.ts +91 -1
- package/src/index.ts +41 -1
- package/src/loaders.ts +47 -0
- package/src/testing/anchor-support.ts +28 -0
- package/src/testing/popover-shim.ts +39 -6
- package/src/theme.ts +30 -6
- package/stylebook/jx-accordion-item.json +195 -0
- package/stylebook/jx-accordion.json +220 -0
- package/stylebook/jx-action-button.json +191 -23
- package/stylebook/jx-action-group.json +335 -0
- package/stylebook/jx-button.json +129 -26
- package/stylebook/jx-checkbox.json +245 -0
- package/stylebook/jx-color-area.json +272 -0
- package/stylebook/jx-color-field.json +261 -0
- package/stylebook/jx-color-slider.json +220 -0
- package/stylebook/jx-combobox.json +332 -0
- package/stylebook/jx-dialog.json +186 -0
- package/stylebook/jx-divider.json +125 -0
- package/stylebook/jx-dot.json +92 -0
- package/stylebook/jx-field.json +313 -0
- package/stylebook/jx-icon.json +4 -0
- package/stylebook/jx-kbd.json +90 -0
- package/stylebook/jx-listbox.json +337 -0
- package/stylebook/jx-menu.json +0 -1
- package/stylebook/jx-number-field.json +203 -0
- package/stylebook/jx-option.json +285 -0
- package/stylebook/jx-popover.json +123 -0
- package/stylebook/jx-select.json +407 -0
- package/stylebook/jx-spinner.json +111 -0
- package/stylebook/jx-split.json +170 -0
- package/stylebook/jx-swatch-group.json +175 -0
- package/stylebook/jx-swatch.json +249 -0
- package/stylebook/jx-switch.json +240 -0
- package/stylebook/jx-tab-panel.json +147 -0
- package/stylebook/jx-tab.json +201 -0
- package/stylebook/jx-tabs.json +278 -0
- package/stylebook/jx-textfield.json +309 -0
- package/stylebook/jx-toast-host.json +157 -0
- package/stylebook/jx-toast.json +266 -0
- package/stylebook/jx-toolbar.json +435 -0
- package/stylebook/jx-tooltip.json +162 -0
- package/stylebook/jx-tree-item.json +258 -0
- 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
|
+
}
|