rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1/* select.js — the searchable-select enhancement, rastrillo.js's sibling
2 on the same rules: first-party, dependency-free, inert by default. Only
3 a <select> opting in with data-rst-select is enhanced, and it stays a
4 real <select> in the page, so everything works with scripts off.
5
6 The PURE half (ranking a query, what leaving the box commits, which
7 blanks are prompts) has no DOM or state and is exported to Node behind
8 a guard, so ui/select_node.mjs pins it without a browser. The rest sits
9 behind the `document` guard. App-owned once scaffolded. Converged with
10 Tito Go's searchselect, whose country picker taught most of it.
11
12 Vocabulary, on the <select>:
13 data-rst-select mirror a filterable ARIA 1.2 combobox
14 onto it
15 data-rst-select="false" never; stay a native select
16 data-rst-select-filter the search box's placeholder
17 data-rst-select-results live-region text, {n} substituted
18 data-rst-select-result-one live-region text for exactly one
19 data-rst-select-no-matches the row shown when nothing matches
20 On an <option>, all optional:
21 data-rst-name / data-rst-desc a name and a quieter description, when
22 the option's text is "Name (desc)"
23 data-rst-short what the CLOSED box shows once this is
24 picked ("+44"), for a narrow box
25 data-rst-lead a decorative glyph before the row and
26 the box (a flag); never the only thing
27 saying which option this is
28 data-rst-class classes the row wears, and the closed
29 box while it shows this pick (a font
30 named in its own face); the box takes
31 them off once somebody types
32 data-rst-terms more words the search matches (an ISO
33 code, "+971") that the row does not print
34 data-rst-first leads its ties in an exact search
35 data-rst-prompt a blank that asks rather than answers
36 ("Country")
37 An <hr> between two options is drawn as a divider, hidden while
38 searching. An <optgroup> becomes a labelled group; a search ranks
39 across groups and hides their headings, and clearing it puts every
40 row back in its own group.
41
42 Content added after load is enhanced when the page dispatches
43 rst:select-scan on document ({detail: {root}} to look only there).
44
45 Every string is looked up on the attribute first, then in the page's
46 table (self.fiestaStrings[lang], the same keys minus data-rst-,
47 which i18n/<lang>.js fills), then the English fallback written here
48 (fiestaWord, below). */
49(function () {
50 "use strict";
51
52 // fold is what a query and an option are compared as: lower case, with the
53 // accents taken off and a curly apostrophe read as a straight one, so "cote"
54 // and "cote d'ivoire" find "Côte d’Ivoire" and "osterreich" finds
55 // "Österreich" — someone on a keyboard without the letter still gets there.
56 const fold = (s) =>
57 String(s || "").normalize("NFD").replace(/\p{M}/gu, "").replace(/[\u2018\u2019]/g, "'").toLowerCase().trim();
58
59 // rank says how well one option answers a query, lower being better, or -1
60 // for no match at all. An option is {name, text, terms}: name is the words
61 // the row leads with, text the whole string (name and desc), terms the extra
62 // words it answers to. 0 is an exact hit (typing "gb" or "+44" or the whole
63 // name); 1 is the start of the name; 2 the start of one of its later words,
64 // or of a term; 3 is anywhere in the text. The start of the name outranks the
65 // start of a later word, so "sudan" is Sudan before South Sudan and "guinea"
66 // is Guinea before Equatorial Guinea. A term is matched from its start with
67 // or without a leading "+", so "971" finds +971 as surely as "+971" does.
68 // folded is an option's words read once, as rank compares them: an option
69 // may carry them precomputed (select.js does, so a keystroke over 240
70 // countries folds only the query), or they are read here. Everything rank
71 // derives from an option's own words is read here too — the name without
72 // its trailing bracket, the words it splits into, the terms without their
73 // "+" — so a keystroke does no string work on an option at all.
74 const plain = (t) => t.replace(/^\+/, "");
75 const folded = (o) => {
76 if (o.folded) return o.folded;
77 const name = fold(o.name);
78 const terms = (o.terms || []).map(fold).filter(Boolean);
79 return {
80 name,
81 text: fold(o.text),
82 terms,
83 // A trailing bracket is a gloss on the name, not part of it: somebody who
84 // types "india" whole has named "India (+91)" exactly.
85 bareName: name.replace(/\s*\([^()]*\)$/, ""),
86 words: name.split(/[\s\-’'(),.]+/).filter(Boolean),
87 plainTerms: terms.map(plain),
88 };
89 };
90
91 // ranked is rank for a query already folded, which is how order asks: once
92 // per keystroke, not once per option.
93 const ranked = (o, q) => {
94 if (!q) return 0;
95 const bare = q.replace(/^\+/, "");
96 const f = folded(o);
97 const name = f.name;
98 const terms = f.terms;
99 const plains = f.plainTerms;
100 // A term typed whole, with or without its "+": "44" is +44 exactly.
101 if (name === q || f.bareName === q || terms.includes(q) || (bare && plains.includes(bare))) return 0;
102 if (name.startsWith(q)) return 1;
103 if (f.words.some((w) => w.startsWith(q))) return 2;
104 if (bare && plains.some((t) => t.startsWith(bare))) return 2;
105 if (f.text.includes(q)) return 3;
106 return -1;
107 };
108
109 const rank = (o, query) => ranked(o, fold(query));
110
111 // order returns the options a query matches, best first. Among EXACT matches
112 // (rank 0 — a calling code typed whole) an option marked `first` (data-rst-first) leads — the country that owns
113 // a shared calling code, so "+1" opens on the United States rather than on
114 // American Samoa — and after that ties keep the options' own order, which is
115 // the page's own (or the alphabet's).
116 const order = (opts, query) => {
117 const q = fold(query);
118 return opts
119 .map((o, i) => ({ o, i, r: ranked(o, q) }))
120 .filter((x) => x.r >= 0)
121 .sort((a, b) => a.r - b.r || (a.r === 0 ? (b.o.first ? 1 : 0) - (a.o.first ? 1 : 0) : 0) || a.i - b.i)
122 .map((x) => x.o);
123 };
124
125 // settle is the match that LEAVING the box after a search commits, or null:
126 // the one row a search can only mean. hits are order()'s answer.
127 // - a single match settles;
128 // - a calling code typed whole whose best row owns it settles, with or
129 // without its "+" ("+44" and "44" are the United Kingdom, "1" the US);
130 // - on three or more characters, the best row settles when it is the ONLY
131 // row that good: an exact hit ("germany"), or a name that starts with
132 // the query when nothing else does ("ger" is Germany; Algeria and Niger
133 // only contain it). "ind" settles nothing: India and Indonesia tie, and
134 // neither does an exact hit two rows share.
135 // Two letters alone never settle: "ge" is Georgia's code and the start of
136 // Germany, and a wrong calling code under a phone number is worse than an
137 // unpicked box the form then asks about.
138 const settle = (hits, query) => {
139 if (hits.length === 1) return hits[0];
140 if (!hits.length) return null;
141 const q = fold(query);
142 const bare = q.replace(/^\+/, "");
143 const r0 = rank(hits[0], query);
144 if (r0 === 0 && /^\d+$/.test(bare) && hits[0].first) return hits[0];
145 if (bare.length < 3 || /^\d+$/.test(bare)) return null;
146 // An exact hit settles only when it is the single best row: nothing else
147 // is exact, or of the exact ties it alone is marked first. Two places
148 // named alike ("springfield" is exactly both "Springfield (IL)" and
149 // "Springfield (MA)", the bracket being a gloss) are two answers, and
150 // leaving the box commits neither. Kept byte for byte with Tito Go's.
151 if (r0 === 0) {
152 const ties = hits.filter((o) => ranked(o, q) === 0);
153 if (ties.length === 1) return hits[0];
154 return hits[0].first && ties.filter((o) => o.first).length === 1 ? hits[0] : null;
155 }
156 return r0 < rank(hits[1], query) ? hits[0] : null;
157 };
158
159 // isPrompt says whether an option is a PROMPT rather than an answer: one the
160 // page marked data-rst-prompt (a phone number's "Country"), or the blank of a
161 // REQUIRED select ("Choose one" over the country question). A prompt is never
162 // shown as the pick — the box stays empty so its placeholder ("Search
163 // countries") says what is wanted — and a required select never offers its
164 // blank as a row: choosing it would only leave the question unanswered. An optional select's blank
165 // ("No answer") is a real answer and is neither.
166 // A DISABLED blank is never an answer either, required or not: a page that
167 // renders "Choose one" disabled means it, and a select that stops being
168 // required (a sibling script unticking a box) must not bring it back as a
169 // pick.
170 const isPrompt = (value, marked, required, disabled) =>
171 marked || (value === "" && (required || !!disabled));
172
173 // offered says whether a blank option is listed. A MARKED prompt (a phone
174 // number's "Country") is listed while nothing is typed and drops out once
175 // something is — required or not, exactly as before. An unmarked blank on a
176 // required select is never listed. Any other blank ("No answer") always is.
177 const offered = (value, marked, required, searching, disabled) =>
178 value !== "" || (marked ? !searching : !(required || !!disabled));
179
180 // The strings: the attribute, then the page's table, then the
181 // fallback (the header says why). datetime.js carries the same two
182 // functions, so each enhancement stays one script tag;
183 // check/strings_test.go holds the two copies identical.
184
185 // The table for a tag: an exact match whatever its case, else the
186 // first in its primary language (pt-BR reads pt-PT), else null.
187 function fiestaPickLang(tag, registry) {
188 var want = String(tag || "").toLowerCase();
189 if (!want) return null;
190 var keys = Object.keys(registry || {}), i;
191 for (i = 0; i < keys.length; i++) {
192 if (keys[i].toLowerCase() === want) return keys[i];
193 }
194 var primary = want.split("-")[0];
195 for (i = 0; i < keys.length; i++) {
196 if (keys[i].toLowerCase().split("-")[0] === primary) return keys[i];
197 }
198 return null;
199 }
200
201 function fiestaWord(el, key, fallback) {
202 var v = el.getAttribute("data-rst-" + key);
203 if (v !== null && v !== "") return v;
204 var registry = (typeof self !== "undefined" && self.fiestaStrings) || {};
205 var lang = fiestaPickLang(document.documentElement.lang, registry);
206 var table = lang ? registry[lang] : null;
207 return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback;
208 }
209
210 // The pure half, for check/node/select_node.mjs, and the lookup for
211 // check/node/strings_node.mjs. A browser has no `module` and never
212 // takes this branch.
213 if (typeof module !== "undefined" && module && module.exports) {
214 module.exports = { fold: fold, rank: rank, order: order, settle: settle, isPrompt: isPrompt, offered: offered, fiestaPickLang: fiestaPickLang, fiestaWord: fiestaWord };
215 }
216 if (typeof document === "undefined") return;
217
218 // The band of this document the reader can see, when a host says so:
219 // {top, bottom} in viewport coordinates. A frame's visual viewport never
220 // shrinks for the top page's keyboard, so a host that can see it tells
221 // the frame; null forgets it. Registered before any box opens, so a box
222 // re-placing on the same event reads the new band.
223 let told = null;
224 document.addEventListener("rst:select-viewport", (e) => {
225 const d = e.detail;
226 told = d && isFinite(d.top) && isFinite(d.bottom) ? { top: +d.top, bottom: +d.bottom } : null;
227 });
228
229 // What a box is to the boxes in it, by CSS: the containing block of
230 // fixed (f) and absolute (a) ones, a clip with overflow visible (p,
231 // paint containment), whether its overflow clips (o), whether it is a
232 // box (b). Each property counts only where it applies: transforms,
233 // motion paths and overflow skip non-atomic inlines, table columns and
234 // ruby's parts; containment, table rows and groups too (css-contain-2
235 // §3); filters skip none. A will-change name counts by its property's
236 // rule, without case, offset as offset-path's shorthand, a -webkit-
237 // alias only where the engine has it (Chromium has none for
238 // backdrop-filter). Chromium takes a set offset-position (a digit in
239 // it) as a motion path; WebKit does not, and gets a shorter list.
240 // Size queries no longer contain layout (css-conditional-5).
241 const holds = (cs) => {
242 const d = cs.display, b = d !== "contents", cv = cs.contentVisibility;
243 const o = b && !/^(inline|run-in)( list-item)?$|^(ruby$|table-col|ruby-)/.test(d), c = o && !/^table-(row|head|foot)/.test(d);
244 const w = (cs.willChange || "").toLowerCase().split(/,\s*/).filter((v) => CSS.supports(v, "initial")).map((v) => v.replace(/^-webkit-/, ""));
245 // Named in will-change, or (v) set to other than none.
246 const X = (s, v) => s.split(" ").some((k) => w.includes(k) || (v && !/^(none|)$/.test(cs.getPropertyValue(k))));
247 const p = c && (/paint|strict|content/.test(cs.contain) || cv === "auto" || cv === "hidden");
248 const f = p || (c && (/layout/.test(cs.contain) || X("contain content-visibility"))) ||
249 (o && (cs.transformStyle === "preserve-3d" || /\d/.test(cs.offsetPosition) || X("transform translate rotate scale perspective offset-path", 1) || X("transform-style offset offset-position"))) ||
250 (b && X("filter backdrop-filter -webkit-backdrop-filter", 1));
251 return { f, a: f || (b && (cs.position !== "static" || X("position"))), p, o, b };
252 };
253 // A box in the top layer (a modal dialog, an open popover) is out of
254 // every ancestor's reach.
255 const onTop = (a) => [":modal", ":popover-open"].some((q) => { try { return a.matches(q); } catch (e) { return false; } });
256
257 // The ancestors that clip a list: only its chain of containing blocks
258 // can, walked through slots as it is drawn. Taking every ancestor that
259 // scrolls held a list fixed or anchored out of a sideways-scrolling bar
260 // (its overflow-y computes to auto) to the 4px of bar under its box:
261 // 0px tall, in Docs' phone toolbar.
262 const clippers = (list, pos) => {
263 const out = [];
264 for (let a = list; (a = a.assignedSlot || a.parentElement || (a.parentNode && a.parentNode.host)) && a !== document.body; ) {
265 const cs = getComputedStyle(a), h = holds(cs);
266 // Fixed skips to a holder; absolute, to a holder or a positioned box.
267 // A boxless ancestor is passed over: its position is no one's.
268 if (h.b && (pos === "fixed" ? h.f : pos !== "absolute" || h.a)) {
269 if (h.p || (h.o && /auto|scroll|hidden|clip/.test(cs.overflowY))) out.push(a);
270 pos = cs.position;
271 }
272 if (onTop(a)) break;
273 }
274 return out;
275 };
276
277 const ACTS = new Set(["ArrowDown", "ArrowUp", "Home", "End", "Enter", "Tab"]);
278
279 const el = (tag, attr) => {
280 const e = document.createElement(tag);
281 if (attr) e.setAttribute(attr, "");
282 return e;
283 };
284
285 // A <select> with too many options to scan by eye becomes a filterable
286 // combobox — but the <select> itself never leaves the DOM. It is
287 // visually hidden and mirrored, so form submission, required
288 // validation, reset and autofill all keep working on the element they
289 // always did. The select is the single source of truth; if this never
290 // runs, the user gets an ordinary native select.
291 // resume, when a box is rebuilt in place, is where the old one was:
292 // {focused, open, typed}. It is restored quietly — no input event for a
293 // page to answer by changing the options again, and no reopening a list
294 // somebody had closed.
295 const enhanced = new WeakSet();
296 function combo(native, resume) {
297 // Idempotent, by a live record rather than the attribute: a page that
298 // replaces a select with a clone of it copies data-rst-enhanced too,
299 // and a clone trusted on that would never be enhanced at all.
300 if (enhanced.has(native)) return;
301 enhanced.add(native);
302 native.dataset.rstEnhanced = "true";
303
304 const id = native.id || "rst-select-" + Math.random().toString(36).slice(2);
305 const listId = id + "-listbox";
306 const say = (key, fallback) => fiestaWord(native, "select-" + key, fallback);
307 const manyFmt = say("results", "{n} results");
308 const oneFmt = say("result-one", "1 result");
309
310 // The option model mirrors the live select, index for index, so a row
311 // and select.selectedIndex always agree. Groups are kept: each option
312 // knows the list it belongs in, and a search that lifts it out puts
313 // it back there.
314 const groups = [];
315 // An option under a disabled <optgroup> is disabled with it: the
316 // native select will not pick it, so no row may either.
317 const offLimits = (o) => o.disabled || !!(o.parentElement && o.parentElement.tagName === "OPTGROUP" && o.parentElement.disabled);
318 // The select's own state, read when it matters: a disabled
319 // <fieldset> disables the select without setting its attribute.
320 const locked = () => native.matches(":disabled");
321 const opts = Array.from(native.options).map((o, i) => {
322 const d = o.dataset;
323 const g = o.parentElement.tagName === "OPTGROUP" ? o.parentElement : null;
324 if (g && groups[groups.length - 1] !== g) groups.push(g);
325 return {
326 text: o.text,
327 name: d.rstName || o.text,
328 desc: d.rstDesc || "",
329 short: d.rstShort || "",
330 lead: d.rstLead || "",
331 // is-active is the highlight's: a row wearing it would look arrowed to.
332 classes: (d.rstClass || "").split(/\s+/).filter((c) => c && c !== "is-active"),
333 terms: (d.rstTerms || "").split(/\s+/).filter(Boolean),
334 marked: d.rstPrompt !== undefined,
335 // Whether it is a prompt can change: a sibling script may stop the
336 // select being required, so the observer below re-derives it.
337 prompt: isPrompt(o.value, d.rstPrompt !== undefined, native.required, offLimits(o)),
338 first: d.rstFirst !== undefined,
339 sepBefore: !!(o.previousElementSibling && o.previousElementSibling.tagName === "HR"),
340 disabled: offLimits(o),
341 blank: o.value === "",
342 index: i,
343 group: g,
344 // The native option itself, read live: an app that removes or
345 // reorders options after enhancement moves every index, and a
346 // cached one would submit a different option from the row chosen.
347 el: o,
348 li: null,
349 };
350 });
351 for (const o of opts) o.folded = folded(o);
352 const compact = opts.some((o) => o.short);
353
354 const wrap = el("div", "rst-combo");
355 if (compact) wrap.setAttribute("rst-combo-compact", "");
356 // The pick's glyph, drawn before the box. Decorative: the description
357 // below is what says which option it is.
358 const lead = el("span", "rst-combo-lead");
359 lead.setAttribute("aria-hidden", "true");
360 // The whole name of a compact pick, for a screen reader only: a box
361 // showing "+44" is described as "United Kingdom +44".
362 const said = el("span");
363 said.id = id + "-said";
364 said.hidden = true;
365
366 const input = el("input", "rst-input");
367 input.type = "text";
368 input.id = id + "-combo";
369 input.autocomplete = "off";
370 input.spellcheck = false;
371 // A disabled select's box is disabled with it (the flips observer
372 // below keeps the two together). A disabled <fieldset> needs no
373 // mirror: the box sits beside the select, inside the same fieldset.
374 input.disabled = native.disabled;
375 // A compact box is too narrow for a search sentence, so it says what
376 // it is for in the one word it is labelled with.
377 input.placeholder = compact && native.getAttribute("aria-label")
378 ? native.getAttribute("aria-label")
379 : say("filter", "Type to filter");
380 input.setAttribute("role", "combobox");
381 input.setAttribute("aria-expanded", "false");
382 input.setAttribute("aria-controls", listId);
383 input.setAttribute("aria-autocomplete", "list");
384 const described = native.getAttribute("aria-describedby");
385 if (described) input.setAttribute("aria-describedby", described);
386 if (compact) input.setAttribute("aria-describedby", described ? described + " " + said.id : said.id);
387
388 const list = el("ul", "rst-combo-list");
389 list.id = listId;
390 list.hidden = true;
391 list.setAttribute("role", "listbox");
392
393 const status = el("p");
394 status.className = "rst-sr-only";
395 status.setAttribute("role", "status");
396 status.setAttribute("aria-live", "polite");
397
398 // The label named the select; it now names the control the user
399 // actually types into, and the list too.
400 const label = native.id && document.querySelector('label[for="' + CSS.escape(native.id) + '"]');
401 if (label) {
402 if (!label.id) label.id = id + "-label";
403 label.htmlFor = input.id;
404 list.setAttribute("aria-labelledby", label.id);
405 } else if (native.getAttribute("aria-label")) {
406 input.setAttribute("aria-label", native.getAttribute("aria-label"));
407 list.setAttribute("aria-label", native.getAttribute("aria-label"));
408 }
409
410 // Each group is a labelled box in the list. The group's name lives on
411 // aria-label, so the heading inside is furniture — hidden from the
412 // accessibility tree and out of the keyboard order. <li> cannot hold
413 // <li>, so its rows nest in a list of their own with role=none.
414 const boxes = new Map();
415 for (const g of groups) {
416 const box = el("li");
417 box.setAttribute("role", "group");
418 box.setAttribute("aria-label", g.label);
419 const rows = el("ul", "rst-combo-rows");
420 rows.setAttribute("role", "none");
421 const head = el("li", "rst-select-group");
422 head.setAttribute("aria-hidden", "true");
423 head.textContent = g.label;
424 rows.appendChild(head);
425 box.appendChild(rows);
426 boxes.set(g, { box, rows });
427 }
428 const home = (o) => (o.group ? boxes.get(o.group).rows : list);
429
430 for (const o of opts) {
431 const li = el("li", "rst-combo-option");
432 li.id = listId + "-" + o.index;
433 li.setAttribute("role", "option");
434 li.setAttribute("aria-selected", "false");
435 if (o.disabled) li.setAttribute("aria-disabled", "true");
436 if (o.classes.length) li.classList.add(...o.classes);
437 if (o.lead) {
438 const glyph = el("span", "rst-combo-lead");
439 glyph.setAttribute("aria-hidden", "true");
440 glyph.textContent = o.lead;
441 li.append(glyph);
442 }
443 if (o.desc) {
444 // Two spans rather than the option's one string: the brackets are
445 // for a reader without JavaScript, and here a quieter colour does
446 // that job with no punctuation for a locale to argue with.
447 const name = el("span", "rst-combo-name");
448 name.textContent = o.name;
449 const desc = el("span", "rst-combo-desc");
450 desc.textContent = o.desc;
451 li.append(name, desc);
452 } else {
453 li.append(o.text);
454 }
455 o.li = li;
456 if (o.group && !list.contains(boxes.get(o.group).box)) list.appendChild(boxes.get(o.group).box);
457 if (o.sepBefore) {
458 o.sep = el("li", "rst-combo-sep");
459 o.sep.setAttribute("aria-hidden", "true");
460 home(o).appendChild(o.sep);
461 }
462 home(o).appendChild(li);
463 }
464 const empty = el("li", "rst-combo-empty");
465 empty.style.display = "none";
466 empty.textContent = say("no-matches", "No matches");
467 list.appendChild(empty);
468
469 native.parentNode.insertBefore(wrap, native);
470 wrap.append(lead, input, said, list, status);
471 // Drop [rst-input] too: its width:100% would otherwise leave the hidden
472 // select a full-width box held out of sight by clip-path alone.
473 const inputAttr = native.getAttribute("rst-input");
474 native.removeAttribute("rst-input");
475 native.classList.add("rst-sr-only");
476 native.setAttribute("tabindex", "-1");
477 native.setAttribute("aria-hidden", "true");
478
479 let open = false;
480 // The highlighted row itself, not an index: an extra row is in `opts`
481 // but has no index in the select.
482 let active = null;
483 let extras = 0;
484
485 // The selection as the select itself reports it: an option removed after
486 // enhancement keeps `selected` set, so it is never asked directly.
487 const selectedEl = () => native.options[native.selectedIndex] || null;
488 const picked = () => opts.find((o) => o.el && o.el === selectedEl()) || null;
489 // A prompt is not an answer, so the closed box shows nothing for it and
490 // its placeholder says what is wanted.
491 const currentText = () => {
492 const o = picked();
493 return o && !o.prompt ? o.short || o.name : "";
494 };
495 // Whether the box shows the pick or words somebody typed: an open
496 // list refreshed from outside (a required flip, new extras) searches
497 // for the typed words, never for the pick's own text.
498 let showingPick = true;
499 const showText = () => {
500 input.value = currentText();
501 showingPick = true;
502 };
503 const query = () => (showingPick ? "" : input.value);
504 // The pick's data-rst-class, off while typing: a query in the picked
505 // font would read as already chosen.
506 let worn = [];
507 const wear = (classes) => {
508 input.classList.remove(...worn);
509 worn = classes;
510 input.classList.add(...worn);
511 };
512 const showPick = () => {
513 const o = picked();
514 wear(o && !o.prompt ? o.classes : []);
515 lead.textContent = (o && o.lead) || "";
516 lead.hidden = !lead.textContent;
517 if (wrap.hasAttribute("rst-combo-has-lead") === lead.hidden) wrap.toggleAttribute("rst-combo-has-lead", !lead.hidden);
518 said.textContent = o && o.short ? [o.name, o.desc].filter(Boolean).join(" ") : "";
519 };
520 showText();
521 showPick();
522
523 // The row that LEAVING the box after typing takes (Tab, or a tap
524 // elsewhere), or null: settle() says which searches can only mean one
525 // row. A row somebody ARROWED to is their choice, as on a native select.
526 let steered = false;
527 const settledPick = () => {
528 if (!open || !active || active.run) return null;
529 if (steered) return active.el === selectedEl() ? null : active;
530 if (input.value === currentText()) return null;
531 const pick = settle(lastHits, input.value);
532 return pick && pick === (lastHits.length === 1 ? pick : active) ? pick : null;
533 };
534
535 // The rows in the order they are on screen: best match first while
536 // searching, the select's own order otherwise; the page's extras last.
537 let shown = opts;
538 const isShown = (e) => e.style.display !== "none";
539 const visible = () => shown.filter((o) => isShown(o.li) && !o.disabled);
540 // What a list falls back to highlighting: one of the select's own
541 // answers — never a row the page added (it owns Enter), and never a
542 // prompt (a bare Enter would "answer" with the question).
543 const firstReal = () => visible().find((o) => !o.run && !o.prompt) || null;
544 const unanswered = () => {
545 const sel = picked();
546 // Nothing selected at all (selectedIndex -1) is unanswered too.
547 return !sel || sel.prompt;
548 };
549 // The row an idle list highlights: the pick on an answered box, nothing
550 // on an unanswered one — highlighting the first row would let a bare
551 // Enter answer the question with whatever happens to sort first. A
552 // cleared search lands where opening does, so an idle list reads one
553 // way however it got there.
554 const opening = () => {
555 if (unanswered()) return null;
556 const sel = picked();
557 return sel && isShown(sel.li) && !sel.disabled ? sel : firstReal();
558 };
559 // The rows the arrows, Home and End step through: what is on screen,
560 // less a prompt, which is still there to tap.
561 const steps = () => visible().filter((o) => !o.prompt);
562
563 // Everything that reads layout — where the box sits, how much room is
564 // above and below it, scrolling the highlight into view — happens at
565 // most once a frame, in one callback. A keystroke that searched twice
566 // in a frame used to force layout twice, on a long form with several
567 // boxes, on a phone: the "slow typing" was reflow.
568 let layout = 0;
569 let placeDue = false;
570 let scrollTo = null;
571 let scrollTop = false;
572 const onLayout = () => {
573 layout = 0;
574 if (!open) return;
575 if (placeDue) place();
576 placeDue = false;
577 if (scrollTop) list.scrollTop = 0;
578 else if (scrollTo && isShown(scrollTo.li)) scrollTo.li.scrollIntoView({ block: "nearest" });
579 scrollTo = null;
580 scrollTop = false;
581 };
582 const soon = () => {
583 if (!layout) layout = requestAnimationFrame(onLayout);
584 };
585 // Down, unless the room below cannot hold the list's own height (its
586 // stylesheet max-height) and there is more room above; then up, so a box
587 // at the foot of a sheet does not open under a phone's keyboard. Held to
588 // the room on its side. Room is the visual viewport, cut to every
589 // ancestor that clips the list. Only placement flips, never keyboard order or
590 // ARIA. The same rule as Tito Go's searchselect. Worked out when the list opens and when the room
591 // changes — never on a keystroke: the room is the same whatever is
592 // typed, and a keystroke's one layout read is the highlight's.
593 // Measured with nothing of ours on the list — once per open, and again
594 // after a window resize, which may be text made bigger: its stylesheet
595 // height and its gap off the box.
596 let preferred = 0;
597 let gap = 0;
598 let chrome = 0; // padding and borders outside a content-box max-height
599 let scrollers = [];
600 let floors = [];
601 const place = () => {
602 if (!preferred) {
603 // Read with nothing of ours in the way: an upward list has no top
604 // margin, and the gap would read as 0.
605 if (wrap.hasAttribute("rst-combo-up")) wrap.removeAttribute("rst-combo-up");
606 list.style.maxBlockSize = "";
607 const cs = getComputedStyle(list);
608 // Again after a resize: a media query can make the list fixed.
609 scrollers = clippers(list, cs.position);
610 preferred = parseFloat(cs.maxBlockSize) || 240;
611 gap = parseFloat(cs.marginTop) || 0;
612 chrome = cs.boxSizing === "border-box" ? 0 : ["paddingTop", "paddingBottom", "borderTopWidth", "borderBottomWidth"].reduce((n, k) => n + (parseFloat(cs[k]) || 0), 0);
613 }
614 const vv = window.visualViewport;
615 let top = vv ? vv.offsetTop : 0;
616 let bottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
617 if (told) {
618 top = Math.max(top, told.top);
619 bottom = Math.min(bottom, told.bottom);
620 }
621 for (const a of scrollers) {
622 const r = a.getBoundingClientRect();
623 top = Math.max(top, r.top);
624 bottom = Math.min(bottom, r.bottom);
625 }
626 const box = wrap.getBoundingClientRect();
627 // A bar pinned below the box (data-rst-select-floor) or above it
628 // (data-rst-select-ceiling, a sticky header) is not room; the nearest
629 // wins, one on the wrong side is ignored.
630 for (const f of floors) {
631 const r = f.getBoundingClientRect();
632 if (!r.height) continue;
633 if (!f.hasAttribute("data-rst-select-ceiling")) { if (r.top >= box.bottom - 1) bottom = Math.min(bottom, r.top); }
634 else if (r.bottom <= box.top + 1) top = Math.max(top, r.bottom);
635 }
636 const below = bottom - box.bottom - gap;
637 const above = box.top - top - gap;
638 // Up only when the list does not fit below and there is more room
639 // above; a tie goes down. Neither fitting, the bigger side wins and
640 // the list is clamped to it, never past the edge (Tito Go #3146).
641 const up = below < preferred + chrome && above > below;
642 const h = Math.floor(Math.max(0, Math.min(preferred + chrome, up ? above : below) - chrome)) + "px";
643 if (list.style.maxBlockSize !== h) list.style.maxBlockSize = h;
644 if (up !== wrap.hasAttribute("rst-combo-up")) wrap.toggleAttribute("rst-combo-up", up);
645 };
646 const replace = (e) => {
647 // A box taken off the page with its whole fragment hears no mutation
648 // and may get no blur; the first event after drops the page-wide
649 // listeners that would otherwise keep it alive.
650 if (!wrap.isConnected) return closeList(false);
651 if (e && e.type === "resize" && e.currentTarget === window) preferred = 0;
652 placeDue = true;
653 soon();
654 };
655 // A panel or sheet around the box scrolling moves it as surely as the
656 // window does; the list's own scrolling does not.
657 const scrolled = (e) => {
658 if (e.target !== list) replace();
659 };
660 const setActive = (o, atTop) => {
661 // Only the row losing the highlight and the row gaining it are touched.
662 // Every write here is guarded: rewriting an attribute to the value it
663 // has is still a mutation the page's stylesheet is asked about.
664 if (active && active !== o) active.li.classList.remove("is-active");
665 if (o && !o.li.classList.contains("is-active")) o.li.classList.add("is-active");
666 active = o || null;
667 if (o) {
668 if (input.getAttribute("aria-activedescendant") !== o.li.id) input.setAttribute("aria-activedescendant", o.li.id);
669 scrollTop = !!(atTop && visible()[0] === o);
670 scrollTo = o;
671 soon();
672 } else if (input.hasAttribute("aria-activedescendant")) {
673 input.removeAttribute("aria-activedescendant");
674 }
675 };
676
677 // A prompt is never selected: a listbox announcing a selected option
678 // says somebody answered the question.
679 const markSelected = () => {
680 for (const o of opts) {
681 const v = o.el && o.el === selectedEl() && !o.prompt ? "true" : "false";
682 if (o.li.getAttribute("aria-selected") !== v) o.li.setAttribute("aria-selected", v);
683 }
684 };
685
686 // A burst of typing is searched twice a frame at most, however many
687 // input events it fires: a phone keyboard can fire several inside one
688 // frame (predictive text, autocorrect) and only the last is ever seen.
689 // The FIRST input of a frame is searched at once, so a single keystroke
690 // reads exactly as it always did; any more only mark the search stale,
691 // and it runs once before the frame is drawn. Anything that ACTS on the
692 // list (Enter, the arrows, Tab, leaving) catches it up first (flush),
693 // so typing then pressing Enter takes the row for the words typed.
694 let pending = false;
695 let frame = 0;
696 let lastQuery = null;
697 let lastHits = [];
698 // Rows a search has lifted to the top of the list, out of their own
699 // place. Only these ever go back when the search is cleared.
700 const lifted = new Set();
701 // Where a row stands with no search: before its successor in the same
702 // list, or last in its group, or before the next group or the empty row.
703 const anchorAfter = (o) => {
704 const next = opts[o.index + 1];
705 if (next && !next.run && next.group === o.group) return next.sep || next.li;
706 if (o.group) return null;
707 if (next && !next.run) return next.group ? boxes.get(next.group).box : next.sep || next.li;
708 return empty;
709 };
710 // A row's shown state, written only when it changes, as an inline
711 // `display`, never the `hidden` attribute: a page with [hidden] inside a
712 // :has() makes each such write a style invalidation WebKit works out on
713 // the spot, and one keystroke hides hundreds of rows (3 to 6 seconds on
714 // an iPhone, on Tito's checkout). No page's CSS asks about inline style.
715 const show = (e, on) => {
716 if (isShown(e) !== on) e.style.display = on ? "" : "none";
717 };
718 const filter = (query) => {
719 pending = false;
720 const own = opts.filter((o) => !o.run);
721 const searching = fold(query) !== "";
722 // A blank that is a prompt is never an answer: a required select never
723 // lists its blank, and a marked prompt drops out once something is
724 // typed. An optional select's "No answer" is a real answer.
725 const answerable = (o) =>
726 !o.blank || offered(o.el.value, o.marked, native.required, searching, o.disabled);
727 const hits = order(own.filter(answerable), query);
728 lastHits = hits.filter((o) => !o.disabled);
729 const hit = new Set(hits);
730 const extra = opts.filter((o) => o.run);
731 shown = searching ? [...hits, ...own.filter((o) => !hit.has(o)), ...extra] : opts;
732 const tail = extra.length ? extra[0].li : null;
733 if (empty.nextSibling !== tail || empty.parentNode !== list) list.insertBefore(empty, tail);
734 // What a keystroke costs is what it MOVES. A search lays out only its
735 // hits, best first, at the top of the list; a row it hides stays where
736 // it is, because a hidden row's place is never seen. A hit already
737 // next in line is not moved, so narrowing a search moves almost
738 // nothing. Clearing puts back only the rows a search lifted.
739 if (searching) {
740 const hitRow = new Set(hits.map((o) => o.li));
741 let ref = list.firstChild;
742 for (const o of hits) {
743 // Never past the empty row (before the extras): a grouped hit is
744 // not a list child, and would land after the extras.
745 while (ref && ref !== empty && ref !== o.li && !hitRow.has(ref)) ref = ref.nextSibling;
746 if (ref === o.li) {
747 ref = ref.nextSibling;
748 } else {
749 list.insertBefore(o.li, ref);
750 lifted.add(o);
751 }
752 }
753 } else if (lifted.size) {
754 // Highest index first, so each row's successor is home before it.
755 for (const o of [...lifted].sort((a, b) => b.index - a.index)) home(o).insertBefore(o.li, anchorAfter(o));
756 lifted.clear();
757 }
758 for (const o of own) {
759 show(o.li, hit.has(o));
760 if (o.sep) show(o.sep, !searching);
761 }
762 for (const { box } of boxes.values()) show(box, !searching);
763 for (const o of extra) show(o.li, true);
764 const found = hits.filter((o) => !o.disabled).length;
765 show(empty, !found);
766 const text = found === 1 ? oneFmt : manyFmt.replace("{n}", String(found));
767 if (status.textContent !== text) status.textContent = text;
768 // A NEW query re-ranks the list, so its best match is the one Enter
769 // takes. The same query run again (the page handing over fresh
770 // extras) keeps a row somebody arrowed to, if it is still there.
771 const requeried = query !== lastQuery;
772 if (requeried) steered = false;
773 lastQuery = query;
774 if (!searching && !steered && (requeried || unanswered())) setActive(opening());
775 else if ((searching && requeried) || !active || !shown.includes(active) || !isShown(active.li) || active.disabled) {
776 setActive(firstReal(), true);
777 }
778 };
779 const flush = () => {
780 if (pending && open) filter(input.value);
781 };
782 const onFrame = () => {
783 frame = 0;
784 flush();
785 };
786 const searchTyping = () => {
787 if (frame) {
788 pending = true;
789 return;
790 }
791 frame = requestAnimationFrame(onFrame);
792 filter(input.value);
793 };
794
795 const openList = (showAll) => {
796 if (open || locked()) return;
797 open = true;
798 list.hidden = false;
799 input.setAttribute("aria-expanded", "true");
800 floors = [...document.querySelectorAll("[data-rst-select-floor],[data-rst-select-ceiling]")];
801 preferred = 0;
802 replace();
803 window.addEventListener("resize", replace);
804 document.addEventListener("rst:select-viewport", replace);
805 document.addEventListener("scroll", scrolled, true);
806 if (window.visualViewport) {
807 window.visualViewport.addEventListener("resize", replace);
808 window.visualViewport.addEventListener("scroll", replace);
809 }
810 if (showAll) filter("");
811 markSelected();
812 setActive(opening());
813 };
814
815 const closeList = (revert) => {
816 if (!open) return;
817 open = false;
818 list.hidden = true;
819 window.removeEventListener("resize", replace);
820 document.removeEventListener("rst:select-viewport", replace);
821 document.removeEventListener("scroll", scrolled, true);
822 if (window.visualViewport) {
823 window.visualViewport.removeEventListener("resize", replace);
824 window.visualViewport.removeEventListener("scroll", replace);
825 }
826 input.setAttribute("aria-expanded", "false");
827 input.removeAttribute("aria-activedescendant");
828 // Typing not yet searched dies with the list, and so does the frame
829 // that would have searched it — or the next keystroke, reopening the
830 // list, would count as the second of that frame and show the old
831 // search's rows against the new words.
832 pending = false;
833 cancelAnimationFrame(frame);
834 frame = 0;
835 // A search closed is a search over: the next one, even with the same
836 // words, is a new query, so it re-ranks (Enter takes its best match,
837 // not a row the old one left highlighted) and clears steering, or
838 // leaving would commit a row somebody arrowed to in a search they
839 // cancelled. Every way back into the list runs filter, so this one
840 // reset carries both; a separate `steered = false` here could never
841 // be observed, and a line no test can see go wrong is not kept.
842 lastQuery = null;
843 if (active) active.li.classList.remove("is-active");
844 if (revert) {
845 showText();
846 showPick();
847 }
848 };
849
850 const choose = (o) => {
851 // A disabled select, or one in a disabled fieldset, changes for
852 // nobody: the row is refused here, whichever way it was reached.
853 if (!o || o.disabled || locked()) return;
854 // An extra row does something instead of BEING something: there is
855 // nothing to select, and the page decides what happens to the text.
856 if (o.run) {
857 o.run();
858 return;
859 }
860 // A row whose option the page has since removed selects nothing.
861 if (!native.contains(o.el)) {
862 closeList(true);
863 return;
864 }
865 o.el.selected = true;
866 // Mirror onto the real control, so a listener the app attached to the
867 // select still fires.
868 native.dispatchEvent(new Event("change", { bubbles: true }));
869 showText();
870 showPick();
871 markSelected();
872 closeList(false);
873 };
874
875 let resuming = false;
876 // Focusing the box selects the pick, so typing or Delete replaces it
877 // (typing into "Option 1" searched for "Option 1O"). WebKit places a
878 // focusing pointer's caret after focus, undoing a select() there, so
879 // the focusing click selects, and again a frame later once Mobile
880 // Safari has placed a tap's caret, only over a collapsed caret: a
881 // mouse skips the focus select(), so a drag that focuses the box keeps
882 // what it selected. A tap keeps it: Chromium holds a selection made
883 // later in a tap, and the next tap would not place the caret.
884 let pointerFocus = "";
885 const selectPick = () => {
886 if (document.activeElement === input && showingPick && input.selectionStart === input.selectionEnd) {
887 input.setSelectionRange(0, input.value.length);
888 }
889 };
890 input.addEventListener("pointerdown", (e) => {
891 pointerFocus = document.activeElement === input ? "" : e.pointerType || "mouse";
892 });
893 input.addEventListener("focus", () => {
894 if (resuming) return;
895 if (pointerFocus !== "mouse") input.select();
896 openList(true);
897 });
898 input.addEventListener("click", () => {
899 if (pointerFocus) {
900 pointerFocus = "";
901 selectPick();
902 requestAnimationFrame(selectPick);
903 }
904 openList(true);
905 });
906 input.addEventListener("input", () => {
907 showingPick = false;
908 // Once somebody types, the box holds their words, not the pick.
909 // Written once, not per keystroke (see show, below).
910 if (!lead.hidden) {
911 lead.hidden = true;
912 wrap.removeAttribute("rst-combo-has-lead");
913 }
914 if (worn.length) wear([]);
915 openList(false);
916 searchTyping();
917 });
918
919 // Whether this keystroke's keydown took Enter for the list: see the
920 // keypress guard below.
921 let tookEnter = false;
922 input.addEventListener("keydown", (e) => {
923 tookEnter = false;
924 if (ACTS.has(e.key)) flush();
925 switch (e.key) {
926 case "ArrowDown": {
927 e.preventDefault();
928 if (!open) return openList(true);
929 const v = steps();
930 steered = true;
931 setActive(v[Math.min(v.indexOf(active) + 1, v.length - 1)] || v[0] || null);
932 break;
933 }
934 case "ArrowUp": {
935 e.preventDefault();
936 if (!open) return openList(true);
937 const v = steps();
938 steered = true;
939 // From nothing highlighted, Up goes to the LAST row, as a native
940 // select and the APG combobox pattern do.
941 setActive((active ? v[Math.max(v.indexOf(active) - 1, 0)] : v[v.length - 1]) || null);
942 break;
943 }
944 case "Home":
945 case "End":
946 if (open) {
947 e.preventDefault();
948 steered = true;
949 const v = steps();
950 setActive((e.key === "Home" ? v[0] : v[v.length - 1]) || null);
951 }
952 break;
953 case "Enter":
954 // With the list open, Enter belongs to the list: it picks the
955 // highlighted row, and with none it does nothing. It never submits
956 // the form from under an open list, which would post whatever the
957 // select held before the person chose.
958 if (open) {
959 e.preventDefault();
960 tookEnter = true;
961 if (active) choose(active);
962 }
963 break;
964 case "Escape":
965 if (open) {
966 // This Escape was for the list; something further up (a dialog)
967 // must not take it as its own and close with the choice half made.
968 e.preventDefault();
969 e.stopPropagation();
970 closeList(true);
971 }
972 break;
973 case "Tab":
974 // Somebody who typed "irel", sees Ireland highlighted and tabs on
975 // means Ireland, as a native select commits its type-ahead on Tab.
976 // With nothing typed, Tab only leaves. Escape reverts.
977 if (settledPick()) choose(settledPick());
978 else closeList(true);
979 break;
980 }
981 });
982
983 // A synthesised key can arrive as a keydown and a separate character
984 // event, and the second carries the \r implicit form submission listens
985 // for with no memory of the first one's preventDefault. Measured, not
986 // guessed (issue #86): without this a browser drive submitted the form
987 // on every Enter and passed only by outrunning the navigation. Keyed on
988 // what the keydown did rather than on `open`, because taking a row has
989 // closed the list by the time the character arrives.
990 input.addEventListener("keypress", (e) => {
991 if (e.key === "Enter" && tookEnter) e.preventDefault();
992 tookEnter = false;
993 });
994
995 // mousedown, not click, so focus never leaves the input mid-pick.
996 list.addEventListener("mousedown", (e) => {
997 const li = e.target.closest("[rst-combo-option]");
998 if (!li) return;
999 e.preventDefault();
1000 choose(opts.find((o) => o.li === li));
1001 });
1002
1003 // Leaving the box after typing takes the settled row, as Tab does: on a
1004 // phone there is no Tab. With nothing typed, leaving only leaves.
1005 wrap.addEventListener("focusout", (e) => {
1006 if (gone) return;
1007 if (wrap.contains(e.relatedTarget)) return;
1008 flush();
1009 if (settledPick()) choose(settledPick());
1010 else closeList(true);
1011 });
1012
1013 // Rows the page adds after the select's own, replacing whatever it added
1014 // last: suggestions for what to do with the typed text. Each is
1015 // {label, meta, run}; an empty list takes them all away.
1016 // The rows last handed over, so a rebuild can hand them to the new box.
1017 let given = [];
1018 wrap.rstExtras = (rows) => {
1019 given = rows || [];
1020 for (let i = opts.length - 1; i >= 0; i--) {
1021 if (!opts[i].run) break;
1022 opts[i].li.remove();
1023 opts.splice(i, 1);
1024 }
1025 for (const row of rows || []) {
1026 const li = el("li", "rst-combo-option");
1027 li.id = listId + "-extra-" + ++extras;
1028 li.setAttribute("role", "option");
1029 li.setAttribute("aria-selected", "false");
1030 const name = el("span", "rst-combo-name");
1031 name.textContent = row.label;
1032 li.append(name);
1033 if (row.meta) {
1034 const meta = el("span", "rst-combo-desc");
1035 meta.textContent = row.meta;
1036 li.append(meta);
1037 }
1038 list.appendChild(li);
1039 // index -1, so markSelected can never call one selected.
1040 opts.push({ text: row.label, name: row.label, desc: row.meta || "", short: "", lead: "",
1041 terms: [], classes: [], first: false, disabled: false, index: -1, group: null, el: null, li, run: row.run });
1042 }
1043 shown = opts;
1044 if (open) filter(query());
1045 };
1046
1047 // A write from outside (autofill, a sibling script) dispatches
1048 // "change", and the display follows. choose()'s own lands as a no-op.
1049 // Listeners this box puts on the select, which outlives it: removed in
1050 // one go when the box steps aside, or every rebuild would leave one
1051 // more set answering the select's events for a box no longer there.
1052 const mine = new AbortController();
1053 const follow = () => {
1054 showText();
1055 showPick();
1056 markSelected();
1057 input.setCustomValidity("");
1058 settleValidity();
1059 };
1060 native.addEventListener("change", follow, { signal: mine.signal });
1061 // A form reset dispatches no change: the select goes back to its
1062 // default, and the box, whose own default is empty, goes blank, so
1063 // the page would show nothing while the form posted the default.
1064 // The controls take their reset values only after the event has
1065 // been dispatched, so the repaint waits a task, and a reset that a
1066 // listener cancelled leaves everything as it was. On the document,
1067 // since the select's form is read when the reset happens, not when
1068 // the box was built.
1069 document.addEventListener("reset", (e) => {
1070 if (e.target !== native.form) return;
1071 setTimeout(() => {
1072 if (e.defaultPrevented || gone) return;
1073 closeList(false);
1074 follow();
1075 }, 0);
1076 }, { signal: mine.signal });
1077
1078 // A REQUIRED select still validates — it is still what the form posts —
1079 // but it is out of sight and aria-hidden, so the browser's "please pick
1080 // one" would focus a control nobody can see. The focus it gives the
1081 // select is handed to the box, which BORROWS the select's message for
1082 // the moment it is shown and gives it straight back: left on the box, it
1083 // would keep the form refusing after the select stopped being required.
1084 const settleValidity = () => {
1085 if (!native.validity.valid && native.willValidate) return;
1086 // Valid now, or no longer validated at all (a sibling script
1087 // dropped required, or disabled it): the borrowed message goes too,
1088 // or it would keep the form refusing a select that no longer objects.
1089 input.removeAttribute("aria-invalid");
1090 input.setCustomValidity("");
1091 };
1092 native.addEventListener("invalid", () => input.setAttribute("aria-invalid", "true"), { signal: mine.signal });
1093 // "true", not an empty value: an ARIA boolean is a string.
1094 const settleRequired = () => {
1095 if (native.required) input.setAttribute("aria-required", "true");
1096 else input.removeAttribute("aria-required");
1097 };
1098 settleRequired();
1099 // A sibling script may stop the select being required (or start it)
1100 // without touching it otherwise. That changes which blanks are prompts,
1101 // and so what the box shows and which row is selected.
1102 const flips = new MutationObserver(() => {
1103 // The box is disabled with the select, and a list open at that
1104 // moment closes: nothing in it can be chosen now.
1105 input.disabled = native.disabled;
1106 if (native.disabled) closeList(false);
1107 settleRequired();
1108 settleValidity();
1109 for (const o of opts) {
1110 if (!o.run) o.prompt = isPrompt(o.el.value, o.marked, native.required, offLimits(o.el));
1111 }
1112 markSelected();
1113 // An open list re-reads its rows: a blank that just became a prompt
1114 // must leave it, and must not stay the highlight.
1115 if (open) {
1116 if (showingPick) {
1117 showText();
1118 const o = picked();
1119 wear(o && !o.prompt ? o.classes : []);
1120 }
1121 filter(query());
1122 } else {
1123 showText();
1124 showPick();
1125 }
1126 });
1127 flips.observe(native, { attributes: true, attributeFilter: ["required", "disabled"] });
1128
1129 // Stepping aside: the box gives the page its select back as it found
1130 // it. Used when the page changes the options (the box is rebuilt from
1131 // what is there now) and when it replaces the select outright (the box
1132 // goes, and whatever took the select's place is enhanced afresh). Every
1133 // observer is disconnected first, so nothing of the old box reacts to
1134 // the new one's work; and `gone` quiets its focus handlers, which a
1135 // browser may fire as the box leaves the page.
1136 let gone = false;
1137 const stepAside = () => {
1138 gone = true;
1139 mine.abort();
1140 flips.disconnect();
1141 options.disconnect();
1142 swapped.disconnect();
1143 closeList(false);
1144 cancelAnimationFrame(frame);
1145 cancelAnimationFrame(layout);
1146 native.classList.remove("rst-sr-only");
1147 native.removeAttribute("tabindex");
1148 native.removeAttribute("aria-hidden");
1149 if (inputAttr !== null) native.setAttribute("rst-input", inputAttr);
1150 delete native.dataset.rstEnhanced;
1151 enhanced.delete(native);
1152 if (label) label.htmlFor = native.id;
1153 wrap.remove();
1154 };
1155 // The page changed the options themselves (rebuilt a list, retitled a
1156 // row): every row must be an option the select really has, so the box
1157 // is built again from what is there now. Somebody mid-search keeps
1158 // their focus and their words.
1159 const options = new MutationObserver((records) => {
1160 if (!records.some((r) => r.target !== native || r.type !== "attributes")) return;
1161 // Changed and then moved or replaced in the same task: that is a
1162 // handover, not a rebuild (this observer runs first).
1163 if (!native.isConnected || native.parentNode !== wrap.parentNode) {
1164 stepAside();
1165 queueMicrotask(() => scan());
1166 return;
1167 }
1168 // Typing not yet searched is searched first, or the words and the
1169 // highlight below would describe two different searches.
1170 flush();
1171 const resume = {
1172 focused: document.activeElement === input, open, typed: showingPick ? null : input.value,
1173 // The row somebody is on — by its option, or by its place among the
1174 // page's extras — and whether they arrowed there: Enter and Tab must
1175 // still mean that row after the rebuild.
1176 at: active && active.el, extra: active && active.run ? opts.filter((o) => o.run).indexOf(active) : -1,
1177 steered, given, tookEnter,
1178 caret: [input.selectionStart, input.selectionEnd, input.selectionDirection],
1179 };
1180 stepAside();
1181 combo(native, resume);
1182 });
1183 options.observe(native, { childList: true, subtree: true, characterData: true, attributes: true,
1184 attributeFilter: ["value", "label", "disabled", "data-rst-prompt"] });
1185 // The page replaced the select itself (a question flow swapping its
1186 // answer control): this box would speak for an element no longer in
1187 // the form, showing one pick while the form posts another. It steps
1188 // aside, and the element that took the select's place is enhanced if
1189 // it asks to be.
1190 const swapped = new MutationObserver(() => {
1191 if (native.isConnected && native.parentNode === wrap.parentNode) return;
1192 stepAside();
1193 // After every observer has run: two selects replaced in one task
1194 // each retire their box first, so no new box is built while an old
1195 // one still holds its label.
1196 queueMicrotask(() => scan());
1197 });
1198 if (wrap.parentNode) swapped.observe(wrap.parentNode, { childList: true });
1199 native.addEventListener("focus", () => {
1200 input.focus();
1201 if (!native.validity.valid) {
1202 input.setCustomValidity(native.validationMessage);
1203 input.reportValidity();
1204 }
1205 }, { signal: mine.signal });
1206 input.addEventListener("input", () => input.setCustomValidity(""));
1207 input.addEventListener("blur", () => {
1208 pointerFocus = "";
1209 if (gone) return;
1210 input.setCustomValidity("");
1211 settleValidity();
1212 });
1213 if (resume) {
1214 resuming = true;
1215 if (resume.focused) input.focus();
1216 resuming = false;
1217 tookEnter = resume.tookEnter;
1218 if (resume.given.length) wrap.rstExtras(resume.given);
1219 if (resume.open) {
1220 openList(resume.typed === null);
1221 if (resume.typed !== null) {
1222 input.value = resume.typed;
1223 showingPick = false;
1224 lead.hidden = true;
1225 wrap.removeAttribute("rst-combo-has-lead");
1226 wear([]);
1227 filter(resume.typed);
1228 }
1229 const was = resume.extra >= 0 ? opts.filter((o) => o.run)[resume.extra]
1230 : resume.at && opts.find((o) => o.el === resume.at && isShown(o.li) && !o.disabled);
1231 if (was) {
1232 setActive(was);
1233 steered = resume.steered;
1234 }
1235 }
1236 if (resume.focused) input.setSelectionRange(...resume.caret);
1237 }
1238 return input;
1239 }
1240
1241 // Idempotent, so re-scanning is safe. A select arriving later inside a
1242 // polled fragment stays native — correct, not enhanced.
1243 // Enhances every data-rst-select under root not already enhanced (the
1244 // WeakSet in combo), so calling it again is harmless.
1245 function scan(root) {
1246 const within = root && root.querySelectorAll ? root : document;
1247 const found = [...within.querySelectorAll("select[data-rst-select]")];
1248 if (within.matches && within.matches("select[data-rst-select]")) found.push(within);
1249 found.forEach((s) => {
1250 // data-rst-select="false" is the markup-side opt-out; to CSS the
1251 // attribute is simply present, so it is checked here.
1252 if (s.dataset.rstSelect !== "false" && !s.multiple) combo(s);
1253 });
1254 }
1255
1256 if (document.readyState === "loading") {
1257 document.addEventListener("DOMContentLoaded", () => scan());
1258 } else {
1259 scan();
1260 }
1261 // Added content (a modal fetched in, a polled fragment) stays native
1262 // until the page asks; running this file again instead would
1263 // double-enhance everything already there.
1264 // A microtask later, so a box the page replaced in the same task has
1265 // retired first and handed back its label.
1266 document.addEventListener("rst:select-scan", (e) => queueMicrotask(() => scan(e.detail && e.detail.root)));
1267})();
1268