@mythicalos/ui-core 0.1.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.
@@ -0,0 +1,1279 @@
1
+ /*! mythical-select v3 — form-associated, native-parity styled <select> for the
2
+ mythical design system (docs/design/). Consumes the --my-* tokens (custom
3
+ properties pierce the shadow boundary); no hardcoded brand values.
4
+
5
+ Authoring — pure tags:
6
+ <mythical-select name="lane" required>
7
+ <mythical-optgroup label="Cross-model">
8
+ <mythical-option value="codex" selected>codex cross-model</mythical-option>
9
+ </mythical-optgroup>
10
+ <mythical-option value="off">off</mythical-option>
11
+ </mythical-select>
12
+ or progressive — wrap a real native <select> (works without JS, upgrades with it;
13
+ on upgrade the inner select is hidden and un-named so the host owns the form value):
14
+ <mythical-select><select name="lane"><optgroup label="…"><option>…</select></mythical-select>
15
+ Contract: after upgrade the host owns state; the hidden native select is kept in
16
+ sync one-way (host→native) so code holding a reference reads current state; writes
17
+ to the native select after upgrade are NOT observed. name/required/disabled/form
18
+ forward onto the host ONCE, at first sight of the native — from then on those
19
+ attributes are HOST-OWNED: clearing one on the host is never re-imposed by a
20
+ later re-adoption (option mutation, form reset). The adopted native is force-
21
+ DISABLED (disabled controls are barred from constraint validation and submission)
22
+ so it can neither fire duplicate `invalid` events nor block the form independently
23
+ of the host — the host owns the constraint. Initial selection reads each
24
+ option's LIVE native selectedness — pre-upgrade script writes beat
25
+ [selected], including a script CLEAR: live selectedIndex -1 upgrades to
26
+ no selection (no first-enabled fallback; a matching host value attribute
27
+ still wins). An option the script SELECTED against its own default (live
28
+ selected, no [selected] attribute) upgrades DIRTY — its own default
29
+ changing then no longer moves it, like native (see #adopt).
30
+ If the native select is later moved out of the host, its PRESENTATION is restored
31
+ (display / aria-hidden / tabindex / the author's original disabled state) but its
32
+ name is NEVER re-added — the host owns form identity for good. Moved DIRECTLY
33
+ into another <mythical-select>, the new host reuses the ORIGINAL presentation
34
+ snapshot (the live attributes at that moment are the old owner's forced inert
35
+ state, not the author's) and takes over the restore duty.
36
+
37
+ Native-parity surface:
38
+ attributes: name · value · disabled · required (+ form association is automatic)
39
+ DOM API: value · selectedIndex · options (array snapshot) · item(i) · type ·
40
+ labels · form · validity · validationMessage · willValidate ·
41
+ checkValidity() · reportValidity() · setCustomValidity(msg).
42
+ Option-dependent members (value, selectedIndex, options, item,
43
+ length — and the validity-facing validity/validationMessage/
44
+ willValidate/checkValidity()/reportValidity()) flush pending
45
+ option mutations first — same-task mutate-then-use resolves
46
+ like the synchronous native; FormData built in the same task
47
+ is reconciled via the formdata event — a GLOBAL-ORDER rebuild:
48
+ each host corrects ONLY its own entry, same-name entries from
49
+ native inputs and sibling hosts survive, and the entry list
50
+ keeps its original cross-name order (see #onFormdata)
51
+ events: input + change on user commit (bubble, composed) — programmatic
52
+ value/selectedIndex writes fire nothing, like native
53
+ forms: submits via FormData; participates in form reset,
54
+ fieldset[disabled], form.elements; <label for> focuses it
55
+ defaults: first NON-DISABLED option selected when nothing carries `selected`
56
+ (native semantics — use a first option with value="" for
57
+ required/placeholder); all options disabled → no selection.
58
+ A selected-but-disabled option submits NO form entry (native)
59
+ selection: the HTML model, PER OPTION (module-level OPT): each option has a
60
+ `selectedness` and a `dirtiness`. The `selected` CONTENT
61
+ ATTRIBUTE is only the DEFAULT — it writes selectedness while
62
+ that option's dirtiness is false, so a default added later
63
+ still moves a control the user has already used, as long as it
64
+ targets a different (pristine) option. A user commit or a
65
+ value/selectedIndex write dirties ONLY the option it selects.
66
+ Structural mutations (option insert/remove/MOVE) run the spec's
67
+ ask-for-reset; attribute/text edits never do (a cleared -1
68
+ selection survives them). Documented engine deviations, both
69
+ followed to the 2-engine majority and pinned by differential
70
+ tests: FIREFOX ignores a [selected] add on index 0 while
71
+ another option is selected (it honors it at every other index);
72
+ WEBKIT re-runs a reset on an option MOVE, losing the
73
+ selectedness the spec keeps on the option node
74
+ limits: a MutationObserver reports mutations asynchronously and in
75
+ batches, so a single task's INTERMEDIATE DOM states are only
76
+ reconstructed, never observed. Ordered child-list replay covers
77
+ the ordinary shapes (insert / remove / move, incl. a move's
78
+ removal-reset preceding its reinsertion), but four contradictory
79
+ same-task batches resolve to the FINAL DOM where native would
80
+ resolve step-by-step: (1) a pristine option's [selected] edited
81
+ while DETACHED, then reinserted; (2) the same option's
82
+ [selected] added AND removed in one task; (3) an option removed
83
+ and another DISABLED in one task while the selection is -1;
84
+ (4) a multi-move batch, or one option inserted AND removed, in a
85
+ single task. All four need mutually-cancelling edits in one
86
+ task; the ordinary reactive-framework shape — moving [selected]
87
+ from one option to another on re-render — matches native on all
88
+ three engines (probed). Cross-model gate CAPPED at 14 rounds
89
+ here: these are the residual, non-consumer-plausible gaps.
90
+ keyboard: Enter/Space/Arrows open (Alt+ArrowDown too — APG chord) · closed
91
+ Home/End open at first/last enabled · Arrows/Home/End move ·
92
+ Enter/Space commit · open Alt+ArrowUp commits + closes (APG chord) ·
93
+ Tab commits + moves focus on · Esc closes · typeahead (also
94
+ closed-typeahead commit, like native; Ctrl/Alt/Meta chords ignored)
95
+ a11y: APG select-only combobox — the shadow trigger is role=combobox
96
+ (aria-expanded, aria-controls, aria-activedescendant while open) over
97
+ the role=listbox popup; labeled option groups render as role=group
98
+ containers labelled (aria-labelledby) by their headings; external
99
+ <label> text is mirrored onto the trigger as aria-label
100
+ (aria-labelledby cannot cross the shadow root; the host's own subtree
101
+ is excluded from a wrapping label's text, and the mirror refreshes on
102
+ focus); EXPLICIT naming attributes outrank the label mirror —
103
+ host aria-labelledby (idrefs resolved in the host's root node) >
104
+ host aria-label > the wrapped native's aria-labelledby/aria-label >
105
+ associated-<label> text; labels that target the WRAPPED native select join the
106
+ mirrored name too (host-targeting labels first), and clicking one
107
+ focuses the trigger — the force-disabled native can't take the
108
+ activation; the selected-option text is the combobox VALUE, not its name;
109
+ required/user-invalid state is reflected onto the trigger as
110
+ aria-required/aria-invalid
111
+ variant: variant="chip" → the compact policy-chip look (hero/detail chips) —
112
+ squared like every interactive control; pills are never interactive (§5)
113
+ browsers: any engine with ElementInternals (Chrome/Edge 77+, Firefox 93+,
114
+ Safari 16.4+). Engines that parse this file but lack ElementInternals
115
+ degrade to a working native <select>: progressive instances keep the
116
+ one they wrap, pure-tag instances get one synthesized — submitted
117
+ forms keep working; the JS API and post-connect option mutation are
118
+ NOT emulated there. Engines too old to parse modern class syntax
119
+ (Safari <15, Chrome <84, Firefox <90) get no upgrade at all — author
120
+ progressive mode where those matter.
121
+ CSP: styling under strict CSP (style-src without 'unsafe-inline') requires
122
+ constructable stylesheets (Chrome 73+/Edge 79+, Firefox 101+,
123
+ Safari 16.4+). Firefox 93–100 has ElementInternals but NOT
124
+ adoptedStyleSheets and falls back to an inline <style>, which a
125
+ strict style-src blocks — functional but unstyled there.
126
+ Not supported (use a native <select>): multiple, size (listbox mode). */
127
+ (() => {
128
+ if (typeof customElements === 'undefined' || customElements.get('mythical-select')) return;
129
+
130
+ // Inert data carriers — defined so :defined styling works and the parser keeps them.
131
+ // Every define is guarded individually: a pre-registered helper name must not
132
+ // throw and kill the registrations that follow it.
133
+ if (!customElements.get('mythical-option')) customElements.define('mythical-option', class extends HTMLElement {});
134
+ if (!customElements.get('mythical-optgroup')) customElements.define('mythical-optgroup', class extends HTMLElement {});
135
+
136
+ // HTMLOptionElement canonicalization ("strip and collapse ASCII whitespace"):
137
+ // the implicit value and the display text derive from the text content with
138
+ // ASCII-whitespace runs collapsed to single spaces and the ends trimmed — the
139
+ // same normalization the native .value/.text/.label getters apply. HTML ASCII
140
+ // whitespace ONLY (tab/LF/FF/CR/space): native canonicalization PRESERVES
141
+ // NBSP and other non-ASCII whitespace ("10&nbsp;GB" must stay "10 GB"),
142
+ // so neither \s nor .trim() (both eat U+00A0 etc.) may be used here. Shared
143
+ // by the full component AND the no-ElementInternals fallback below.
144
+ const collapse = s => s.replace(/[\t\n\f\r ]+/g, ' ').replace(/^ | $/g, '');
145
+
146
+ // No ElementInternals (pre-2023 engines): degrade to a working native <select>.
147
+ // Progressive instances keep the select they wrap; pure-tag instances get one
148
+ // synthesized from their <mythical-option> tags — submitted forms keep working,
149
+ // unskinned. Deliberately minimal: no JS API, no post-connect option mutation.
150
+ if (!('attachInternals' in HTMLElement.prototype) || typeof ElementInternals === 'undefined') {
151
+ // Defining the element defeats :not(:defined) FOUC guards, so hide the raw
152
+ // option tags ourselves until the native select is synthesized.
153
+ const st = document.createElement('style');
154
+ st.textContent = 'mythical-select mythical-option,mythical-select mythical-optgroup{display:none}';
155
+ (document.head || document.documentElement).append(st);
156
+ if (!customElements.get('mythical-select')) customElements.define('mythical-select', class extends HTMLElement {
157
+ connectedCallback() {
158
+ // During parsing, connectedCallback fires before children exist — defer.
159
+ if (document.readyState === 'loading') {
160
+ document.addEventListener('DOMContentLoaded', () => this.#build(), { once: true });
161
+ } else this.#build();
162
+ }
163
+ #build() {
164
+ if (this.querySelector('select')) return; // progressive — already native
165
+ const sel = document.createElement('select');
166
+ // `form` forwards too — a host with form="f" outside its form must
167
+ // keep submitting into THAT form via the synthesized select.
168
+ for (const a of ['name', 'required', 'disabled', 'form'])
169
+ if (this.hasAttribute(a)) sel.setAttribute(a, this.getAttribute(a) || '');
170
+ const opt = el => {
171
+ const o = document.createElement('option');
172
+ // same canonicalization as the full component: implicit value and
173
+ // display text strip-and-collapse; a label attribute overrides display
174
+ const t = collapse(el.textContent);
175
+ o.value = el.getAttribute('value') ?? t;
176
+ o.text = t;
177
+ if (el.hasAttribute('label')) o.setAttribute('label', el.getAttribute('label'));
178
+ o.disabled = el.hasAttribute('disabled');
179
+ o.selected = el.hasAttribute('selected');
180
+ return o;
181
+ };
182
+ for (const el of [...this.children]) {
183
+ if (el.localName === 'mythical-optgroup') {
184
+ const g = document.createElement('optgroup');
185
+ g.label = el.getAttribute('label') || '';
186
+ g.disabled = el.hasAttribute('disabled');
187
+ for (const c of el.children) if (c.localName === 'mythical-option') g.append(opt(c));
188
+ sel.append(g); el.remove();
189
+ } else if (el.localName === 'mythical-option') { sel.append(opt(el)); el.remove(); }
190
+ }
191
+ // Honor a value attribute only when an option matches (native-parity:
192
+ // a stale value must not clear the [selected]/first-option default).
193
+ const v = this.getAttribute('value');
194
+ if (v !== null && [...sel.options].some(o => o.value === v)) sel.value = v;
195
+ this.append(sel);
196
+ }
197
+ });
198
+ return;
199
+ }
200
+
201
+ const CSS = `
202
+ :host{display:inline-block;position:relative;min-width:180px;
203
+ font:var(--my-fs-body,14px)/1.4 var(--my-font-ui,-apple-system,sans-serif)}
204
+ :host([hidden]){display:none}
205
+ :host([variant="chip"]){min-width:0}
206
+ button{display:flex;align-items:center;gap:8px;width:100%;height:34px;padding:0 10px;
207
+ border:1px solid var(--my-control-border,#878E9B);border-radius:var(--my-r-control,6px);
208
+ /* control boundary ≥3:1 (--my-control-border) — the popover keeps --my-border:
209
+ it is an overlay surface, not a control boundary */
210
+ background:var(--my-surface,#fff);color:var(--my-ink,#16181D);font:inherit;cursor:pointer;
211
+ -webkit-tap-highlight-color:transparent;touch-action:manipulation;
212
+ transition:border-color var(--my-t-fast,120ms ease),background var(--my-t-fast,120ms ease)}
213
+ button:hover{border-color:var(--my-muted,#5A5F6A)}
214
+ button:focus-visible{outline:2px solid var(--my-accent,#0F6B66);outline-offset:2px}
215
+ :host([data-open]) button{border-color:var(--my-accent,#0F6B66)}
216
+ :host(:user-invalid) button{border-color:var(--my-error,#B3261E)}
217
+ :host([data-user-invalid]) button{border-color:var(--my-error,#B3261E)}
218
+ :host([disabled]) button,:host([data-form-disabled]) button{
219
+ background:var(--my-disabled-bg,#F1EFE9);color:var(--my-disabled-ink,#9AA0AC);
220
+ border-color:transparent;cursor:not-allowed}
221
+ :host([variant="chip"]) button{height:28px;padding:0 12px;font-size:12.5px;
222
+ border-radius:var(--my-r-control,6px)}
223
+ #label{flex:1;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
224
+ #label:empty::before{content:"\\00a0"}
225
+ .car{flex:none;color:var(--my-muted,#5A5F6A);transition:transform var(--my-t-fast,120ms ease)}
226
+ :host([data-open]) .car{transform:rotate(180deg)}
227
+ #pop{position:absolute;top:calc(100% + 6px);left:0;z-index:30;min-width:100%;max-height:280px;
228
+ overflow:auto;overscroll-behavior:contain;padding:6px;background:var(--my-surface,#fff);
229
+ border:1px solid var(--my-border,#E7E3DB);border-radius:var(--my-r-card,10px);
230
+ box-shadow:var(--my-shadow-modal,0 16px 48px rgba(22,24,29,.16));
231
+ scrollbar-width:thin;scrollbar-color:var(--my-border,#E7E3DB) transparent}
232
+ #pop::-webkit-scrollbar{width:8px}
233
+ #pop::-webkit-scrollbar-thumb{background:var(--my-border,#E7E3DB);border-radius:4px}
234
+ #pop[hidden]{display:none}
235
+ #pop:not([hidden]){animation:in var(--my-t-fast,120ms ease)}
236
+ :host([data-flip]) #pop{top:auto;bottom:calc(100% + 6px)}
237
+ :host([data-flip]) #pop:not([hidden]){animation-name:in-flip}
238
+ @keyframes in{from{opacity:0;transform:translateY(-4px)}}
239
+ @keyframes in-flip{from{opacity:0;transform:translateY(4px)}}
240
+ .grp{font-size:10px;font-weight:650;text-transform:uppercase;letter-spacing:.4px;
241
+ color:var(--my-muted,#5A5F6A);padding:7px 10px 3px}
242
+ /* hairline separator above each subsequent group container (options sit INSIDE
243
+ role=group wrappers now, so .opt never directly precedes a .grp heading) */
244
+ [role="group"] + [role="group"],.opt + [role="group"]{
245
+ margin-top:4px;border-top:1px solid var(--my-border,#E7E3DB);padding-top:1px}
246
+ .opt{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:7px 10px;
247
+ border-radius:var(--my-r-control,6px);font-size:12.5px;cursor:pointer;white-space:nowrap;
248
+ -webkit-tap-highlight-color:transparent}
249
+ .opt[data-active]{background:var(--my-surface-hover,#F6F4EF)}
250
+ .opt[aria-selected="true"]{color:var(--my-accent-strong,#0B5450);font-weight:var(--my-fw-medium,500)}
251
+ .opt[aria-disabled="true"]{color:var(--my-disabled-ink,#9AA0AC);cursor:not-allowed}
252
+ .mark{visibility:hidden}
253
+ .opt[aria-selected="true"] .mark{visibility:visible}
254
+ @media (prefers-reduced-motion:reduce){#pop:not([hidden]){animation:none}.car{transition:none}}`;
255
+
256
+ // One constructable sheet shared by every instance (works under style-src without
257
+ // 'unsafe-inline'); engines without adoptedStyleSheets get a per-instance <style>
258
+ // element populated via textContent — never an HTML-string sink.
259
+ let SHEET = null;
260
+ const applyCSS = root => {
261
+ try {
262
+ if (!SHEET) { const s = new CSSStyleSheet(); s.replaceSync(CSS); SHEET = s; }
263
+ root.adoptedStyleSheets = [SHEET];
264
+ } catch {
265
+ const st = document.createElement('style');
266
+ st.textContent = CSS;
267
+ root.append(st);
268
+ }
269
+ };
270
+ const SVGNS = 'http://www.w3.org/2000/svg';
271
+
272
+ // WebIDL `long` conversion (native parity for numeric API arguments):
273
+ // ToNumber, truncate toward zero, non-finite (NaN/±Infinity) → 0. item()
274
+ // layers unsigned-long semantics on top (negatives → null) at its call site.
275
+ const toLong = v => { const n = Math.trunc(Number(v)); return Number.isFinite(n) ? n : 0; };
276
+
277
+ // Interactive content (HTML label activation rules): a label click that
278
+ // lands on interactive content INSIDE the label does not activate the
279
+ // labeled control — the nested link/button/input keeps its own behavior.
280
+ const INTERACTIVE = 'a[href],button,input,select,textarea,[tabindex],[contenteditable],audio[controls],video[controls]';
281
+
282
+ // Adopted-native presentation snapshots — MODULE-level, keyed by the native
283
+ // element with its current OWNER host recorded, so a native moved directly
284
+ // between hosts keeps ONE original-author snapshot: the adopting host that finds
285
+ // an existing entry reuses it (the live attributes at that moment are the old
286
+ // owner's forced inert state, not the author's) and takes ownership, and only
287
+ // the recorded owner may run the departed-native restore — which consumes the
288
+ // entry, so the restore happens exactly once.
289
+ const SNAPS = new WeakMap(); // native <select> → { owner, display, ariaHidden, tabindex, disabled }
290
+
291
+ /* Per-OPTION selection state — the HTML model (replaces a select-wide dirty
292
+ flag plus an adoption-strength enum). Every option element carries:
293
+ selectedness is this option selected RIGHT NOW;
294
+ dirtiness has a user commit / programmatic selection touched this
295
+ option — which makes its `selected` CONTENT ATTRIBUTE stop
296
+ writing selectedness (the attribute is only the DEFAULT).
297
+ MODULE-level and keyed by the option ELEMENT (precedent: SNAPS above), so
298
+ the state survives re-adoption, reordering, detach and even a move between
299
+ hosts — exactly like native, where selectedness lives on the option NODE,
300
+ never on the select. Selection is therefore never reconstructed from a
301
+ fresh [selected] attribute scan: adoption reads these live flags. */
302
+ const OPT = new WeakMap(); // option element → { selectedness, dirtiness }
303
+ const isOpt = n => n.localName === 'mythical-option' || n.localName === 'option';
304
+ const isGrp = n => n.localName === 'mythical-optgroup' || n.localName === 'optgroup';
305
+ // Seed at FIRST SIGHT from the `selected` content attribute (the option's
306
+ // default selectedness). `live` overrides it for the options of a wrapped
307
+ // native at UPGRADE, where the real select's resolved selectedness (which a
308
+ // pre-upgrade script may have written) is the truth — see #adopt.
309
+ const optState = (el, live) => {
310
+ let s = OPT.get(el);
311
+ if (!s) OPT.set(el, s = live ?? { selectedness: el.hasAttribute('selected'), dirtiness: false });
312
+ return s;
313
+ };
314
+
315
+ // formdata reconciliation bookkeeping — per FormDataEvent, SHARED by every
316
+ // reconciler in that event (see #onFormdata): the FIRST reconciler to
317
+ // REGISTER snapshots the COMPLETE entry list (every name, original GLOBAL
318
+ // order) before any edit lands; each reconciler registers its correction
319
+ // keyed by its DOCUMENT RANK among same-name mythical-select hosts; and
320
+ // EVERY reconciler rebuilds the entire list from snapshot + corrections —
321
+ // idempotent, so the final list is independent of listener run order.
322
+ // Keyed on the event object: each FormData construction is its own event,
323
+ // and the entry GCs with it.
324
+ const FD = new WeakMap(); // FormDataEvent → { snap: [name, value][], corr: Map<name, {rank, stale, desired}[]> }
325
+
326
+ class MythicalSelect extends HTMLElement {
327
+ static formAssociated = true;
328
+ static observedAttributes = ['value', 'disabled', 'required', 'name',
329
+ 'aria-label', 'aria-labelledby']; // explicit naming attrs re-mirror (#syncLabel)
330
+ #internals; #btn; #labelEl; #pop; #native = null;
331
+ #opts = []; #idx = -1; #act = -1; #open = false; #adopted = false;
332
+ // One-shot value-attribute path: stays armed until the first adoption that
333
+ // actually FINDS options — a register-before-markup connect adopts zero
334
+ // children and must NOT consume it (the options stream in afterwards) — and
335
+ // never applies once the host is dirty. Re-armed by formResetCallback.
336
+ #attrPending = true;
337
+ #customError = ''; #ta = ''; #taT = 0; #mo; #onDocDown; #touched = false;
338
+ // Activation bridge for INNER-native labels: ONE delegated click listener
339
+ // on the host's root document while a native is adopted (see
340
+ // #bindLabelBridge) — resolving against the CURRENT native.labels at click
341
+ // time, so labels added or retargeted AFTER adoption keep working.
342
+ // #labelDoc records the document actually bound (removeEventListener must
343
+ // target the same node even if the host changes documents).
344
+ #labelDoc = null; #onLabelClick;
345
+ // formdata reconciliation: the ASSOCIATED form (tracked via
346
+ // formAssociatedCallback) carries ONE `formdata` listener per association
347
+ // so same-task FormData construction serializes fresh state (#onFormdata).
348
+ // #lastFormValue mirrors the value last handed to setFormValue — the exact
349
+ // entry THIS host contributed to a formdata event's entry list, used to
350
+ // locate OUR occurrence among same-name entries (null = no entry).
351
+ #form = null; #onFormdata; #lastFormValue = null;
352
+ // Selection DIRTINESS now lives PER OPTION (module-level OPT), like native.
353
+ // This host-level latch is only the guard on the one-shot value ATTRIBUTE:
354
+ // once a user commit or a value/selectedIndex write has picked a selection,
355
+ // a value attribute that has not been consumed yet (options streamed in
356
+ // after the write) must not override it. Re-armed by formResetCallback.
357
+ #written = false;
358
+
359
+ constructor() {
360
+ super();
361
+ this.#internals = this.attachInternals();
362
+ const r = this.attachShadow({ mode: 'open', delegatesFocus: true });
363
+ applyCSS(r);
364
+ // Skeleton via DOM APIs — no HTML-string sinks (strict CSP / Trusted Types safe).
365
+ // APG select-only combobox: the trigger owns role, name, expanded state, the
366
+ // popup relationship (aria-controls) and the active descendant.
367
+ const btn = this.#btn = document.createElement('button');
368
+ btn.type = 'button';
369
+ btn.setAttribute('part', 'control');
370
+ btn.setAttribute('role', 'combobox');
371
+ btn.setAttribute('aria-expanded', 'false');
372
+ btn.setAttribute('aria-controls', 'pop');
373
+ this.#labelEl = document.createElement('span');
374
+ this.#labelEl.id = 'label';
375
+ // SVG caret, not a text glyph — renders identically across platforms/fonts.
376
+ const svg = document.createElementNS(SVGNS, 'svg');
377
+ for (const [k, v] of [['class', 'car'], ['width', '10'], ['height', '6'],
378
+ ['viewBox', '0 0 10 6'], ['aria-hidden', 'true']]) svg.setAttribute(k, v);
379
+ const path = document.createElementNS(SVGNS, 'path');
380
+ for (const [k, v] of [['d', 'M1 1l4 4 4-4'], ['fill', 'none'],
381
+ ['stroke', 'currentColor'], ['stroke-width', '1.5'], ['stroke-linecap', 'round'],
382
+ ['stroke-linejoin', 'round']]) path.setAttribute(k, v);
383
+ svg.append(path);
384
+ btn.append(this.#labelEl, svg);
385
+ const pop = this.#pop = document.createElement('div');
386
+ pop.id = 'pop';
387
+ pop.setAttribute('part', 'popover');
388
+ pop.setAttribute('role', 'listbox');
389
+ pop.hidden = true;
390
+ r.append(btn, pop);
391
+ this.#btn.addEventListener('click', () => this.#toggle());
392
+ this.#btn.addEventListener('keydown', e => this.#onKey(e));
393
+ this.#pop.addEventListener('pointerdown', e => e.preventDefault()); // focus stays on the control
394
+ this.#pop.addEventListener('pointermove', e => {
395
+ const o = e.target.closest('.opt');
396
+ if (o && o.getAttribute('aria-disabled') !== 'true') this.#setActive(+o.dataset.i);
397
+ });
398
+ this.#pop.addEventListener('click', e => {
399
+ const o = e.target.closest('.opt');
400
+ if (o && o.getAttribute('aria-disabled') !== 'true') this.#commit(+o.dataset.i);
401
+ });
402
+ // Outside pointerdown (and any focus-out it causes) closes WITHOUT
403
+ // committing — native <select> cancels on outside click on both macOS and
404
+ // Windows; Tab is the committing departure (deliberate, reviewed).
405
+ this.#onDocDown = e => { if (!e.composedPath().includes(this)) this.#close(); };
406
+ // Delegated INNER-native label activation (bound in #bindLabelBridge while
407
+ // a native is adopted): if the clicked label is CURRENTLY associated with
408
+ // the wrapped native, focus the trigger — the force-disabled native can't
409
+ // take the activation. Guard at click time, not bind time: a host disabled
410
+ // AFTER binding must not have its trigger focused (native-disabled parity).
411
+ this.#onLabelClick = e => {
412
+ if (!this.#native || this.#btn.disabled) return;
413
+ const l = e.target instanceof Element ? e.target.closest('label') : null;
414
+ if (!l || ![...this.#native.labels].includes(l)) return;
415
+ // HTML label activation does NOT fire for clicks on interactive
416
+ // content inside the label — a nested link/button/input keeps its own
417
+ // behavior instead of activating the control. The closest() search
418
+ // stops at the label (the label itself never counts, nor anything
419
+ // outside it); the wrapped native select is exempt — it is
420
+ // force-disabled, and the bridge exists exactly to stand in for it.
421
+ const hit = e.target.closest(INTERACTIVE);
422
+ if (hit && hit !== l && hit !== this.#native && l.contains(hit)) return;
423
+ this.#btn.focus();
424
+ };
425
+ // FormData built in the SAME task as an option mutation would serialize
426
+ // the stale ElementInternals value (the observer is async; native option
427
+ // lists are synchronous — setFormValue only lands after the observer
428
+ // task). Reconcile on the `formdata` event, which fires synchronously
429
+ // while the entry list is constructed: drain pending mutations, then
430
+ // rebuild the outgoing FormData with this control's correction applied —
431
+ // a GLOBAL-ORDER rebuild (per-name delete+re-append would move the
432
+ // name's entries to the END of the whole list; see the FD state above).
433
+ this.#onFormdata = e => {
434
+ // The entry list was built from the internals value AT EVENT ENTRY —
435
+ // capture it BEFORE the drain below refreshes setFormValue.
436
+ const stale = this.#lastFormValue;
437
+ this.#drain();
438
+ const name = this.getAttribute('name');
439
+ // A disabled control contributed no entry (disabled controls are
440
+ // barred from submission) — and must not touch (or claim) a same-name
441
+ // entry some OTHER control may legitimately own.
442
+ if (!name || this.hasAttribute('disabled') || this.hasAttribute('data-form-disabled')) return;
443
+ const o = this.#opts[this.#idx];
444
+ // Mirrors #setIndex's setFormValue rule: a selected-but-disabled
445
+ // option — directly or via its group — or no selection submits NO entry.
446
+ const desired = o && !o.disabled ? o.value : null;
447
+ if (stale === null && desired === null) return; // contributed nothing, wants nothing — done
448
+ // (a) FIRST registering reconciler on this event: snapshot the
449
+ // COMPLETE entry list (every name, original global order) before any
450
+ // edit lands — the list is only ever edited by the rebuild below, so
451
+ // at first registration it is still exactly as constructed.
452
+ let st = FD.get(e);
453
+ if (!st) FD.set(e, st = { snap: [...e.formData], corr: new Map() });
454
+ // (b) Register this host's correction keyed by its DOCUMENT RANK among
455
+ // same-name mythical-select hosts (form.elements is tree-ordered and
456
+ // includes form-associated custom elements) — deterministic and
457
+ // independent of listener attach order, so every rebuild resolves the
458
+ // same value-to-slot assignment no matter which reconciler runs it.
459
+ // Registered even when desired === stale: the claim must still occupy
460
+ // its slot so equal-valued siblings resolve around it.
461
+ const rank = [...this.#form.elements]
462
+ .filter(el => el.localName === 'mythical-select' && el.getAttribute('name') === name)
463
+ .indexOf(this);
464
+ let corr = st.corr.get(name);
465
+ if (!corr) st.corr.set(name, corr = []);
466
+ corr.push({ rank, stale, desired });
467
+ // (c) EVERY reconciler rebuilds the ENTIRE list from snapshot +
468
+ // corrections (idempotent — reruns converge on the same list).
469
+ // Slot assignment per corrected name: entries carry no owner identity,
470
+ // so slots are matched by VALUE — the k same-name mythical hosts
471
+ // claiming stale value v take, in document-rank order, the LAST k
472
+ // occurrences of v among that name's snapshot entries (each host
473
+ // contributed its own stale entry, so an occurrence exists; taking
474
+ // the last ones leaves a preceding equal-valued NATIVE entry — e.g.
475
+ // <input value="a"> ahead of a stale-"a" host — unclaimed); natives
476
+ // keep the remaining slots, stationary.
477
+ // DOCUMENTED LIMITATION (platform boundary — FormData has no
478
+ // positional insert, and native submitting-ness is not replicable
479
+ // exactly): when same-name NATIVE controls interleave between
480
+ // same-name mythical hosts AND hold values equal to a mythical host's
481
+ // stale value, the value-to-slot assignment can still swap between
482
+ // those equal-valued slots — the list ORDER is always preserved and
483
+ // the value MULTISET is always correct, but WHICH equal-valued slot
484
+ // received the correction may differ from a real native form.
485
+ const assign = new Map(); // snapshot index → replacement (null = drop in place)
486
+ const tails = []; // [name, value] appends — the placement rule below
487
+ for (const [n, list] of st.corr) {
488
+ const slots = []; // this name's snapshot entries, global indices ascending
489
+ st.snap.forEach(([en], i) => { if (en === n) slots.push(i); });
490
+ const byRank = [...list].sort((a, b) => a.rank - b.rank);
491
+ const claims = new Map(); // stale value → total claim count k
492
+ for (const c of byRank) if (c.stale !== null) claims.set(c.stale, (claims.get(c.stale) ?? 0) + 1);
493
+ const queue = new Map(); // stale value → its claimed slots (the LAST k, ascending)
494
+ for (const [v, k] of claims)
495
+ queue.set(v, slots.filter(i => st.snap[i][1] === v).slice(-k));
496
+ for (const c of byRank) {
497
+ const at = c.stale === null ? undefined : queue.get(c.stale).shift();
498
+ if (at !== undefined) assign.set(at, c.desired); // replace, or remove (desired null)
499
+ // No slot to edit (this host contributed nothing at event time —
500
+ // e.g. a same-task re-enable of its selected-disabled option):
501
+ // the true document-order position is unknowable, so the
502
+ // PLACEMENT RULE is best-effort — a previously-contribute-nothing
503
+ // host's new entry appends at the END of the whole list
504
+ // (document-rank order among same-event same-name inserts).
505
+ else if (c.desired !== null) tails.push([n, c.desired]);
506
+ }
507
+ }
508
+ // Rebuild: delete every name present in the snapshot or corrections,
509
+ // then re-append ALL entries in ORIGINAL global order with the
510
+ // corrections applied — removed entries drop in place, cross-name
511
+ // order survives, tail inserts land last.
512
+ const names = new Set(st.snap.map(([n]) => n));
513
+ for (const n of st.corr.keys()) names.add(n);
514
+ for (const n of names) e.formData.delete(n);
515
+ st.snap.forEach(([n, v], i) => {
516
+ const out = assign.has(i) ? assign.get(i) : v;
517
+ if (out !== null) e.formData.append(n, out);
518
+ });
519
+ for (const [n, v] of tails) e.formData.append(n, v);
520
+ };
521
+ // <label for> activation: every engine forwards the label's click to the
522
+ // host (composedPath()[0] === host — impossible for real pointer clicks,
523
+ // which always start inside the shadow tree), but WebKit does not forward
524
+ // FOCUS to a form-associated element's delegatesFocus target. Do it
525
+ // ourselves — native parity: clicking a select's label focuses it
526
+ // (and only focuses: it never opens the picker).
527
+ this.addEventListener('click', e => {
528
+ if (e.composedPath()[0] === this) this.#btn.focus();
529
+ });
530
+ // Blocked form submission fires `invalid` at the element — that's the
531
+ // interaction signal :user-invalid keys on; mirror it for the fallback attr.
532
+ this.addEventListener('invalid', () => { this.#touched = true; this.#updateValidity(); });
533
+ // Label text can change after adoption (no observer watches the light DOM
534
+ // around us) — recompute the mirrored aria-label when focus arrives, so it
535
+ // is fresh at the moment a screen reader announces the control.
536
+ this.addEventListener('focusin', () => this.#syncLabel());
537
+ // Focus DEPARTURE while open (programmatic el2.focus(), AT focus moves…)
538
+ // cancels like outside-click: close WITHOUT committing. relatedTarget is
539
+ // null when focus leaves the document — outside too. Option clicks never
540
+ // land here (#pop's pointerdown preventDefault keeps focus on the trigger),
541
+ // and Tab keeps its commit: keydown committed + closed BEFORE focus moved,
542
+ // so #open is already false when this fires.
543
+ this.addEventListener('focusout', e => {
544
+ if (!this.#open) return;
545
+ const t = e.relatedTarget;
546
+ if (t instanceof Node && (this.contains(t) || r.contains(t))) return;
547
+ this.#close();
548
+ });
549
+ // ONE observer for the element's whole life, observing from construction —
550
+ // never per-connect (repeated connects would stack observers, and a
551
+ // DETACHED host would never see its wrapped native depart, so its
552
+ // presentation restore would never run). Observation works on detached
553
+ // trees and does not leak: observer and host GC together. Pre-connection
554
+ // mutations flow through the SAME record replay (#replay).
555
+ this.#mo = new MutationObserver(recs => {
556
+ const rel = this.#relevant(recs);
557
+ if (rel.length) this.#adopt({ recs: rel });
558
+ });
559
+ this.#mo.observe(this, { childList: true, subtree: true, attributes: true,
560
+ characterData: true, // text-node edits re-adopt (label/implicit value)
561
+ attributeFilter: ['value', 'label', 'disabled', 'selected'] });
562
+ }
563
+
564
+ connectedCallback() {
565
+ // Fold any records still queued from PRE-CONNECT mutations into this
566
+ // adoption — they carry real selection semantics (a [selected] option
567
+ // streamed in while detached), and replaying them afterwards would
568
+ // re-resolve against a consumed #attrPending.
569
+ this.#adopt({ recs: this.#relevant(this.#mo.takeRecords()) });
570
+ }
571
+ // Disconnect drops the document-level label bridge (no leak through a
572
+ // departed host); reconnect re-binds it via connectedCallback → #adopt.
573
+ disconnectedCallback() { this.#close(); this.#unbindLabelBridge(); }
574
+
575
+ /* Records worth re-adopting for. Attribute records on the HOST itself belong
576
+ to attributeChangedCallback — re-adopting on them would e.g. revert an
577
+ unmatched value="…" write (no-match → selection correctly cleared) back to
578
+ the [selected]/first default while the attribute still says otherwise.
579
+ Everything else (child/subtree childList, option attributes, text) either
580
+ carries selection semantics (see #replay) or refreshes the rendered list. */
581
+ #relevant(recs) { return recs.filter(r => r.type !== 'attributes' || r.target !== this); }
582
+
583
+ /* Synchronous flush of pending option mutations (the MutationObserver is
584
+ async; native option lists are synchronous): called at the top of every
585
+ PUBLIC option-dependent member — the value/selection/options surface AND
586
+ the validity-facing members (validity, validationMessage, willValidate,
587
+ checkValidity, reportValidity), plus the formdata reconciliation
588
+ listener — so same-task mutate-then-use resolves against the fresh
589
+ option list — never in internal paths (#adopt already flushes its own
590
+ records at the end, so this cannot recurse). Records are
591
+ classified exactly like the async callback would classify them; taking
592
+ them here empties the observer queue, so the callback won't replay them. */
593
+ #drain() {
594
+ const recs = this.#relevant(this.#mo.takeRecords());
595
+ if (recs.length) this.#adopt({ recs });
596
+ }
597
+
598
+ attributeChangedCallback(n, ov, nv) {
599
+ // Pre-adoption attrs are read by #adopt() itself — its tail #syncLabel /
600
+ // #syncDisabled / #updateValidity calls pick them up, so the early-return
601
+ // below is safe for the aria naming attrs too.
602
+ if (!this.#adopted) return;
603
+ if (n === 'value' && ov !== nv) this.value = nv ?? '';
604
+ if (n === 'disabled') this.#syncDisabled();
605
+ if (n === 'required') this.#updateValidity();
606
+ if (n === 'aria-label' || n === 'aria-labelledby') this.#syncLabel();
607
+ }
608
+
609
+ /* ---- option adoption: pure tags, or a wrapped native <select> ----
610
+ Selection is NEVER reconstructed from a fresh [selected] scan: it is read
611
+ off the LIVE per-option selectedness (module-level OPT), which the record
612
+ replay below maintains exactly like native maintains it on the option
613
+ nodes. Callers:
614
+ { recs } observer/drain/connect — the mutation records to replay IN
615
+ ORDER (a removal's ask-for-reset must land before the
616
+ matching re-insertion of a MOVE — see #replay);
617
+ { reset } form reset — option state is put back to the [selected]
618
+ defaults by formResetCallback, then ask-for-reset runs here.
619
+ The FIRST adoption of pure tags also asks for a reset (that is what a
620
+ parsed <select> does). The first adoption of a WRAPPED native does NOT:
621
+ the real select already resolved its own selection, and its live
622
+ selectedness — including a pre-upgrade script write, or a script CLEAR to
623
+ -1 — is what we adopt (header contract). */
624
+ #adopt({ recs = [], reset = false } = {}) {
625
+ // Re-adoption while open: close first and drop the active-index state so
626
+ // the rebuild can leave no dangling aria-activedescendant idref and no
627
+ // out-of-range commit target.
628
+ if (this.#open) this.#close();
629
+ this.#act = -1;
630
+ const native = this.querySelector(':scope > select');
631
+ const upgrading = native && native !== this.#native; // first sight of this native
632
+ // A previously-adopted native that is no longer our child gets its
633
+ // PRESENTATION back — the EXACT pre-upgrade inline display / aria-hidden /
634
+ // tabindex snapshotted at adoption, not cleared-to-empty values — never its
635
+ // name: the host owns form identity (header contract). Only the RECORDED
636
+ // owner restores (moved directly into another host, the native has a new
637
+ // owner whose adoption reuses the snapshot — restoring here would fight
638
+ // it), and restoring CONSUMES the entry so it runs exactly once.
639
+ if (this.#native && this.#native.parentNode !== this) {
640
+ const s = SNAPS.get(this.#native);
641
+ if (s && s.owner === this) {
642
+ SNAPS.delete(this.#native);
643
+ this.#native.style.display = s.display;
644
+ if (s.ariaHidden === null) this.#native.removeAttribute('aria-hidden');
645
+ else this.#native.setAttribute('aria-hidden', s.ariaHidden);
646
+ if (s.tabindex === null) this.#native.removeAttribute('tabindex');
647
+ else this.#native.setAttribute('tabindex', s.tabindex);
648
+ // the adoption-time force-disable (below) comes off too — the AUTHOR's
649
+ // original disabled state returns, not our own
650
+ if (s.disabled === null) this.#native.removeAttribute('disabled');
651
+ else this.#native.setAttribute('disabled', s.disabled);
652
+ }
653
+ }
654
+ const list = [];
655
+ // `el` = the SOURCE element per option — selection preservation across
656
+ // re-adoption is by identity first (see the order note below).
657
+ // Canonical option properties (native parity): a wrapped native's options
658
+ // are read through HTMLOptionElement.value/.text/.label — value falls back
659
+ // to the whitespace-stripped-and-collapsed text, a label attribute
660
+ // overrides the DISPLAY text (never the text-derived value). mythical-option
661
+ // gets the SAME normalization via collapse().
662
+ const push = (el, grp) => {
663
+ const nat = el.tagName === 'OPTION';
664
+ const text = nat ? el.text : collapse(el.textContent);
665
+ list.push({
666
+ el,
667
+ grpEl: grp ?? null, // source GROUP element — boundaries/validity key on identity
668
+ value: nat ? el.value : el.getAttribute('value') ?? text,
669
+ text,
670
+ label: nat ? el.label : el.getAttribute('label') ?? text,
671
+ disabled: el.hasAttribute('disabled') || (grp?.hasAttribute('disabled') ?? false),
672
+ group: grp?.getAttribute('label') || null,
673
+ });
674
+ };
675
+ const scan = (root, opt, grp) => {
676
+ for (const el of root.children) {
677
+ if (el.matches(grp)) { for (const o of el.children) if (o.matches(opt)) push(o, el); }
678
+ else if (el.matches(opt)) push(el, null);
679
+ }
680
+ };
681
+ if (native) {
682
+ // Upgrade: the host owns the form value; the native control goes inert and is
683
+ // thereafter kept in sync one-way host→native (contract in the header).
684
+ // Snapshot the ORIGINAL presentation first (only on first sight — later
685
+ // re-adoptions see our own inert values) for an exact departed-native
686
+ // restore. A native arriving FROM another host still carries that owner's
687
+ // forced inert state, not the author's: REUSE the original snapshot and
688
+ // just take ownership (module-level SNAPS — the old owner's restore path
689
+ // then skips, see above).
690
+ if (upgrading) {
691
+ const prev = SNAPS.get(native);
692
+ if (prev) prev.owner = this;
693
+ else SNAPS.set(native, {
694
+ owner: this,
695
+ display: native.style.display,
696
+ ariaHidden: native.getAttribute('aria-hidden'),
697
+ tabindex: native.getAttribute('tabindex'),
698
+ disabled: native.getAttribute('disabled'), // the AUTHOR's — read before the force-disable below
699
+ });
700
+ // Attribute forwarding happens ONLY here, at first sight of this
701
+ // native: from then on the host's attributes are HOST-OWNED — a
702
+ // consumer clearing a forwarded required/disabled/form on the host
703
+ // must not get it re-imposed by later re-adoptions (option mutations,
704
+ // form reset). `form` forwards too: FACE honors the form content
705
+ // attribute for association, so a wrapped <select form="f"> outside
706
+ // its form keeps submitting into THAT form after upgrade.
707
+ for (const a of ['name', 'required', 'form'])
708
+ if (native.hasAttribute(a) && !this.hasAttribute(a)) this.setAttribute(a, native.getAttribute(a) || '');
709
+ // disabled forwards from the SNAPSHOT, never the live attribute: a
710
+ // native arriving FROM another host still carries that owner's
711
+ // force-disable — reading it live would wrongly disable the host on
712
+ // the author's behalf.
713
+ const snap = SNAPS.get(native);
714
+ if (snap.disabled !== null && !this.hasAttribute('disabled'))
715
+ this.setAttribute('disabled', snap.disabled);
716
+ }
717
+ native.style.display = 'none'; native.setAttribute('aria-hidden', 'true');
718
+ native.tabIndex = -1;
719
+ // Force-disable the adopted native AFTER the forwarding above: disabled
720
+ // controls are barred from constraint validation and submission, so the
721
+ // hidden native can't fire duplicate `invalid` events or block the form
722
+ // independently of the host (its name is removed, but unnamed controls
723
+ // still constraint-validate). Guarded — blindly re-setting the attribute
724
+ // would retrigger the MutationObserver ('disabled' is in its filter).
725
+ // Host→native selectedIndex sync is unaffected: property writes on a
726
+ // disabled select work fine.
727
+ if (!native.disabled) native.disabled = true;
728
+ native.removeAttribute('name');
729
+ scan(native, 'option', 'optgroup');
730
+ } else {
731
+ scan(this, 'mythical-option', 'mythical-optgroup');
732
+ }
733
+ // Seed per-option state for every option now in the list, BEFORE the
734
+ // replay: an option INSERTED by these very records must already carry the
735
+ // selectedness its [selected] attribute gives it when its insertion asks
736
+ // for a reset (that is how an inserted [selected] carrier wins).
737
+ // At UPGRADE the wrapped native's own resolved selectedness is the truth
738
+ // (a pre-upgrade script write beats stale [selected] attrs — header
739
+ // contract), so seed from the LIVE property instead of the attribute.
740
+ // Its dirtiness is inferred: an option the script SELECTED against its
741
+ // own default (live selected, no [selected] attribute) is dirty in the
742
+ // real select too — a later attribute change must not move it. Options
743
+ // the script merely DESELECTED keep dirtiness false, like native (only
744
+ // the newly selected option is made dirty). DOCUMENTED, undetectable edge
745
+ // (unchanged): a pre-upgrade script that selected the option that was
746
+ // ALREADY the default reads as pristine — a wrapped select's interaction
747
+ // history is not inspectable.
748
+ for (const o of list)
749
+ optState(o.el, upgrading
750
+ ? { selectedness: o.el.selected, dirtiness: o.el.selected && !o.el.hasAttribute('selected') }
751
+ : undefined);
752
+ // Records are only replayed against an ALREADY-ADOPTED list: anything
753
+ // queued before the first adoption merely describes how the initial DOM
754
+ // was built, which the scan + seeding above already captures in full.
755
+ // Replaying it would re-insert those options into an empty list and ask
756
+ // for a reset — resurrecting a selection an upgraded native deliberately
757
+ // has none of (a pre-upgrade script CLEAR).
758
+ if (this.#adopted) this.#replay(recs, list);
759
+ this.#native = native;
760
+ this.#opts = list;
761
+ // Activation bridge for INNER-native labels (progressive markup commonly
762
+ // labels the wrapped select itself): one DELEGATED document click listener
763
+ // while a native is adopted — labels added or retargeted after adoption
764
+ // still bridge (resolved against the current native.labels at click time).
765
+ // Departure unbinds it, so a departed native's labels revert to plain
766
+ // native activation against the restored select.
767
+ if (native) this.#bindLabelBridge(); else this.#unbindLabelBridge();
768
+ // listbox DOM — built with createElement/textContent, no HTML-string sinks.
769
+ // Labeled groups become role=group containers labelled by their heading
770
+ // (aria-labelledby); ungrouped options stay direct listbox children.
771
+ // Option ids (o<i>) and data-i stay FLAT across groups — nav is by index.
772
+ this.#pop.replaceChildren();
773
+ // Group boundaries key on the SOURCE group element's identity, not its label
774
+ // text — two ADJACENT groups with identical labels are two role=group
775
+ // containers, like two native <optgroup>s (the label string is display-only).
776
+ let lastSrc, grpBox = null, gi = 0;
777
+ list.forEach((o, i) => {
778
+ if (o.grpEl !== lastSrc) {
779
+ if (o.grpEl && o.group) {
780
+ const h = document.createElement('div');
781
+ h.className = 'grp'; h.id = `g${gi}`; h.textContent = o.group;
782
+ grpBox = document.createElement('div');
783
+ grpBox.setAttribute('role', 'group');
784
+ grpBox.setAttribute('aria-labelledby', `g${gi}`);
785
+ grpBox.append(h);
786
+ this.#pop.append(grpBox);
787
+ gi++;
788
+ } else grpBox = null;
789
+ }
790
+ lastSrc = o.grpEl;
791
+ const d = document.createElement('div');
792
+ d.className = 'opt'; d.setAttribute('part', 'option'); d.id = `o${i}`;
793
+ d.dataset.i = i; d.setAttribute('role', 'option');
794
+ if (o.disabled) d.setAttribute('aria-disabled', 'true');
795
+ const txt = document.createElement('span'); txt.textContent = o.label; // display text — label attr overrides
796
+ const mark = document.createElement('span');
797
+ mark.className = 'mark'; mark.setAttribute('aria-hidden', 'true'); mark.textContent = '✓';
798
+ d.append(txt, mark);
799
+ (grpBox ?? this.#pop).append(d);
800
+ });
801
+ // Ask for reset (the spec algorithm) on the FIRST adoption of pure tags —
802
+ // what a freshly parsed <select> does: nothing selected → the first
803
+ // NON-DISABLED option; several [selected] → the last one wins. An UPGRADE
804
+ // skips it (the wrapped native already resolved its own selection, and a
805
+ // live -1 from a pre-upgrade script CLEAR must stay -1), and so does every
806
+ // later adoption unless its records ask for one (#replay) — native only
807
+ // resets on membership changes, so a -1 survives attribute/text edits.
808
+ if (reset || (!this.#adopted && !upgrading)) this.#askForReset(list);
809
+ // One-shot value attribute: applicable until the first adoption that
810
+ // actually FINDS options (a register-before-markup connect adopts zero
811
+ // children — the attribute must survive until the options stream in), and
812
+ // never once a selection has been WRITTEN. Consumed only by an
813
+ // option-bearing adoption. It outranks everything above (documented
814
+ // extension semantics, incl. an inserted [selected] carrier and a wrapped
815
+ // native's pre-upgrade script write — header contract) and selects
816
+ // through the same per-option path, WITHOUT dirtying: it is a default,
817
+ // so a later [selected] change still moves a pristine control.
818
+ const attrV = this.getAttribute('value');
819
+ if (attrV !== null && this.#attrPending && !this.#written) {
820
+ const i = list.findIndex(o => o.value === attrV);
821
+ if (i >= 0) this.#selectEl(list, i, false);
822
+ }
823
+ if (list.length) this.#attrPending = false;
824
+ // The selection IS the live per-option selectedness — no attribute scan.
825
+ // (reduce, not findLastIndex — Firefox 93–103 are in the support matrix;
826
+ // ask-for-reset leaves at most one, so the last is the one.)
827
+ const sel = list.reduce((a, o, i) => optState(o.el).selectedness ? i : a, -1);
828
+ this.#adopted = true;
829
+ this.#setIndex(sel);
830
+ this.#syncDisabled();
831
+ this.#syncLabel();
832
+ // Drop records this fresh scan already consumed — our OWN adoption
833
+ // mutations (the native force-disable above) and any leftovers from
834
+ // mutations predating a direct #adopt call (lazy value-setter adoption,
835
+ // reset). Replaying them would re-resolve against the post-adoption state
836
+ // — e.g. a [selected] carrier record surviving a later .value write would
837
+ // come back as 'force' and snap the selection off the written value.
838
+ this.#mo.takeRecords();
839
+ }
840
+
841
+ /* Replay observer records IN ORDER against the live per-option state. Order
842
+ matters: a MOVE arrives as a removal record PLUS an insertion record, and
843
+ native runs the removal's ask-for-reset (landing on the first enabled
844
+ option) BEFORE the re-insertion — reading only the final DOM would pick
845
+ the wrong option. `list` is the freshly scanned FINAL option list. */
846
+ #replay(recs, list) {
847
+ if (!recs.length) return;
848
+ const prevEls = this.#opts.map(o => o.el);
849
+ const finalEls = list.map(o => o.el);
850
+ const finalSet = new Set(finalEls);
851
+ const known = new Set(prevEls); // options already adopted before these records
852
+ // Ordering backbone over union(previous, final): the FINAL tree order,
853
+ // with each REMOVED option spliced back in just before its nearest
854
+ // surviving successor from the previous order (walked backwards so runs
855
+ // of removals keep their relative order).
856
+ // APPROXIMATION (documented boundary): an option's position in an
857
+ // INTERMEDIATE state is taken to be its final one — exact for the
858
+ // single-move / insert / remove batches that occur in practice, but a
859
+ // multi-move batch could order an intermediate state differently from the
860
+ // real DOM. An option inserted AND removed within one batch (in neither
861
+ // list) is not modeled at all.
862
+ const order = [...finalEls];
863
+ for (let i = prevEls.length - 1; i >= 0; i--) {
864
+ if (finalSet.has(prevEls[i])) continue;
865
+ let at = order.length;
866
+ for (let j = i + 1; j < prevEls.length; j++) {
867
+ const k = order.indexOf(prevEls[j]);
868
+ if (k >= 0) { at = k; break; }
869
+ }
870
+ order.splice(at, 0, prevEls[i]);
871
+ }
872
+ const live = new Set(prevEls);
873
+ const dis = new Map(list.map(o => [o.el, o.disabled]));
874
+ // A removed option is already out of the DOM, so its group is unreachable
875
+ // — its own [disabled] is all we can read (and it only matters while the
876
+ // option is still `live` in an intermediate state).
877
+ const disabled = el => dis.get(el) ?? (el.hasAttribute('disabled') ||
878
+ !!(el.parentElement && isGrp(el.parentElement) && el.parentElement.hasAttribute('disabled')));
879
+ const rows = () => order.filter(el => live.has(el)).map(el => ({ el, disabled: disabled(el) }));
880
+ // Option elements at select/optgroup level within an added/removed
881
+ // subtree. Content INSIDE an option is display text, never membership.
882
+ const optsIn = n => n.nodeType !== 1 ? []
883
+ : [...(isOpt(n) ? [n] : []), ...(n.querySelectorAll?.('mythical-option,option') ?? [])]
884
+ .filter(el => finalSet.has(el) || known.has(el));
885
+ const inOption = t => { for (let n = t; n; n = n.parentElement) if (known.has(n)) return true; return false; };
886
+ for (const r of recs) {
887
+ if (r.type === 'attributes') {
888
+ // Only `selected` carries selection semantics — value/label/disabled
889
+ // records just refresh the rebuilt list.
890
+ if (r.attributeName !== 'selected' || !isOpt(r.target) || !live.has(r.target)) continue;
891
+ const s = optState(r.target);
892
+ // The content attribute is only the DEFAULT selectedness: once the
893
+ // option is DIRTY (a user commit or a selection write touched it),
894
+ // adding or removing [selected] does nothing at all.
895
+ if (s.dirtiness) continue;
896
+ const want = r.target.hasAttribute('selected');
897
+ if (want === s.selectedness) continue; // no state change → nothing to reset
898
+ s.selectedness = want;
899
+ // Single select: an option becoming selected deselects every other
900
+ // one. THIS is what makes a [selected] default added later beat an
901
+ // earlier programmatic pick on a DIFFERENT (non-dirty) option —
902
+ // probed unanimous on chromium/firefox/webkit for a non-first option;
903
+ // see the differential tests for Firefox's index-0 deviation.
904
+ if (want) for (const el of order) { if (el !== r.target && live.has(el)) optState(el).selectedness = false; }
905
+ // An option losing its selectedness leaves a menu-list single select
906
+ // with nothing selected — it re-picks the first selectable option
907
+ // (probed, unanimous).
908
+ else this.#askForReset(rows());
909
+ } else if (r.type === 'childList' && !inOption(r.target)) {
910
+ let changed = false;
911
+ for (const n of r.removedNodes) for (const el of optsIn(n)) changed = live.delete(el) || changed;
912
+ for (const n of r.addedNodes) for (const el of optsIn(n)) if (!live.has(el)) { live.add(el); changed = true; }
913
+ // MEMBERSHIP changed → ask for reset at THIS point in the sequence.
914
+ // (An option's CONTENT changing — text nodes, inline markup — is not
915
+ // membership: native leaves even a -1 selection alone. Same for a
916
+ // [selected]-attributed NON-option node: never a carrier.)
917
+ if (changed) this.#askForReset(rows());
918
+ }
919
+ }
920
+ }
921
+
922
+ /* The spec's "ask for reset" for a single select over `rows` ({el, disabled}
923
+ in tree order): nothing selected → select the first NON-DISABLED option;
924
+ two or more selected → keep the LAST (so an inserted [selected] carrier
925
+ beats a standing selection). With EVERY option disabled there is no
926
+ selection at all — the spec picks "the first option … that is not
927
+ disabled, IF ANY", and Chromium + Firefox agree (probed); WebKit alone
928
+ selects the first option anyway — we follow the spec majority. */
929
+ #askForReset(rows) {
930
+ const on = rows.filter(o => optState(o.el).selectedness);
931
+ if (!on.length) { const first = rows.find(o => !o.disabled); if (first) optState(first.el).selectedness = true; }
932
+ else for (const o of on.slice(0, -1)) optState(o.el).selectedness = false;
933
+ }
934
+
935
+ /* Select row `i` (or nothing, at -1), deselecting all the others — the
936
+ spec's "select an option, deselect others". `dirty` marks the newly
937
+ selected option DIRTY: only the selected one, exactly like native — the
938
+ options merely DESELECTED keep their dirtiness, so their [selected]
939
+ attribute still writes their selectedness afterwards. */
940
+ #selectEl(rows, i, dirty) {
941
+ rows.forEach((o, n) => { optState(o.el).selectedness = n === i; });
942
+ if (dirty && rows[i]) optState(rows[i].el).dirtiness = true;
943
+ }
944
+
945
+ /* ---- selection state ---- */
946
+ // A user/programmatic SELECTION write (never an adoption): selects the
947
+ // option, deselects the rest, marks the picked one dirty, and arms the
948
+ // host-level #written latch that the one-shot value ATTRIBUTE defers to.
949
+ #write(i, fire) { this.#written = true; this.#selectEl(this.#opts, i, true); this.#setIndex(i, fire); }
950
+ #setIndex(i, fire = false) {
951
+ this.#idx = i;
952
+ const o = this.#opts[i];
953
+ this.#labelEl.textContent = o ? o.label : ''; // display text — label attr overrides
954
+ this.#pop.querySelectorAll('.opt').forEach(n =>
955
+ n.setAttribute('aria-selected', String(+n.dataset.i === i)));
956
+ // one-way host→native sync (header contract) — property write, fires no events
957
+ if (this.#native) this.#native.selectedIndex = i;
958
+ // Native form submission skips disabled options: a selected-but-disabled
959
+ // option (directly or via its group — o.disabled folds both in) submits
960
+ // NO form entry. Validity is untouched — required/valueMissing still
961
+ // compute off the selection itself (incl. the placeholder rule below).
962
+ const fv = o && !o.disabled ? o.value : null;
963
+ this.#internals.setFormValue(fv);
964
+ this.#lastFormValue = fv; // the entry this host contributes to a formdata event (see #onFormdata)
965
+ this.#updateValidity();
966
+ if (fire) {
967
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
968
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
969
+ }
970
+ }
971
+ #commit(i) { this.#touched = true; this.#write(i, i !== this.#idx); this.#close(); this.#btn.focus(); }
972
+
973
+ /* ---- open/close/keyboard ---- */
974
+ #toggle() { if (this.#btn.disabled) return; this.#open ? this.#close() : this.#openList(); }
975
+ #openList() {
976
+ if (!this.#opts.length) return;
977
+ this.#open = true; this.#pop.hidden = false;
978
+ // Viewport-aware: open upward when the list would clip below but fits above
979
+ // (need = list height + the 6px anchor gap in the #pop rules + 2px breathing).
980
+ const b = this.#btn.getBoundingClientRect(), need = this.#pop.offsetHeight + 8;
981
+ this.toggleAttribute('data-flip',
982
+ b.bottom + need > document.documentElement.clientHeight && b.top - need > 0);
983
+ this.toggleAttribute('data-open', true);
984
+ this.#btn.setAttribute('aria-expanded', 'true');
985
+ this.#setActive(this.#idx >= 0 ? this.#idx : this.#step(-1, 1));
986
+ document.addEventListener('pointerdown', this.#onDocDown, true);
987
+ }
988
+ #close() {
989
+ if (!this.#open) return;
990
+ this.#open = false; this.#pop.hidden = true;
991
+ this.removeAttribute('data-open');
992
+ this.#btn.setAttribute('aria-expanded', 'false');
993
+ this.#btn.removeAttribute('aria-activedescendant');
994
+ document.removeEventListener('pointerdown', this.#onDocDown, true);
995
+ }
996
+ #setActive(i) {
997
+ // While navigating, aria-selected stays on the COMMITTED option — selection-
998
+ // follows-focus is optional listbox semantics; ours mirrors native commit-on-
999
+ // close, and the active option is conveyed by aria-activedescendant instead.
1000
+ if (i < 0) return;
1001
+ this.#act = i;
1002
+ this.#pop.querySelectorAll('.opt').forEach(n => n.toggleAttribute('data-active', +n.dataset.i === i));
1003
+ this.#btn.setAttribute('aria-activedescendant', `o${i}`);
1004
+ this.#pop.querySelector(`#o${i}`)?.scrollIntoView({ block: 'nearest' });
1005
+ }
1006
+ #step(from, dir) { // next enabled index in direction, or stay
1007
+ for (let i = from + dir; i >= 0 && i < this.#opts.length; i += dir)
1008
+ if (!this.#opts[i].disabled) return i;
1009
+ return from >= 0 ? from : -1;
1010
+ }
1011
+ #onKey(e) {
1012
+ const k = e.key;
1013
+ if (!this.#open) {
1014
+ if (['ArrowDown', 'ArrowUp', ' ', 'Enter'].includes(k)) { e.preventDefault(); this.#openList(); return; }
1015
+ if (k === 'Home' || k === 'End') { // APG: open with the first/last enabled option active — commits nothing
1016
+ e.preventDefault(); this.#openList();
1017
+ this.#setActive(k === 'Home' ? this.#step(-1, 1) : this.#step(this.#opts.length, -1));
1018
+ return;
1019
+ }
1020
+ } else {
1021
+ if (k === 'Escape') { e.preventDefault(); this.#close(); return; }
1022
+ if (k === 'Enter' || k === ' ') { e.preventDefault(); if (this.#act >= 0) this.#commit(this.#act); return; }
1023
+ // APG chord — open + Alt+ArrowUp commits the active option and closes;
1024
+ // handled BEFORE ordinary ArrowUp so the chord never just moves active.
1025
+ if (k === 'ArrowUp' && e.altKey) {
1026
+ e.preventDefault();
1027
+ if (this.#act >= 0) this.#commit(this.#act); else this.#close();
1028
+ return;
1029
+ }
1030
+ if (k === 'ArrowDown') { e.preventDefault(); this.#setActive(this.#step(this.#act, 1)); return; }
1031
+ if (k === 'ArrowUp') { e.preventDefault(); this.#setActive(this.#step(this.#act, -1)); return; }
1032
+ if (k === 'Home') { e.preventDefault(); this.#setActive(this.#step(-1, 1)); return; }
1033
+ if (k === 'End') { e.preventDefault(); this.#setActive(this.#step(this.#opts.length, -1)); return; }
1034
+ if (k === 'PageDown' || k === 'PageUp') { // OPEN only: jump 10 enabled options,
1035
+ // clamped at the first/last enabled. Closed PageUp/PageDown is deliberately
1036
+ // NOT handled (falls through to nothing) — native <select> parity.
1037
+ e.preventDefault();
1038
+ const dir = k === 'PageDown' ? 1 : -1;
1039
+ let i = this.#act;
1040
+ for (let n = 0; n < 10; n++) { const nx = this.#step(i, dir); if (nx === i) break; i = nx; }
1041
+ this.#setActive(i);
1042
+ return;
1043
+ }
1044
+ if (k === 'Tab') { // commit like Enter, but no preventDefault — focus moves on
1045
+ if (this.#act >= 0) { this.#touched = true; this.#write(this.#act, this.#act !== this.#idx); }
1046
+ this.#close(); return;
1047
+ }
1048
+ }
1049
+ // typeahead — CLOSED typeahead commits without opening: native <select>
1050
+ // parity (deliberate). Printable keys carrying Ctrl/Alt/Meta are browser
1051
+ // shortcuts (Ctrl+C, Cmd+F…) — they must never typeahead-commit. The
1052
+ // modifier bail applies ONLY to this branch: the Alt+Arrow chords above
1053
+ // (and closed Alt+ArrowDown → open, via the include list) stay live.
1054
+ if (k.length === 1 && /\S/.test(k) && !e.ctrlKey && !e.altKey && !e.metaKey) {
1055
+ const now = Date.now();
1056
+ this.#ta = (now - this.#taT < 600 ? this.#ta : '') + k.toLowerCase(); this.#taT = now;
1057
+ // A SINGLE-character buffer — one press, or the same character repeated
1058
+ // ("a", "aa", "aaa"…) — CYCLES: search from just past the current active
1059
+ // (open) / selected (closed) option for the next option starting with that
1060
+ // character, wrapping around — native parity ("ash" selected, press "a" →
1061
+ // "aspen" commits). Multi-character DISTINCT prefixes keep from-start
1062
+ // prefix matching.
1063
+ const cycle = this.#ta.length === 1 || [...this.#ta].every(c => c === this.#ta[0]);
1064
+ let i = -1;
1065
+ if (cycle) {
1066
+ const from = this.#open ? this.#act : this.#idx, n = this.#opts.length;
1067
+ for (let s = 1; s <= n; s++) {
1068
+ const j = (from + s + n) % n, o = this.#opts[j];
1069
+ if (!o.disabled && o.label.toLowerCase().startsWith(this.#ta[0])) { i = j; break; }
1070
+ }
1071
+ } else {
1072
+ i = this.#opts.findIndex(o => !o.disabled && o.label.toLowerCase().startsWith(this.#ta));
1073
+ }
1074
+ if (i >= 0) this.#open ? this.#setActive(i) : this.#commit(i);
1075
+ }
1076
+ }
1077
+
1078
+ /* ---- native-parity API ----
1079
+ Option-dependent members #drain() pending option mutations first (the
1080
+ observer is async, native option lists are synchronous) — a same-task
1081
+ append + value write/read resolves against the fresh list, not stale
1082
+ #opts that the later observer callback would then "correct". */
1083
+ get type() { return 'select-one'; }
1084
+ get value() { this.#drain(); return this.#opts[this.#idx]?.value ?? ''; }
1085
+ // Pre-connect writes (never-adopted element): adopt synchronously first — the
1086
+ // child scan works while disconnected — so the write resolves against the real
1087
+ // option list instead of an empty one; the connect-time re-adoption then keeps
1088
+ // the chosen value via the dirty-preservation path.
1089
+ set value(v) { this.#drain(); if (!this.#adopted) this.#adopt(); this.#write(this.#opts.findIndex(o => o.value === String(v)), false); }
1090
+ get selectedIndex() { this.#drain(); return this.#idx; }
1091
+ // WebIDL long conversion like native ('1' and 1.9 both mean index 1,
1092
+ // NaN/Infinity → 0); out-of-range still clears the selection to -1.
1093
+ set selectedIndex(i) { this.#drain(); if (!this.#adopted) this.#adopt(); const n = toLong(i); this.#write(this.#opts[n] ? n : -1, false); }
1094
+ // snapshot reports CURRENT selectedness, not the adoption-time [selected] flag
1095
+ // (the internal source-element/group-element references stay private — the
1096
+ // group LABEL string remains exposed as `group`)
1097
+ get options() { this.#drain(); return this.#opts.map(({ el: _el, grpEl: _grpEl, ...o }, index) => ({ ...o, index, selected: index === this.#idx })); }
1098
+ item(i) { this.#drain(); const n = toLong(i); return (n < 0 ? null : this.options[n]) ?? null; } // unsigned long: negatives → null
1099
+ get length() { this.#drain(); return this.#opts.length; }
1100
+ get name() { return this.getAttribute('name') ?? ''; }
1101
+ set name(v) { this.setAttribute('name', v); }
1102
+ get disabled() { return this.hasAttribute('disabled'); }
1103
+ set disabled(v) { this.toggleAttribute('disabled', !!v); }
1104
+ get required() { return this.hasAttribute('required'); }
1105
+ set required(v) { this.toggleAttribute('required', !!v); }
1106
+ get form() { return this.#internals.form; }
1107
+ get labels() { return this.#internals.labels; }
1108
+ // The validity-facing members #drain() too: option mutations feed validity
1109
+ // (placeholder shape, disabled state, membership), and native constraint
1110
+ // validation sees them synchronously — a same-task mutate-then-check must
1111
+ // not read the stale pre-observer internals state.
1112
+ get validity() { this.#drain(); return this.#internals.validity; }
1113
+ get validationMessage() { this.#drain(); return this.#internals.validationMessage; }
1114
+ get willValidate() { this.#drain(); return this.#internals.willValidate; }
1115
+ checkValidity() { this.#drain(); return this.#internals.checkValidity(); }
1116
+ reportValidity() { this.#drain(); this.#touched = true; this.#updateValidity(); return this.#internals.reportValidity(); }
1117
+ setCustomValidity(msg) {
1118
+ // WebIDL DOMString conversion (native parity): EVERY argument
1119
+ // stringifies — setCustomValidity(0) sets the error "0" (invalid),
1120
+ // null sets "null"; only the empty STRING clears. The argument is
1121
+ // required — a zero-arg call throws a TypeError like native.
1122
+ if (arguments.length === 0)
1123
+ throw new TypeError("Failed to execute 'setCustomValidity' on 'MythicalSelect': 1 argument required, but only 0 present.");
1124
+ this.#customError = String(msg);
1125
+ this.#updateValidity();
1126
+ }
1127
+
1128
+ /* ---- form lifecycle ---- */
1129
+ // Track the associated form for the formdata reconciliation listener (see
1130
+ // #onFormdata): ONE listener per association, detached from the previous
1131
+ // form on re-association (and on disassociation — the callback fires with
1132
+ // null), so a departed control never edits a stranger form's entries.
1133
+ // CAPTURE phase: the reconciler must correct the entry list BEFORE any
1134
+ // author `formdata` listener reads it — a bubble-phase reconciler would let
1135
+ // listeners registered earlier observe our stale entry, where a native
1136
+ // select would already expose the fresh value.
1137
+ formAssociatedCallback(form) {
1138
+ if (this.#form) this.#form.removeEventListener('formdata', this.#onFormdata, true);
1139
+ this.#form = form ?? null;
1140
+ if (form) form.addEventListener('formdata', this.#onFormdata, true);
1141
+ this.#syncLabel();
1142
+ }
1143
+ // The spec's form-reset for a select: every option's selectedness goes back
1144
+ // to its DEFAULT (its [selected] attribute), every option's dirtiness
1145
+ // clears — then ask for reset (run by #adopt).
1146
+ formResetCallback() {
1147
+ for (const o of this.#opts) {
1148
+ const s = optState(o.el);
1149
+ s.selectedness = o.el.hasAttribute('selected');
1150
+ s.dirtiness = false;
1151
+ }
1152
+ this.#adopted = false; this.#touched = false; this.#written = false; this.#attrPending = true;
1153
+ this.#adopt({ reset: true });
1154
+ }
1155
+ formDisabledCallback(d) { this.toggleAttribute('data-form-disabled', d); this.#syncDisabled(); }
1156
+ formStateRestoreCallback(state) { if (typeof state === 'string') this.value = state; }
1157
+
1158
+ #syncDisabled() {
1159
+ this.#btn.disabled = this.hasAttribute('disabled') || this.hasAttribute('data-form-disabled');
1160
+ if (this.#btn.disabled) this.#close();
1161
+ }
1162
+ /* Delegated activation bridge for the wrapped native's OWN labels
1163
+ (native.labels — for/id and wrapping associations both), so author markup
1164
+ that labels the INNER select keeps working after upgrade — including
1165
+ labels added or retargeted AFTER adoption, which per-label binding at
1166
+ adoption time would miss. ONE click listener on the host's root document,
1167
+ bound while a native is adopted; removed on departure AND on host
1168
+ disconnect, re-bound on reconnect while still adopted. Idempotent per
1169
+ document (rebinding never stacks duplicates). No preventDefault — the
1170
+ label's own activation targets the force-disabled native and goes
1171
+ nowhere. */
1172
+ #bindLabelBridge() {
1173
+ // Bind on the ROOT NODE, not ownerDocument: inside an ancestor shadow
1174
+ // root a click is retargeted to the shadow HOST before it reaches the
1175
+ // document, so a document listener can never see the <label> itself.
1176
+ const doc = this.getRootNode();
1177
+ if (this.#labelDoc === doc) return;
1178
+ this.#unbindLabelBridge();
1179
+ doc.addEventListener('click', this.#onLabelClick);
1180
+ this.#labelDoc = doc;
1181
+ }
1182
+ #unbindLabelBridge() {
1183
+ if (!this.#labelDoc) return;
1184
+ this.#labelDoc.removeEventListener('click', this.#onLabelClick);
1185
+ this.#labelDoc = null;
1186
+ }
1187
+ #syncLabel() {
1188
+ // aria-labelledby cannot cross the shadow boundary, so mirror the associated
1189
+ // external labels' text onto the trigger as its accessible NAME. The selected-
1190
+ // option text in #label is the combobox VALUE (and the fallback name when no
1191
+ // label exists — then no aria-label is set). A WRAPPING label contains the
1192
+ // host itself — walk the label recursively, skipping the host node (and with
1193
+ // it our whole subtree, so option text never leaks into the name) but KEEPING
1194
+ // surrounding text at any depth, e.g. <label><span>Mode <host/> required
1195
+ // </span></label> → "Mode required". Whitespace-normalized; recomputed on
1196
+ // focusin (constructor) so it is fresh when announced.
1197
+ // Pragmatic accname SUBSET (a deliberate approximation — no aria-labelledby
1198
+ // or aria-label resolution inside the label, no role-based pruning): skip
1199
+ // aria-hidden="true" and [hidden] subtrees entirely, and take an <img>'s
1200
+ // alt text instead of descending it.
1201
+ const textOf = n => {
1202
+ if (n === this) return '';
1203
+ if (n.nodeType === Node.TEXT_NODE) return n.textContent;
1204
+ if (n.nodeType !== Node.ELEMENT_NODE) return '';
1205
+ if (n.getAttribute('aria-hidden') === 'true' || n.hasAttribute('hidden')) return '';
1206
+ if (n.localName === 'img') return n.getAttribute('alt') ?? '';
1207
+ return [...n.childNodes].map(textOf).join('');
1208
+ };
1209
+ const norm = s => s.replace(/\s+/g, ' ').trim();
1210
+ // EXPLICIT naming attributes outrank the label mirror (accname precedence):
1211
+ // aria-labelledby idrefs resolve in the element's ROOT NODE (getRootNode —
1212
+ // document or surrounding shadow root) and their text goes through the
1213
+ // SAME accname-subset walk; aria-label is taken verbatim. Applied to the
1214
+ // host first, then to the wrapped native (progressive markup commonly
1215
+ // names the inner select). First non-empty tier wins:
1216
+ // (1) host aria-labelledby (2) host aria-label
1217
+ // (3) native aria-labelledby / aria-label (4) associated-<label> text.
1218
+ const explicit = el => {
1219
+ const ids = el.getAttribute('aria-labelledby');
1220
+ if (ids !== null) {
1221
+ const root = el.getRootNode();
1222
+ const byRef = ids.split(/\s+/).filter(Boolean)
1223
+ .map(id => { const n = root.getElementById?.(id); return n ? norm(textOf(n)) : ''; })
1224
+ .filter(Boolean).join(' ');
1225
+ if (byRef) return byRef;
1226
+ }
1227
+ const al = el.getAttribute('aria-label');
1228
+ return al && al.trim() ? al : '';
1229
+ };
1230
+ let t = explicit(this) || (this.#native ? explicit(this.#native) : '');
1231
+ if (!t) {
1232
+ // Progressive markup commonly labels the INNER select (<label for> its id,
1233
+ // or a wrapping label whose first labelable descendant it is) — after
1234
+ // upgrade those labels are orphaned (the native is force-disabled and
1235
+ // hidden), so their text joins the mirrored name: host-targeting labels
1236
+ // first, then native-targeting, deduped (the same label element counted
1237
+ // once), through the SAME accname-subset walk.
1238
+ const hostLabels = [...this.#internals.labels];
1239
+ const nativeLabels = [...this.#native?.labels ?? []].filter(l => !hostLabels.includes(l));
1240
+ t = [...hostLabels, ...nativeLabels]
1241
+ .map(l => norm(textOf(l)))
1242
+ .filter(Boolean).join(' ');
1243
+ }
1244
+ t ? this.#btn.setAttribute('aria-label', t) : this.#btn.removeAttribute('aria-label');
1245
+ }
1246
+ #updateValidity() {
1247
+ // Native valueMissing is PLACEHOLDER-ONLY: a required select is valueMissing
1248
+ // when the SELECTED option is the placeholder label option — the FIRST
1249
+ // option, a DIRECT child (not inside a group), with value "". Selecting any
1250
+ // OTHER empty-valued option is a legitimate choice (native parity); no
1251
+ // selection at all (idx < 0) stays missing.
1252
+ const first = this.#opts[0];
1253
+ const req = this.hasAttribute('required');
1254
+ const missing = req &&
1255
+ (this.#idx < 0 || (this.#idx === 0 && first.value === '' && !first.grpEl));
1256
+ // Flags COMBINE like native (a required placeholder + a custom error →
1257
+ // BOTH customError and valueMissing true); the message prefers the custom
1258
+ // text, else the valueMissing one.
1259
+ const flags = { customError: !!this.#customError, valueMissing: missing };
1260
+ if (flags.customError || flags.valueMissing)
1261
+ this.#internals.setValidity(flags,
1262
+ this.#customError || 'Please select an item in the list.', this.#btn);
1263
+ else this.#internals.setValidity({});
1264
+ // AX states on the shadow trigger (the role=combobox the host attribute
1265
+ // can't reach): aria-required mirrors the required attribute (runs on
1266
+ // attributeChangedCallback AND every adoption — both funnel here);
1267
+ // aria-invalid holds exactly while the [data-user-invalid] condition does.
1268
+ if (req) this.#btn.setAttribute('aria-required', 'true');
1269
+ else this.#btn.removeAttribute('aria-required');
1270
+ // :user-invalid fallback for engines without it on form-associated elements.
1271
+ const bad = this.#touched && !this.#internals.validity.valid;
1272
+ this.toggleAttribute('data-user-invalid', bad);
1273
+ if (bad) this.#btn.setAttribute('aria-invalid', 'true');
1274
+ else this.#btn.removeAttribute('aria-invalid');
1275
+ }
1276
+ }
1277
+
1278
+ if (!customElements.get('mythical-select')) customElements.define('mythical-select', MythicalSelect);
1279
+ })();