@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,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The popover behaviour: the family's one clamp, and the small amount a panel needs that the closed
|
|
3
|
+
* operator set cannot express.
|
|
4
|
+
*
|
|
5
|
+
* Everything here measures, calls an element's OWN `showPopover()` / `hidePopover()`, or writes the
|
|
6
|
+
* element's own reactive state — which is exactly the allowed set (specs/ui.md §2). It writes no
|
|
7
|
+
* attribute and no style on any element, its own included. Light dismissal, Escape, the top layer
|
|
8
|
+
* and focus restoration are the platform's.
|
|
9
|
+
*
|
|
10
|
+
* Three things here are corrections rather than features:
|
|
11
|
+
*
|
|
12
|
+
* 1. **A popover restores focus relative to its INVOKER**, and the invoker is established either by a
|
|
13
|
+
* `popovertarget` button or by `showPopover({ source })`. A bare `showPopover()` leaves the
|
|
14
|
+
* panel with no source, so closing it returns the caret to wherever the document happened to
|
|
15
|
+
* leave it. {@link openAt} always passes a source, inside a `try`/`catch` because an engine that
|
|
16
|
+
* predates the options bag throws on it — and arity detection does not work, since
|
|
17
|
+
* `HTMLElement.prototype.showPopover.length` is 0 in a browser that accepts it. The catch is
|
|
18
|
+
* narrowed to a `TypeError`, because "the bag was rejected" and "the call was refused" are two
|
|
19
|
+
* different failures and retrying the second one bare throws it a second time.
|
|
20
|
+
* 2. **A panel is usually opened by the platform, not by this module.** The two spellings the kit
|
|
21
|
+
* sanctions — a raw `<button popovertarget>` and `popovertarget` forwarded through a kit button
|
|
22
|
+
* — never reach {@link openAt}, so a measurement keyed on what `openAt` recorded is a
|
|
23
|
+
* measurement that almost never happens. {@link onToggle} therefore takes the trigger from the
|
|
24
|
+
* `ToggleEvent`'s own `source` (Chrome 152 carries it for an invoker click and for
|
|
25
|
+
* `showPopover({ source })` alike, measured), and falls back to the invoker that names the panel
|
|
26
|
+
* on an engine that does not.
|
|
27
|
+
* 3. **A 0ms transition fires no `transitionend`.** `@(prefers-reduced-motion: reduce)` zeroes the
|
|
28
|
+
* `--jx-dur-*` tokens, so on a reader's machine the panel's transition is 0ms and the event that
|
|
29
|
+
* was to clear `settling` never arrives. So {@link onToggle} also clears it in a frame when the
|
|
30
|
+
* host is running no animations, and without that second path `data-jx-settling` sticks
|
|
31
|
+
* forever.
|
|
32
|
+
*
|
|
33
|
+
* **Placement is the platform's whenever it can be, and the clamp is the fallback.** A popover
|
|
34
|
+
* shown from a source — `showPopover({ source })`, or a `popovertarget` invoker — has that source
|
|
35
|
+
* as its IMPLICIT anchor, by the platform's own rule and with no `anchor-name` on anybody's
|
|
36
|
+
* element, so the document's `position-area` rule places it, follows the source through scroll and
|
|
37
|
+
* resize, and flips it when the first placement would overflow. This module's part is one decision,
|
|
38
|
+
* made in {@link onBeforeToggle} before the panel is laid out: `anchored` is true for an opening
|
|
39
|
+
* toggle with a source on a panel with no floor, and the clamp is skipped for an anchored panel on
|
|
40
|
+
* an engine that positions by anchor. Everything else — a panel shown from nothing, a panel with a
|
|
41
|
+
* floor, an engine without anchor positioning — is placed at `x` and `y` and clamped, exactly as
|
|
42
|
+
* before. The measurement stays the fallback rather than going, because the kit publishes to site
|
|
43
|
+
* authors and their readers' engines are not all one engine.
|
|
44
|
+
*
|
|
45
|
+
* @docs extending/ui-kit
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** The reactive scope a popover-shaped document hands its handlers. */
|
|
49
|
+
export interface PopoverState {
|
|
50
|
+
open?: boolean;
|
|
51
|
+
settling?: boolean;
|
|
52
|
+
matchWidth?: boolean;
|
|
53
|
+
anchorWidth?: number;
|
|
54
|
+
/** Whether the panel is showing from a source and therefore placed by the platform. */
|
|
55
|
+
anchored?: boolean;
|
|
56
|
+
x?: number;
|
|
57
|
+
y?: number;
|
|
58
|
+
floor?: number;
|
|
59
|
+
[key: string]: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether this engine positions by anchor at all — the same question the document's `@supports`
|
|
64
|
+
* block asks, so the clamp is skipped exactly where the anchored rule applies and nowhere else. A
|
|
65
|
+
* platform query, not a DOM write, and answered fresh each time because it is cheap and a test may
|
|
66
|
+
* stub it either way.
|
|
67
|
+
*/
|
|
68
|
+
export function supportsAnchorPositioning(): boolean {
|
|
69
|
+
const css = (globalThis as { CSS?: { supports?: (property: string, value: string) => boolean } })
|
|
70
|
+
.CSS;
|
|
71
|
+
return typeof css?.supports === "function" && css.supports("position-area", "block-end");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The popover API as this module calls it.
|
|
76
|
+
*
|
|
77
|
+
* Declared here rather than reached for through the DOM types because an engine may not have it at
|
|
78
|
+
* all — the kit publishes to site authors, and every call site guards on `typeof`.
|
|
79
|
+
*/
|
|
80
|
+
interface PopoverApi {
|
|
81
|
+
showPopover?: (options?: { source?: HTMLElement }) => void;
|
|
82
|
+
hidePopover?: () => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** An element that may be a popover: every method the behaviour calls is optional. */
|
|
86
|
+
export type PopoverElement = HTMLElement;
|
|
87
|
+
|
|
88
|
+
/** The popover API of an element, whether or not this engine implements it. */
|
|
89
|
+
function apiOf(host: HTMLElement): PopoverApi {
|
|
90
|
+
return host as unknown as PopoverApi;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The element each SHOWING panel was opened from, for an engine whose `ToggleEvent` carries no
|
|
95
|
+
* `source`.
|
|
96
|
+
*
|
|
97
|
+
* Off to the side rather than on the element, because a WeakMap is not a DOM write: `openAt` may
|
|
98
|
+
* not put an attribute or a property on a consumer's trigger.
|
|
99
|
+
*
|
|
100
|
+
* **It is cleared when the panel closes**, and that is not tidiness. A `WeakMap` is weak in its
|
|
101
|
+
* KEY: while a long-lived panel is alive it holds its value — a trigger, its subtree and every
|
|
102
|
+
* listener on it — strongly, so a map that is only ever written pins each removed trigger of a
|
|
103
|
+
* churning surface for as long as the panel exists. Every close path goes through the platform's
|
|
104
|
+
* own toggle, so {@link onToggle} is where the entry goes; {@link close} does not need to repeat it
|
|
105
|
+
* and deliberately does not.
|
|
106
|
+
*/
|
|
107
|
+
const anchors = new WeakMap<HTMLElement, Element>();
|
|
108
|
+
|
|
109
|
+
/** The element an event is being handled on, when it is an element at all. */
|
|
110
|
+
function hostOf(event: Event): HTMLElement | null {
|
|
111
|
+
const target = event.currentTarget;
|
|
112
|
+
return target instanceof HTMLElement ? target : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The element `host` was last opened from, if one was named. */
|
|
116
|
+
export function anchorOf(host: HTMLElement): Element | null {
|
|
117
|
+
return anchors.get(host) ?? null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Show a panel from an anchor, so the platform knows what to restore focus to.
|
|
122
|
+
*
|
|
123
|
+
* The host's door in, together with {@link close}: `open` is mirrored FROM the platform and writing
|
|
124
|
+
* it shows nothing.
|
|
125
|
+
*
|
|
126
|
+
* @param host The panel element.
|
|
127
|
+
* @param anchor The element the panel was opened from, if any.
|
|
128
|
+
*/
|
|
129
|
+
export function openAt(host: PopoverElement, anchor?: Element | null): void {
|
|
130
|
+
const api = apiOf(host);
|
|
131
|
+
if (typeof api.showPopover !== "function") {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!anchor) {
|
|
135
|
+
api.showPopover();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
/* BEFORE the call, because `beforetoggle` fires inside it and {@link onBeforeToggle} reads the
|
|
139
|
+
map on an engine whose event carries no `source`. A refused open is taken back below, so the
|
|
140
|
+
map still answers "what is this panel showing from" with nothing for a panel that did not
|
|
141
|
+
open. */
|
|
142
|
+
anchors.set(host, anchor);
|
|
143
|
+
try {
|
|
144
|
+
show(api, anchor);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
anchors.delete(host);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The call itself, with the one retry it is allowed.
|
|
153
|
+
*
|
|
154
|
+
* ONLY a `TypeError` means "this engine has no options bag" — that is what a WebIDL overload or
|
|
155
|
+
* dictionary conversion raises. Everything else is the call itself refusing: `showPopover` throws a
|
|
156
|
+
* `DOMException` (`NotSupportedError`) for an element that is not a popover at all, and retrying
|
|
157
|
+
* that one bare throws the same error a second time, out of `openAt` and into whatever bound the
|
|
158
|
+
* handler. So it is re-raised from the FIRST call, unswallowed.
|
|
159
|
+
*/
|
|
160
|
+
function show(api: PopoverApi, anchor: Element): void {
|
|
161
|
+
try {
|
|
162
|
+
/* `ShowPopoverOptions.source` is typed `HTMLElement`, but any element can be a
|
|
163
|
+
trigger and the platform only reads it as a focus origin. */
|
|
164
|
+
api.showPopover?.({ source: anchor as HTMLElement });
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (!(error instanceof TypeError)) {
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
api.showPopover?.();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hide a panel. `open`, `data-open` and `settling` follow from the platform's own toggle.
|
|
175
|
+
*
|
|
176
|
+
* @param host The panel element.
|
|
177
|
+
*/
|
|
178
|
+
export function close(host: PopoverElement): void {
|
|
179
|
+
const api = apiOf(host);
|
|
180
|
+
if (typeof api.hidePopover === "function") {
|
|
181
|
+
api.hidePopover();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The invoker that names `host` as its popover target, if the document holds one.
|
|
187
|
+
*
|
|
188
|
+
* The fallback for an engine whose `ToggleEvent` has no `source`. A read, never a write: the
|
|
189
|
+
* trigger is a consumer's element and this module may not touch it (specs/ui.md §2 principle 5). It
|
|
190
|
+
* answers the same element the platform would — the button carrying the attribute, which for a kit
|
|
191
|
+
* button is the inner `<button part="control">` the prop is forwarded to.
|
|
192
|
+
*/
|
|
193
|
+
function invokerOf(host: HTMLElement): Element | null {
|
|
194
|
+
const { id } = host;
|
|
195
|
+
if (!id) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
/* The attribute is compared rather than interpolated into a selector: an id is an author's
|
|
199
|
+
string, and `[popovertarget="…"]` would need escaping to be either correct or safe. */
|
|
200
|
+
for (const invoker of host.ownerDocument.querySelectorAll("[popovertarget]")) {
|
|
201
|
+
if (invoker.getAttribute("popovertarget") === id) {
|
|
202
|
+
return invoker;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What a toggle says the panel was opened from: the platform's own answer first, then whatever
|
|
210
|
+
* {@link openAt} recorded, then the invoker that names the panel.
|
|
211
|
+
*/
|
|
212
|
+
function toggleSourceOf(host: HTMLElement, event: Event): Element | null {
|
|
213
|
+
const { source } = event as Event & { source?: unknown };
|
|
214
|
+
if (source instanceof Element) {
|
|
215
|
+
return source;
|
|
216
|
+
}
|
|
217
|
+
return anchorOf(host) ?? invokerOf(host);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Measure the anchor's border box into `anchorWidth`, under `matchWidth` only.
|
|
222
|
+
*
|
|
223
|
+
* On toggle only, which is parity with what a Spectrum overlay does at `@sp-opened` rather than a
|
|
224
|
+
* regression: a resize observer on every open panel would measure on every frame of a drag.
|
|
225
|
+
*
|
|
226
|
+
* @param state The panel's reactive state.
|
|
227
|
+
* @param host The panel element.
|
|
228
|
+
* @param anchor The element to measure; defaults to whatever opened the panel.
|
|
229
|
+
*/
|
|
230
|
+
export function measureAnchor(
|
|
231
|
+
state: PopoverState,
|
|
232
|
+
host: HTMLElement,
|
|
233
|
+
anchor: Element | null = anchorOf(host),
|
|
234
|
+
): void {
|
|
235
|
+
if (state.matchWidth !== true || !anchor) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const box = anchor.getBoundingClientRect();
|
|
239
|
+
if (box.width > 0) {
|
|
240
|
+
state.anchorWidth = Math.round(box.width);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Clear `settling` once the host is genuinely still.
|
|
246
|
+
*
|
|
247
|
+
* A frame later, because an animation started by this toggle does not exist yet when the toggle
|
|
248
|
+
* event is dispatched; and only when nothing is running, so a real transition is left to
|
|
249
|
+
* {@link onTransitionEnd}.
|
|
250
|
+
*/
|
|
251
|
+
function clearWhenStill(state: PopoverState, host: HTMLElement): void {
|
|
252
|
+
const settle = (): void => {
|
|
253
|
+
if (typeof host.getAnimations === "function" && host.getAnimations().length > 0) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
state.settling = false;
|
|
257
|
+
};
|
|
258
|
+
if (typeof requestAnimationFrame === "function") {
|
|
259
|
+
requestAnimationFrame(settle);
|
|
260
|
+
} else {
|
|
261
|
+
settle();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The platform's `beforetoggle`: where `anchored` is decided, and the only thing decided there.
|
|
267
|
+
*
|
|
268
|
+
* Before the show rather than on the toggle after it, because `toggle` is queued once the panel is
|
|
269
|
+
* already in the top layer: a panel that learned it was anchored a frame late would paint once at
|
|
270
|
+
* its coordinates and then jump to its anchor. A closing `beforetoggle` clears it, so the
|
|
271
|
+
* coordinate rule is back in force for whatever shows the panel next.
|
|
272
|
+
*
|
|
273
|
+
* @param state The panel's reactive state.
|
|
274
|
+
* @param event The platform's ToggleEvent.
|
|
275
|
+
*/
|
|
276
|
+
export function onBeforeToggle(state: PopoverState, event: Event): void {
|
|
277
|
+
const host = hostOf(event);
|
|
278
|
+
if (!host) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const opening = (event as { newState?: string }).newState === "open";
|
|
282
|
+
const floor = Number(state.floor ?? 0);
|
|
283
|
+
state.anchored = opening && floor <= 0 && toggleSourceOf(host, event) !== null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The platform's toggle: the one source of truth for `open`.
|
|
288
|
+
*
|
|
289
|
+
* An opening toggle measures the element the toggle names as its source and, unless the platform is
|
|
290
|
+
* placing the panel by anchor, clamps it into its area; a closing one drops the recorded anchor.
|
|
291
|
+
*
|
|
292
|
+
* @param state The panel's reactive state.
|
|
293
|
+
* @param event The platform's ToggleEvent.
|
|
294
|
+
*/
|
|
295
|
+
export function onToggle(state: PopoverState, event: Event): void {
|
|
296
|
+
const host = hostOf(event);
|
|
297
|
+
if (!host) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const opening = (event as { newState?: string }).newState === "open";
|
|
301
|
+
state.open = opening;
|
|
302
|
+
state.settling = true;
|
|
303
|
+
if (opening) {
|
|
304
|
+
measureAnchor(state, host, toggleSourceOf(host, event));
|
|
305
|
+
if (!(state.anchored === true && supportsAnchorPositioning())) {
|
|
306
|
+
clampIntoViewport(state, host, { floor: Number(state.floor ?? 0) });
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
/* The panel is showing from nothing now, so the map stops holding the trigger alive. Here
|
|
310
|
+
rather than in `close`, because light dismissal and Escape close a panel without it. */
|
|
311
|
+
anchors.delete(host);
|
|
312
|
+
}
|
|
313
|
+
clearWhenStill(state, host);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The panel's own transition finishing. A transition on a child is not the panel coming to rest, so
|
|
318
|
+
* an event that merely bubbled through is ignored.
|
|
319
|
+
*
|
|
320
|
+
* @param state The panel's reactive state.
|
|
321
|
+
* @param event The platform's TransitionEvent.
|
|
322
|
+
*/
|
|
323
|
+
export function onTransitionEnd(state: PopoverState, event: Event): void {
|
|
324
|
+
if (event.target === event.currentTarget) {
|
|
325
|
+
state.settling = false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** What a caller knows about the area a panel is being clamped into. */
|
|
330
|
+
export interface ClampOptions {
|
|
331
|
+
/** The lowest edge the panel may reach, in viewport pixels; 0 or absent means the viewport. */
|
|
332
|
+
floor?: number;
|
|
333
|
+
/**
|
|
334
|
+
* A box to flip to the inline-start of when the panel would leave the viewport on the right.
|
|
335
|
+
*
|
|
336
|
+
* A submenu flips beside its parent panel rather than sliding over it; a standalone panel has
|
|
337
|
+
* nothing to flip against and slides.
|
|
338
|
+
*/
|
|
339
|
+
flipAgainst?: HTMLElement | null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* How far below its own `inset-block-start` a panel's box lands.
|
|
344
|
+
*
|
|
345
|
+
* A panel's gap from its anchor is its own `margin-block-start` — the `--jx-popover-offset` token —
|
|
346
|
+
* and a margin is applied AFTER the inset that `state.y` becomes. So a clamp of `floor - height`
|
|
347
|
+
* puts the rendered bottom edge at `floor + offset`, over the very bar `floor` exists to stay off.
|
|
348
|
+
* Read from the element rather than from the token: it is 0 for a menu, which carries no such
|
|
349
|
+
* margin, and it follows a consumer who has retuned the token. (`getComputedStyle` resolves it to a
|
|
350
|
+
* used pixel length: Chrome 152 answers `4px` for `var(--jx-popover-offset, 4px)`, measured.)
|
|
351
|
+
*/
|
|
352
|
+
function blockOffsetOf(panel: HTMLElement): number {
|
|
353
|
+
// An intentional partial parse: a computed margin is "4px" and `Number()` answers NaN for it.
|
|
354
|
+
// oxlint-disable-next-line unicorn/prefer-number-coercion
|
|
355
|
+
const offset = Number.parseFloat(getComputedStyle(panel).marginBlockStart);
|
|
356
|
+
return Number.isFinite(offset) && offset > 0 ? offset : 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Keep a shown panel inside its area. The family's ONE clamp: `jx-menu` imports it from here, so a
|
|
361
|
+
* menu and a panel cannot drift into two answers for the same question.
|
|
362
|
+
*
|
|
363
|
+
* Measured after a frame, because an unlaid-out panel is zero wide and would never appear to
|
|
364
|
+
* overflow anything; a zero-size box (a test DOM) is left where it is.
|
|
365
|
+
*
|
|
366
|
+
* @param state The panel's reactive state, whose `x` and `y` are moved.
|
|
367
|
+
* @param panel The element to measure.
|
|
368
|
+
* @param options The floor, and what to flip against.
|
|
369
|
+
*/
|
|
370
|
+
export function clampIntoViewport(
|
|
371
|
+
state: PopoverState,
|
|
372
|
+
panel: HTMLElement,
|
|
373
|
+
options: ClampOptions = {},
|
|
374
|
+
): void {
|
|
375
|
+
const measure = (): void => {
|
|
376
|
+
const box = panel.getBoundingClientRect();
|
|
377
|
+
if (box.width === 0 && box.height === 0) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const margin = 4;
|
|
381
|
+
const floor = options.floor && options.floor > 0 ? options.floor : window.innerHeight;
|
|
382
|
+
if (box.right > window.innerWidth - margin) {
|
|
383
|
+
const parent = options.flipAgainst ?? null;
|
|
384
|
+
const flipped = parent ? Math.round(parent.getBoundingClientRect().left) - box.width + 2 : -1;
|
|
385
|
+
state.x =
|
|
386
|
+
flipped >= margin
|
|
387
|
+
? flipped
|
|
388
|
+
: Math.max(margin, Math.round(window.innerWidth - box.width - margin));
|
|
389
|
+
}
|
|
390
|
+
if (box.bottom > floor) {
|
|
391
|
+
state.y = Math.max(margin, Math.round(floor - box.height - blockOffsetOf(panel)));
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
if (typeof requestAnimationFrame === "function") {
|
|
395
|
+
requestAnimationFrame(measure);
|
|
396
|
+
} else {
|
|
397
|
+
measure();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jx-select`'s sidecar: the two things a document body cannot say about a native `<select>`, and
|
|
3
|
+
* nothing else. It never builds DOM — it writes state and it writes one DOM property — because the
|
|
4
|
+
* document is what renders rows.
|
|
5
|
+
*
|
|
6
|
+
* SELECTEDNESS is the reason this file exists, and both obvious spellings are broken.
|
|
7
|
+
*
|
|
8
|
+
* The `selected` CONTENT ATTRIBUTE must never be written. Measured in Chrome 152 with real CDP
|
|
9
|
+
* `ArrowDown` + `Enter` on the "Courier New" row, then a host write away and back to that same row:
|
|
10
|
+
* the `selected` attribute is on the Courier option and `select.value` is `"Georgia, serif"`, the
|
|
11
|
+
* FIRST row. The reader's own pick set that option's dirtiness flag, and from that moment the
|
|
12
|
+
* attribute no longer moves selectedness — so the select falls to index 0. This is the `checked`
|
|
13
|
+
* trap, confirmed on `<select>` with genuine input rather than with a synthetic event.
|
|
14
|
+
*
|
|
15
|
+
* The `value` PROPERTY binding alone is broken too, and for a different reason. Its effect runs
|
|
16
|
+
* while the `<select>` is being built, BEFORE any `$map` has appended an option, so `select.value =
|
|
17
|
+
* "system-ui"` is refused by the platform and silently leaves `""`. The effect then never re-runs,
|
|
18
|
+
* because nothing it read changed: `@vue/reactivity` stops an equal write before `bindProperty`'s
|
|
19
|
+
* own equal-write guard is even reached, and `renderMappedArrayInto` defers every re-render to a
|
|
20
|
+
* microtask, so any effect reacting to the same data change runs before the options exist.
|
|
21
|
+
*
|
|
22
|
+
* What passes is the property binding PLUS this: a `MutationObserver` on the control, re-asserting
|
|
23
|
+
* `sel.value` whenever the option list changes and only when the wanted value is actually among the
|
|
24
|
+
* options and differs from what is there. Measured green on first paint (index 5), on the rows
|
|
25
|
+
* being replaced and the row returning, on a real pick followed by a host write back to the picked
|
|
26
|
+
* row, and on list churn while sitting on the row. Eight observer runs across five list mutations,
|
|
27
|
+
* so it is not a hot path.
|
|
28
|
+
*
|
|
29
|
+
* The observer watches `data-value` as well as `childList`, and that half is this file's one
|
|
30
|
+
* addition to the contract it implements. A host write to `value` mutates no option, so it produces
|
|
31
|
+
* no childList record at all — and the stand-in row below is computed FROM that value, so a value
|
|
32
|
+
* moving off a stood-in row onto a listed one left the stand-in in the list for the life of the
|
|
33
|
+
* element. The document is what makes the write visible: the `<select>` binds `data-value` to
|
|
34
|
+
* `state.value`, so the one attribute the observer filters on changes exactly when the answer does.
|
|
35
|
+
* The alternative was a reactive effect, which would make this kit depend on the runtime's
|
|
36
|
+
* reactivity library to learn something the document can simply say out loud.
|
|
37
|
+
*
|
|
38
|
+
* @docs extending/ui-kit
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** A row as the element draws it. Only `value` and `label` are ever read here. */
|
|
42
|
+
interface UnlistedRow {
|
|
43
|
+
value: string;
|
|
44
|
+
label: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How many stems have been minted, so no two instances share one. */
|
|
48
|
+
let minted = 0;
|
|
49
|
+
|
|
50
|
+
/** The element's own native control. */
|
|
51
|
+
function controlOf(host: HTMLElement): HTMLSelectElement | null {
|
|
52
|
+
return host.querySelector<HTMLSelectElement>('select[part="control"]');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether the wanted value is held by a row the DOCUMENT listed.
|
|
57
|
+
*
|
|
58
|
+
* The stand-in row is excluded by its own `part`, and that exclusion is what stops the computation
|
|
59
|
+
* below oscillating: were the synthesised option counted as listed, the next observer run would
|
|
60
|
+
* find the value present, drop the row, find it absent again, and add it back for as long as the
|
|
61
|
+
* element lived.
|
|
62
|
+
*/
|
|
63
|
+
function isListed(sel: HTMLSelectElement, want: string): boolean {
|
|
64
|
+
return [...sel.options].some(
|
|
65
|
+
(option) => option.getAttribute("part") !== "unlisted" && option.value === want,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Bring the control back into agreement with `state.value`: the stand-in row first, then
|
|
71
|
+
* selectedness.
|
|
72
|
+
*
|
|
73
|
+
* Order matters. A value no group holds needs its row to EXIST before `sel.value` can be moved onto
|
|
74
|
+
* it, and appending that row is itself a childList mutation, so the assignment lands on the
|
|
75
|
+
* observer run the append provokes rather than on this one.
|
|
76
|
+
*
|
|
77
|
+
* **Selectedness is TOTAL and the stand-in row is not**, and the asymmetry is the whole of the
|
|
78
|
+
* empty string's special case. A blank row labelled with a blank value would say nothing about what
|
|
79
|
+
* is held, so `""` is never stood in for — but leaving the assignment out for the same value left
|
|
80
|
+
* the element saying one thing and its control showing and SUBMITTING another. `<jx-select
|
|
81
|
+
* label="Alignment" name="align">` with rows and no `value` measured `element ""` against `control
|
|
82
|
+
* "start" index 0`, so an ordinary first paint shipped two answers to one question and a form got
|
|
83
|
+
* the one the element had never been told to hold. When no option holds the wanted value the
|
|
84
|
+
* control now holds no option, which is what "nothing here says that" looks like on a select.
|
|
85
|
+
*
|
|
86
|
+
* `sel.value` alone cannot tell those apart: it reads `""` both for a picked `""` row and for no
|
|
87
|
+
* row at all, so the index is what discriminates.
|
|
88
|
+
*
|
|
89
|
+
* @param state The element's reactive scope.
|
|
90
|
+
* @param sel The element's own `<select>`.
|
|
91
|
+
*/
|
|
92
|
+
export function syncSelect(state: Record<string, unknown>, sel: HTMLSelectElement): void {
|
|
93
|
+
const want = String(state["value"] ?? "");
|
|
94
|
+
const rows = (state["unlisted"] as UnlistedRow[] | undefined) ?? [];
|
|
95
|
+
/* The empty string is a legal value — the inherit row — rather than absence, so it is never
|
|
96
|
+
stood in for: a blank row labelled with a blank value would say nothing about what is held. */
|
|
97
|
+
if (want !== "" && !isListed(sel, want)) {
|
|
98
|
+
if (rows.length !== 1 || rows[0]?.value !== want) {
|
|
99
|
+
state["unlisted"] = [{ label: want, value: want }];
|
|
100
|
+
}
|
|
101
|
+
} else if (rows.length > 0) {
|
|
102
|
+
state["unlisted"] = [];
|
|
103
|
+
}
|
|
104
|
+
if ([...sel.options].some((option) => option.value === want)) {
|
|
105
|
+
if (sel.value !== want || sel.selectedIndex < 0) {
|
|
106
|
+
sel.value = want;
|
|
107
|
+
}
|
|
108
|
+
} else if (sel.selectedIndex >= 0) {
|
|
109
|
+
sel.selectedIndex = -1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Mint the element's id stem, then keep the control in step with `state.value` for as long as it
|
|
115
|
+
* lives.
|
|
116
|
+
*
|
|
117
|
+
* A document cannot mint the stem: the closed operator set has no counter and no identity, and two
|
|
118
|
+
* selects on one surface must not collide. This is the sidecar case specs/ui.md §3.2 sanctions.
|
|
119
|
+
*
|
|
120
|
+
* @param state The element's reactive scope, whose `uid` and `unlisted` this writes.
|
|
121
|
+
* @param host The `jx-select` element.
|
|
122
|
+
*/
|
|
123
|
+
export function mountSelect(state: Record<string, unknown>, host: HTMLElement): void {
|
|
124
|
+
minted += 1;
|
|
125
|
+
state["uid"] = `jx-select-${minted}`;
|
|
126
|
+
const sel = controlOf(host);
|
|
127
|
+
if (!sel) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
syncSelect(state, sel);
|
|
131
|
+
const observer = new MutationObserver(() => syncSelect(state, sel));
|
|
132
|
+
observer.observe(sel, {
|
|
133
|
+
attributeFilter: ["data-value"],
|
|
134
|
+
attributes: true,
|
|
135
|
+
childList: true,
|
|
136
|
+
subtree: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
* THERE IS NO `onUnmount`, and its absence is the decision rather than the omission.
|
|
142
|
+
*
|
|
143
|
+
* The runtime initialises an element ONCE — `connectedCallback` returns early on `_jxInitialized` —
|
|
144
|
+
* so a sidecar that disconnects its observer at `disconnectedCallback` can never be handed the
|
|
145
|
+
* chance to install another. Moving an element to a different parent fires the remove steps and
|
|
146
|
+
* then the insert steps, which means an ordinary re-parent (Studio does this) would bring the
|
|
147
|
+
* element back rendering, reactive, and DEAF: its value would stop following its option list, in
|
|
148
|
+
* silence, on an element that looks entirely alive.
|
|
149
|
+
*
|
|
150
|
+
* Nothing leaks by leaving it. The observer's only strong references are the registration on the
|
|
151
|
+
* `<select>` and the closure over that same select and its scope, so the whole cycle is inside the
|
|
152
|
+
* detached subtree and is collected with it; the observation is on the control rather than on the
|
|
153
|
+
* document, so a detached element's mutations reach nothing else.
|
|
154
|
+
*/
|