carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1/* select.js — the searchable-select enhancement. A sibling of
2 rastrillo.js, following exactly the same rules: first-party,
3 dependency-free, and inert by default. Only a <select> that opts in
4 with data-rst-select gets behavior, and what it enhances works with
5 scripts disabled, because the thing it enhances is a real <select>
6 that never leaves the page.
7
8 It is a separate file rather than more of rastrillo.js so that both
9 stay small enough for an app owner to read in one sitting — the shim
10 holds itself to 8KB and a full ARIA combobox would have doubled it;
11 this one holds itself to 12KB.
12 Both are app-owned from the moment they are scaffolded.
13
14 Vocabulary:
15 data-rst-select on a <select>: mirror a filterable
16 ARIA 1.2 combobox onto it
17 data-rst-select="false" never; stay a native select at any
18 size
19 data-rst-select-filter placeholder for the filter input
20 data-rst-select-results live-region text, {n} substituted
21 data-rst-select-result-one live-region text for exactly one
22
23 ui/partials/field-select.html emits all four past ten options. The
24 strings come from the framework's base catalog (rastrillo.ui.select_*)
25 and ride out on attributes, because this markup is built in the
26 browser where the catalog is out of reach. */
27(function () {
28 "use strict";
29
30// A <select> with too many options to scan by eye becomes a filterable
31// combobox — but the <select> itself never leaves the DOM. It is
32// visually hidden and mirrored, so form submission, required
33// validation, reset and browser autofill all keep working on the
34// element they always did. Nothing here invents state: the select is
35// the single source of truth, and if this function never runs the user
36// gets a perfectly ordinary native select.
37//
38// Strings arrive on data attributes because this markup is built in
39// the browser, after the catalog is out of reach. See
40// rastrillo.ui.select_* in the framework's base catalog.
41function combo(native) {
42 if (native.dataset.rstEnhanced) return; // idempotent: safe to re-scan
43 native.dataset.rstEnhanced = "true";
44
45 // The list mirrors the select's own shape rather than flattening it:
46 // an <optgroup> the author wrote to make a long list readable becomes
47 // a group in the popup, and loose <option> children sit at the top
48 // level as a group with no label. native.options would have been
49 // shorter and would have silently thrown the headings away.
50 var groups = [];
51 Array.prototype.forEach.call(native.children, function (el) {
52 if (el.tagName === "OPTGROUP") {
53 groups.push({ label: el.label, options: Array.prototype.slice.call(el.querySelectorAll("option")) });
54 } else if (el.tagName === "OPTION") {
55 groups.push({ label: "", options: [el] });
56 }
57 });
58 var id = native.id;
59 var listId = id + "-listbox";
60 var filterLabel = native.getAttribute("data-rst-select-filter") || "Type to filter";
61 var manyFmt = native.getAttribute("data-rst-select-results") || "{n} results";
62 var oneFmt = native.getAttribute("data-rst-select-result-one") || "1 result";
63
64 var wrap = document.createElement("div");
65 wrap.setAttribute("rst-combo", "");
66
67 var input = document.createElement("input");
68 input.type = "text";
69 // rst-input and nothing else: rst-combo-input never had a rule, so
70 // the flip dropped it rather than renaming a name into the
71 // vocabulary that styles nothing.
72 input.setAttribute("rst-input", "");
73 input.id = id + "-combo";
74 input.autocomplete = "off";
75 input.setAttribute("role", "combobox");
76 input.setAttribute("aria-expanded", "false");
77 input.setAttribute("aria-controls", listId);
78 input.setAttribute("aria-autocomplete", "list");
79 input.setAttribute("placeholder", filterLabel);
80
81 var list = document.createElement("ul");
82 list.setAttribute("rst-combo-list", "");
83 list.id = listId;
84 list.setAttribute("role", "listbox");
85 list.hidden = true;
86
87 var status = document.createElement("p");
88 status.className = "rst-sr-only";
89 status.setAttribute("role", "status");
90 status.setAttribute("aria-live", "polite");
91
92 native.parentNode.insertBefore(wrap, native);
93 wrap.appendChild(input);
94 wrap.appendChild(list);
95 wrap.appendChild(status);
96
97 // The label named the select; it now names the control the user
98 // actually types into. Without this the combobox has no accessible
99 // name at all.
100 var label = native.id && document.querySelector('label[for="' + native.id + '"]');
101 if (label) label.setAttribute("for", input.id);
102 // Drop [rst-input] too: both are single class selectors, so its
103 // width:100% wins on source order and would leave the hidden select a
104 // full-width box held out of sight by clip-path alone.
105 native.removeAttribute("rst-input");
106 native.classList.add("rst-sr-only");
107 native.setAttribute("tabindex", "-1");
108 native.setAttribute("aria-hidden", "true");
109
110 var shown = [];
111 var active = -1;
112
113 function chosenLabel() {
114 var o = native.options[native.selectedIndex];
115 return o && o.value !== "" ? o.text : "";
116 }
117 input.value = chosenLabel();
118
119 function close() {
120 list.hidden = true;
121 input.setAttribute("aria-expanded", "false");
122 input.removeAttribute("aria-activedescendant");
123 active = -1;
124 }
125
126 function commit(option) {
127 native.value = option.value;
128 input.value = option.text;
129 // Mirror onto the real control, so a listener the app attached to
130 // the select still fires.
131 native.dispatchEvent(new Event("change", { bubbles: true }));
132 close();
133 }
134
135 // One pickable row. Its index is its position in `shown`, which is
136 // the keyboard order — headings never enter it, so arrowing down
137 // steps option to option straight across a group boundary.
138 function row(o) {
139 var li = document.createElement("li");
140 li.id = listId + "-" + shown.length;
141 li.setAttribute("rst-combo-option", "");
142 li.setAttribute("role", "option");
143 li.setAttribute("aria-selected", o.value === native.value ? "true" : "false");
144 li.textContent = o.text;
145 li.addEventListener("mousedown", function (e) {
146 e.preventDefault(); // keep focus; blur would undo the pick
147 commit(o);
148 });
149 shown.push(o);
150 return li;
151 }
152
153 function draw(filter) {
154 var needle = filter.trim().toLowerCase();
155 shown = [];
156 list.innerHTML = "";
157 groups.forEach(function (g) {
158 var matched = g.options.filter(function (o) {
159 return o.value !== "" && o.text.toLowerCase().indexOf(needle) !== -1;
160 });
161 // A group filtered down to nothing takes its heading with it: a
162 // heading over no rows is a lie about what is there.
163 if (!matched.length) return;
164 var into = list;
165 if (g.label) {
166 // The group's name lives on aria-label, so the heading itself is
167 // furniture — hidden from the accessibility tree, out of the
168 // keyboard order, announced once as the group it names.
169 var box = document.createElement("li");
170 box.setAttribute("role", "group");
171 box.setAttribute("aria-label", g.label);
172 // <li> cannot hold <li>, so the rows nest in a list of their
173 // own; role=none keeps it out of the accessibility tree, which
174 // leaves the options owned by the group.
175 into = document.createElement("ul");
176 into.setAttribute("rst-combo-rows", "");
177 into.setAttribute("role", "none");
178 var head = document.createElement("li");
179 head.setAttribute("rst-select-group", "");
180 head.setAttribute("aria-hidden", "true");
181 head.textContent = g.label;
182 into.appendChild(head);
183 box.appendChild(into);
184 list.appendChild(box);
185 }
186 matched.forEach(function (o) { into.appendChild(row(o)); });
187 });
188 list.hidden = shown.length === 0;
189 input.setAttribute("aria-expanded", shown.length ? "true" : "false");
190 status.textContent = shown.length === 1
191 ? oneFmt
192 : manyFmt.replace("{n}", String(shown.length));
193 active = -1;
194 input.removeAttribute("aria-activedescendant");
195 }
196
197 function move(delta) {
198 if (list.hidden) draw(input.value);
199 if (!shown.length) return;
200 active = (active + delta + shown.length) % shown.length;
201 // The rows, not list.children: a grouped list's children are the
202 // groups, and the headings inside them are not stops.
203 list.querySelectorAll('[role="option"]').forEach(function (li, i) {
204 li.classList.toggle("is-active", i === active);
205 });
206 input.setAttribute("aria-activedescendant", listId + "-" + active);
207 }
208
209 input.addEventListener("input", function () { draw(input.value); });
210 input.addEventListener("focus", function () {
211 // Select the committed label so the first keystroke replaces it.
212 // Otherwise typing into a box holding "Option 1" filters for
213 // "Option 1Option 12" and silently matches nothing.
214 input.select();
215 draw("");
216 });
217 input.addEventListener("blur", function () {
218 // A half-typed filter is not a value: restore what is committed.
219 window.setTimeout(function () { input.value = chosenLabel(); close(); }, 0);
220 });
221 input.addEventListener("keydown", function (e) {
222 switch (e.key) {
223 case "ArrowDown": e.preventDefault(); move(1); break;
224 case "ArrowUp": e.preventDefault(); move(-1); break;
225 case "Home": if (!list.hidden) { e.preventDefault(); active = -1; move(1); } break;
226 case "End": if (!list.hidden) { e.preventDefault(); active = shown.length - 1; move(0); } break;
227 case "Enter":
228 // Enter acts on the filter, never on the form: submitting
229 // mid-filter discards the edit and silently keeps the previous
230 // selection. Take the highlight, or the only match, or close.
231 e.preventDefault();
232 if (active >= 0) {
233 commit(shown[active]);
234 } else if (shown.length === 1) {
235 commit(shown[0]);
236 } else {
237 close();
238 }
239 break;
240 case "Escape": e.preventDefault(); close(); input.value = chosenLabel(); break;
241 }
242 });
243
244 // The same guard datetime.js carries, where the long version of this
245 // note lives. Enter belongs to the combobox, never to the form: a
246 // commit that also submits posts whatever the select held before the
247 // user chose. On a key a person presses, the keydown handler's
248 // preventDefault settles it — the engine derives the keypress from
249 // the keydown it just cancelled. On a SYNTHESISED key it does not:
250 // CDP, and every browser drive built on it, can deliver the keydown
251 // and the character as two independent events, so the second arrives
252 // with no memory of the first one's refusal, carrying the \r that
253 // implicit form submission listens for. Measured, not guessed (issue
254 // #86): without this line the select drive fired a submit on every
255 // run and passed only by outrunning the navigation.
256 input.addEventListener("keypress", function (e) {
257 if (e.key === "Enter") e.preventDefault();
258 });
259}
260
261// Idempotent, so re-scanning is safe if a future change re-scans after
262// the shim swaps in a polled fragment. Today nothing does: a select
263// arriving inside a replacement stays native — correct, not enhanced.
264function scan() {
265 document.querySelectorAll("select[data-rst-select]").forEach(function (el) {
266 // data-rst-select="false" is the markup-side opt-out: a select that
267 // says no stays native at any size. It has to be checked rather
268 // than selected against, because to CSS the attribute is simply
269 // present. field-select opts out the other way, by never emitting
270 // the attribute at all; this is for markup written by hand.
271 if (el.dataset.rstSelect !== "false") combo(el);
272 });
273}
274
275if (document.readyState === "loading") {
276 document.addEventListener("DOMContentLoaded", scan);
277} else {
278 scan();
279}
280})();
281