@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.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +89 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -0
- package/dist/logic/button.d.ts +10 -0
- package/dist/logic/button.js +12 -0
- package/dist/logic/dialog.d.ts +9 -0
- package/dist/logic/dialog.js +12 -0
- package/dist/logic/gauge.d.ts +13 -0
- package/dist/logic/gauge.js +18 -0
- package/dist/logic/poll.d.ts +72 -0
- package/dist/logic/poll.js +80 -0
- package/dist/logic/toast.d.ts +20 -0
- package/dist/logic/toast.js +14 -0
- package/dist/logic/tone.d.ts +8 -0
- package/dist/logic/tone.js +19 -0
- package/package.json +61 -0
- package/src/select/mythical-select.d.ts +115 -0
- package/src/select/mythical-select.js +1279 -0
- package/src/select/register.js +4 -0
- package/styles.css +302 -0
|
@@ -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 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
|
+
})();
|