oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

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