@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,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
+ */