rastrillo / fiesta Public

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

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package check
4
5// Browser drives for select.js's converged behaviour, ported from Tito
6// Go's searchselect fences (internal/instance/ui_test.go) by way of
7// rastrillo's ui/select_browser_test.go. Each drive runs its steps
8// inside one page script and reads the result back as JSON, because
9// what is being fenced is timing inside a frame and the exact DOM
10// writes a keystroke makes — things a step-per-round-trip drive would
11// blur. driveSelectFilter, the type-to-filter-and-commit journey, is
12// standalone so the release build's tests can run it against the
13// stripped scripts on a rig of their own built from selectFilterPages.
14
15import (
16 "context"
17 "encoding/json"
18 "fmt"
19 "html"
20 "strings"
21 "testing"
22 "time"
23
24 "github.com/chromedp/cdproto/emulation"
25 "github.com/chromedp/chromedp"
26
27 "amadan.net/rastrillo/fiesta/internal/harness"
28)
29
30// afterFrame resolves once the next frame has been drawn and the task
31// after it has run: where a keystroke's deferred search has landed.
32const afterFrame = `new Promise((r) => requestAnimationFrame(() => setTimeout(() => r(0), 0)))`
33
34// zones is a time-zone list long enough (over fifty rows) that a
35// keystroke moving every row would show, with "Europe/Lo…" and "Dubl"
36// among them.
37var zones = []string{
38 "Africa/Abidjan", "Africa/Accra", "Africa/Algiers", "Africa/Cairo", "Africa/Casablanca",
39 "Africa/Johannesburg", "Africa/Lagos", "Africa/Nairobi", "Africa/Tunis", "America/Anchorage",
40 "America/Bogota", "America/Buenos_Aires", "America/Caracas", "America/Chicago", "America/Denver",
41 "America/Halifax", "America/Havana", "America/Lima", "America/Los_Angeles", "America/Mexico_City",
42 "America/New_York", "America/Phoenix", "America/Santiago", "America/Sao_Paulo", "America/Toronto",
43 "America/Vancouver", "Asia/Bangkok", "Asia/Dhaka", "Asia/Dubai", "Asia/Hong_Kong",
44 "Asia/Jakarta", "Asia/Jerusalem", "Asia/Karachi", "Asia/Kathmandu", "Asia/Kolkata",
45 "Asia/Manila", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", "Asia/Tehran",
46 "Asia/Tokyo", "Atlantic/Azores", "Atlantic/Reykjavik", "Australia/Adelaide", "Australia/Brisbane",
47 "Australia/Perth", "Australia/Sydney", "Europe/Amsterdam", "Europe/Athens", "Europe/Berlin",
48 "Europe/Brussels", "Europe/Bucharest", "Europe/Dublin", "Europe/Helsinki", "Europe/Istanbul",
49 "Europe/Lisbon", "Europe/London", "Europe/Madrid", "Europe/Moscow", "Europe/Oslo",
50 "Europe/Paris", "Europe/Prague", "Europe/Rome", "Europe/Stockholm", "Europe/Vienna",
51 "Europe/Warsaw", "Europe/Zurich", "Pacific/Auckland", "Pacific/Fiji", "Pacific/Honolulu",
52}
53
54// flag is a country's regional-indicator pair, the glyph data-rst-lead
55// carries.
56func flag(iso string) string {
57 var b strings.Builder
58 for _, r := range iso {
59 b.WriteRune(0x1F1E6 + (r - 'A'))
60 }
61 return b.String()
62}
63
64// selectFilterPages is the one page the drives below open, at "/":
65// select.js, the stylesheets and one form holding the selects. The
66// fixture server (sizingMux) serves the scripts and stylesheets it
67// links; a release rig serves the same page through sizingMuxWith.
68func selectFilterPages(t *testing.T) map[string]string {
69 t.Helper()
70 var body strings.Builder
71 body.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8">` +
72 `<title>select</title><link rel="stylesheet" href="/tokens.css">` +
73 `<link rel="stylesheet" href="/theme.css">` +
74 `<script defer src="/select.js"></script></head><body>` +
75 `<form method="post" action="/submit">`)
76
77 // A time zone, no blank: the keeps-pace drive.
78 body.WriteString(`<label rst-field-label for="tz">Time zone</label>` +
79 `<select rst-input id="tz" name="tz" data-rst-select>`)
80 for _, z := range zones {
81 fmt.Fprintf(&body, `<option value="%s">%s</option>`, z, z)
82 }
83 body.WriteString(`</select>`)
84
85 // A REQUIRED country question: pinned countries above a divider, then
86 // the rest, under a disabled "Choose one".
87 pinned := []string{"AE", "SA", "GB", "IN"}
88 byISO := map[string]countryRow{"SA": {"SA", "Saudi Arabia", "Saudi-Arabien", "966"}}
89 for _, c := range countries {
90 byISO[c.iso] = c
91 }
92 option := func(c countryRow) {
93 fmt.Fprintf(&body, `<option value="%s" data-rst-lead="%s" data-rst-terms="%s +%s">%s</option>`,
94 c.iso, flag(c.iso), c.iso, c.code, html.EscapeString(c.en))
95 }
96 body.WriteString(`<label rst-field-label for="country">Country</label>` +
97 `<select rst-input id="country" name="country" required data-rst-select>` +
98 `<option value="" disabled selected>Choose one</option>`)
99 for _, iso := range pinned {
100 option(byISO[iso])
101 }
102 body.WriteString(`<hr>`)
103 for _, c := range countries {
104 if c.iso != "GB" && c.iso != "AE" && c.iso != "IN" {
105 option(c)
106 }
107 }
108 body.WriteString(`</select>`)
109
110 // A phone number's country: a compact box, a MARKED prompt.
111 body.WriteString(`<select rst-input id="cc" name="cc" aria-label="Country" required data-rst-select data-rst-select-filter="Search">` +
112 `<option data-rst-prompt value="" selected>Country</option>`)
113 for _, c := range countries {
114 first := ""
115 if primary[c.iso] {
116 first = " data-rst-first"
117 }
118 fmt.Fprintf(&body, `<option value="%s" data-rst-short="+%s" data-rst-lead="%s" data-rst-terms="%s +%s"%s>%s (+%s)</option>`,
119 c.iso, c.code, flag(c.iso), c.iso, c.code, first, html.EscapeString(c.en), c.code)
120 }
121 body.WriteString(`</select><input type="tel" name="phone">`)
122
123 // An optional select whose blank is a real answer — until something
124 // makes the select required.
125 body.WriteString(`<label rst-field-label for="size">Size</label>` +
126 `<select rst-input id="size" name="size" data-rst-select>` +
127 `<option value="" selected>None</option><option value="s">Small</option><option value="m">Medium</option><option value="l">Large</option>` +
128 `</select>`)
129
130 // A grouped select, for the extra rows a page adds under a search.
131 body.WriteString(`<label rst-field-label for="dept">Department</label>` +
132 `<select rst-input id="dept" name="dept" data-rst-select>` +
133 `<optgroup label="Sales"><option value="acc">Accounts</option><option value="ret">Retail</option></optgroup>` +
134 `<optgroup label="Support"><option value="asi">Assistance</option><option value="bil">Billing</option></optgroup>` +
135 `</select>`)
136
137 // A select the page replaces outright, and one whose options it
138 // rebuilds, each in a host of its own.
139 body.WriteString(`<label rst-field-label for="colour">Colour</label><div id="colour-host">` +
140 `<select rst-input id="colour" name="colour" data-rst-select>` +
141 `<option value="red">Red</option><option value="green">Green</option><option value="blue">Blue</option>` +
142 `</select></div>`)
143 body.WriteString(`<label rst-field-label for="flavour">Flavour</label><div id="flavour-host">` +
144 `<select rst-input id="flavour" name="flavour" data-rst-select>` +
145 `<option value="apple">Apple</option><option value="banana">Banana</option><option value="cherry">Cherry</option>` +
146 `</select></div>`)
147
148 body.WriteString(`<button type="submit" id="go">Save</button></form></body></html>`)
149 return map[string]string{"/": body.String()}
150}
151
152// drive opens the page on a rig of its own and runs one page script,
153// decoding its result.
154func drive(t *testing.T, script string, out any) {
155 t.Helper()
156 driveScript(t, sizingRig(t, false, selectFilterPages(t)), script, out)
157}
158
159// driveScript is drive against a rig the caller built from
160// selectFilterPages.
161func driveScript(t *testing.T, rig *harness.Rig, script string, out any) {
162 t.Helper()
163 ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second)
164 defer cancel()
165 var raw string
166 if err := chromedp.Run(ctx,
167 // A page in a background tab gets no focus events from focus(),
168 // so a handler that reacts to focus could not be observed at all.
169 emulation.SetFocusEmulationEnabled(true),
170 chromedp.Navigate(rig.Origin+"/"),
171 chromedp.WaitVisible(`#tz-combo`, chromedp.ByQuery),
172 chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+script+`})()))()`, &raw, awaitPromise),
173 ); err != nil {
174 t.Fatalf("drive failed: %v", err)
175 }
176 if err := json.Unmarshal([]byte(raw), out); err != nil {
177 t.Fatalf("decode %q: %v", raw, err)
178 }
179}
180
181// driveSelectFilter is TestSelectKeepsPaceWithTyping's body, the
182// type-to-filter-and-commit journey, against any rig that serves
183// selectFilterPages. A burst of typing is searched once at the time
184// and once in the frame, not once per input event; Enter typed in the
185// same task acts on the words typed; a keystroke moves only the rows
186// it shows and writes a row's hidden state only when it changes; and
187// clearing puts every row back in its own place.
188func driveSelectFilter(t *testing.T, rig *harness.Rig) {
189 t.Helper()
190 var got struct {
191 SyncSearches int `json:"syncSearches"`
192 FrameSearches int `json:"frameSearches"`
193 Picked string `json:"picked"`
194 Rows int `json:"rows"`
195 Moves int `json:"moves"`
196 HiddenMoved []string `json:"hiddenMoved"`
197 NoopWrites []string `json:"noopWrites"`
198 OutOfPlace []string `json:"outOfPlace"`
199 Shown int `json:"shown"`
200 }
201 driveScript(t, rig, `
202 const input = document.querySelector('#tz-combo');
203 const select = document.querySelector('#tz');
204 const list = document.querySelector('#tz-listbox');
205 const frame = () => `+afterFrame+`;
206 const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); };
207 const out = { hiddenMoved: [], noopWrites: [], outOfPlace: [], moves: 0 };
208 input.focus();
209 input.click();
210 await frame();
211 const seen = [];
212 const mo = new MutationObserver((m) => seen.push(...m));
213 mo.observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'], attributeOldValue: true });
214
215 // A search shows itself as rows shown and hidden. Four inputs in one
216 // task: the first is searched at once (rows change now) and the rest
217 // once, in the frame (rows change again then). Had every input been
218 // searched at once, the frame would have nothing left to do. Counted
219 // as batches, since the highlight is no longer rewritten when it does
220 // not move.
221 mo.takeRecords();
222 type('D');
223 out.syncSearches = mo.takeRecords().length ? 1 : 0;
224 for (const v of ['Du', 'Dub', 'Dubl']) type(v);
225 out.syncSearches += mo.takeRecords().length ? 1 : 0;
226 await frame();
227 out.frameSearches = (seen.length + mo.takeRecords().length) ? 1 : 0;
228 seen.length = 0;
229
230 type('Dubl');
231 type('Lond');
232 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
233 out.picked = select.value;
234 await frame();
235 mo.takeRecords();
236 seen.length = 0;
237
238 input.click();
239 await frame();
240 mo.takeRecords();
241 seen.length = 0;
242 const word = 'Europe/Lo';
243 for (let i = 1; i <= word.length; i++) {
244 type(word.slice(0, i));
245 await frame();
246 const recs = [...seen, ...mo.takeRecords()];
247 seen.length = 0;
248 const writes = new Map();
249 for (const r of recs) {
250 if (r.type === 'childList') {
251 for (const n of r.addedNodes) {
252 out.moves++;
253 if (n.style.display === 'none') out.hiddenMoved.push(word.slice(0, i) + ': ' + (n.id || n.tagName));
254 }
255 } else {
256 if (!writes.has(r.target)) writes.set(r.target, []);
257 writes.get(r.target).push((r.oldValue || '').includes('none'));
258 }
259 }
260 for (const [el, olds] of writes) {
261 olds.forEach((was, j) => {
262 const now = j + 1 < olds.length ? olds[j + 1] : el.style.display === 'none';
263 if (was === now) out.noopWrites.push(word.slice(0, i) + ': ' + (el.id || el.tagName));
264 });
265 }
266 }
267 mo.disconnect();
268 out.shown = list.querySelectorAll('[rst-combo-option]:not([style*="none"])').length;
269
270 type('');
271 await frame();
272 const rows = [...list.querySelectorAll('[rst-combo-option]')];
273 out.rows = rows.length;
274 rows.forEach((li, i) => {
275 if (li.id !== 'tz-listbox-' + i) out.outOfPlace.push(i + ': ' + li.id);
276 });
277 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
278 return out;
279 `, &got)
280 if got.SyncSearches != 1 || got.FrameSearches != 1 {
281 t.Errorf("four input events in one task were searched %d times at once and %d more in the frame, want 1 and 1", got.SyncSearches, got.FrameSearches)
282 }
283 if got.Picked != "Europe/London" {
284 t.Errorf("typing \"Dubl\" then \"Lond\" and pressing Enter in the same task picked %q, want Europe/London: Enter acted on a list that had not caught up with the words", got.Picked)
285 }
286 if len(got.HiddenMoved) > 0 {
287 t.Errorf("a keystroke moved %d rows it hides (first: %v): only the rows a search shows should move", len(got.HiddenMoved), got.HiddenMoved[0])
288 }
289 if len(got.NoopWrites) > 0 {
290 t.Errorf("%d hidden-state writes set a row to what it already was (first: %v)", len(got.NoopWrites), got.NoopWrites[0])
291 }
292 // The fence above is only a fence if the observer saw rows move at all.
293 if got.Shown == 0 || got.Moves == 0 {
294 t.Errorf("typing \"Europe/Lo\" showed %d rows and moved %d: the observer saw no search happen", got.Shown, got.Moves)
295 }
296 if got.Rows < 50 || len(got.OutOfPlace) > 0 {
297 t.Errorf("after clearing the search, %d of %d rows are out of their own place (first: %v)", len(got.OutOfPlace), got.Rows, got.OutOfPlace)
298 }
299}
300
301func TestSelectKeepsPaceWithTyping(t *testing.T) {
302 t.Parallel()
303 driveSelectFilter(t, sizingRig(t, false, selectFilterPages(t)))
304}
305
306// A required country question with pinned countries above a divider:
307// clearing a search puts a lifted pinned country back ABOVE the divider
308// and the divider shows again; the required blank is never a row, even
309// for words that name it; words typed and cleared on the unanswered
310// question leave nothing highlighted, so Enter cannot answer it; and a
311// pick closes the list without leaving the next search waiting on a frame.
312func TestSelectKeepsItsDividerAndPrompt(t *testing.T) {
313 t.Parallel()
314 var got struct {
315 Sep int `json:"sep"`
316 Restored bool `json:"restored"`
317 Order []string `json:"order"`
318 SepSearching bool `json:"sepSearching"`
319 SepAfter bool `json:"sepAfter"`
320 PromptIdle bool `json:"promptIdle"`
321 PromptTyped bool `json:"promptTyped"`
322 ClearedActive string `json:"clearedActive"`
323 ClearedEnter string `json:"clearedEnter"`
324 ClearedOpen bool `json:"clearedOpen"`
325 Picked string `json:"picked"`
326 ActiveAfter string `json:"activeAfter"`
327 TopAfter string `json:"topAfter"`
328 }
329 drive(t, `
330 const input = document.querySelector('#country-combo');
331 const list = document.querySelector('#country-listbox');
332 const select = document.querySelector('#country');
333 const frame = () => `+afterFrame+`;
334 const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); };
335 const seq = () => [...list.children].map((c) => c.id || c.tagName + (c.hasAttribute('rst-combo-sep') ? ':sep' : ''));
336 const blank = document.querySelector('#country-listbox-0');
337 const sep = list.querySelector('[rst-combo-sep]');
338 const out = { sep: list.querySelectorAll('[rst-combo-sep]').length };
339 input.focus();
340 input.click();
341 await frame();
342 const s0 = seq();
343 out.promptIdle = blank.style.display !== 'none';
344 for (const q of ['a', 'i', 'united k']) {
345 type(q);
346 await frame();
347 if (q === 'a') out.sepSearching = sep.style.display !== 'none';
348 }
349 type('');
350 await frame();
351 out.order = seq();
352 out.restored = out.order.join() === s0.join();
353 out.sepAfter = sep.style.display !== 'none';
354 type('choose');
355 await frame();
356 out.promptTyped = blank.style.display !== 'none';
357 type('ger');
358 await frame();
359 type('');
360 await frame();
361 out.clearedActive = input.getAttribute('aria-activedescendant') || '';
362 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
363 out.clearedEnter = select.value;
364 out.clearedOpen = input.getAttribute('aria-expanded') === 'true';
365
366 type('irel');
367 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
368 out.picked = select.value;
369 type('germ');
370 const act = input.getAttribute('aria-activedescendant');
371 out.activeAfter = act ? select.options[Number(act.split('-listbox-')[1])].value : '';
372 const top = list.querySelector('[rst-combo-option]:not([style*="none"])');
373 out.topAfter = top ? select.options[Number(top.id.split('-listbox-')[1])].value : '';
374 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
375 return out;
376 `, &got)
377 if got.Sep != 1 {
378 t.Fatalf("the country list drew %d dividers, want 1: the fence below needs one", got.Sep)
379 }
380 if !got.Restored {
381 head := got.Order
382 if len(head) > 8 {
383 head = head[:8]
384 }
385 t.Errorf("after searching \"a\", \"i\" and \"united k\" and clearing, the list is not in its own order: it starts %v", head)
386 }
387 if got.SepSearching || !got.SepAfter {
388 t.Errorf("the divider showed %v while searching and %v after, want false then true", got.SepSearching, got.SepAfter)
389 }
390 if got.PromptIdle || got.PromptTyped {
391 t.Errorf("the required blank was a row with nothing typed (%v) or for \"choose\" (%v): it is a prompt, never an answer", got.PromptIdle, got.PromptTyped)
392 }
393 if got.ClearedActive != "" || got.ClearedEnter != "" || !got.ClearedOpen {
394 t.Errorf("typing \"ger\" and clearing it on the unanswered question left %q highlighted, and Enter picked %q (list open: %v): a cleared search must highlight nothing, as opening does", got.ClearedActive, got.ClearedEnter, got.ClearedOpen)
395 }
396 if got.Picked != "IE" {
397 t.Errorf("\"irel\" then Enter picked %q, want IE", got.Picked)
398 }
399 if got.ActiveAfter != "DE" || got.TopAfter != "DE" {
400 t.Errorf("typing \"germ\" in the frame after a pick highlighted %q with %q on top, want DE and DE: the search waited on the frame the earlier typing armed", got.ActiveAfter, got.TopAfter)
401 }
402}
403
404// A phone number's country: a MARKED prompt, "Country", which is a row
405// while nothing is typed. Unanswered, it highlights nothing — on opening,
406// and when a search is cleared — and the box shows only its placeholder,
407// so Enter picks nothing and the list stays open. The prompt is never a
408// stop for the arrows, Home or End, and never aria-selected. Answered,
409// the box reopens on its pick and a cleared search comes back to it.
410func TestSelectPromptClearsToItsPlaceholder(t *testing.T) {
411 t.Parallel()
412 var got struct {
413 PromptRow bool `json:"promptRow"`
414 OpenActive string `json:"openActive"`
415 ClearedActive string `json:"clearedActive"`
416 ClearedValue string `json:"clearedValue"`
417 Placeholder string `json:"placeholder"`
418 ClearedEnter string `json:"clearedEnter"`
419 ClearedOpen bool `json:"clearedOpen"`
420 Picked string `json:"picked"`
421 Shown string `json:"shown"`
422 PromptSelected string `json:"promptSelected"`
423 DownFromNothing string `json:"downFromNothing"`
424 UpFromFirst string `json:"upFromFirst"`
425 End string `json:"end"`
426 Home string `json:"home"`
427 GBRow string `json:"gbRow"`
428 GBSelected string `json:"gbSelected"`
429 ReopenActive string `json:"reopenActive"`
430 SearchActive string `json:"searchActive"`
431 AnsweredClearedActive string `json:"answeredClearedActive"`
432 }
433 drive(t, `
434 const input = document.querySelector('#cc-combo');
435 const select = document.querySelector('#cc');
436 const frame = () => `+afterFrame+`;
437 const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); };
438 const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
439 const act = () => input.getAttribute('aria-activedescendant') || '';
440 const out = {};
441 input.focus();
442 input.click();
443 await frame();
444 const prompt = document.querySelector('#cc-listbox-0');
445 out.promptRow = prompt.style.display !== 'none';
446 out.openActive = act();
447 out.promptSelected = prompt.getAttribute('aria-selected');
448 key('ArrowDown');
449 out.downFromNothing = act();
450 key('ArrowUp');
451 out.upFromFirst = act();
452 key('End');
453 out.end = act();
454 key('Home');
455 out.home = act();
456 type('44');
457 await frame();
458 type('');
459 await frame();
460 out.clearedActive = act();
461 out.clearedValue = input.value;
462 out.placeholder = input.placeholder;
463 key('Enter');
464 out.clearedEnter = select.value;
465 out.clearedOpen = input.getAttribute('aria-expanded') === 'true';
466 type('+44');
467 key('Enter');
468 out.picked = select.value;
469 out.shown = input.value;
470 out.gbRow = 'cc-listbox-' + select.selectedIndex;
471 out.gbSelected = document.querySelector('#' + out.gbRow).getAttribute('aria-selected');
472 input.blur();
473 input.focus();
474 input.click();
475 await frame();
476 out.reopenActive = act();
477 type('ger');
478 await frame();
479 out.searchActive = act();
480 type('');
481 await frame();
482 out.answeredClearedActive = act();
483 key('Escape');
484 return out;
485 `, &got)
486 if !got.PromptRow {
487 t.Error("the \"Country\" prompt is not a row with nothing typed")
488 }
489 if got.OpenActive != "" {
490 t.Errorf("the unanswered box opened with %q highlighted, want nothing", got.OpenActive)
491 }
492 if got.ClearedActive != "" || got.ClearedEnter != "" || !got.ClearedOpen {
493 t.Errorf("typing \"44\" and clearing it left %q highlighted, and Enter picked %q (list open: %v): a cleared search highlights nothing", got.ClearedActive, got.ClearedEnter, got.ClearedOpen)
494 }
495 if got.ClearedValue != "" || got.Placeholder != "Country" {
496 t.Errorf("the cleared box reads %q under placeholder %q, want empty under \"Country\"", got.ClearedValue, got.Placeholder)
497 }
498 if got.Picked != "GB" || got.Shown != "+44" {
499 t.Errorf("\"+44\" then Enter picked %q showing %q, want GB showing +44", got.Picked, got.Shown)
500 }
501 // The prompt is never a stop for the keys, and never "selected".
502 const first = "cc-listbox-1"
503 last := fmt.Sprintf("cc-listbox-%d", len(countries))
504 if got.DownFromNothing != first {
505 t.Errorf("ArrowDown from nothing highlighted %q, want the first country %s: the prompt is not a stop", got.DownFromNothing, first)
506 }
507 if got.UpFromFirst != first || got.Home != first {
508 t.Errorf("ArrowUp from the first country went to %q and Home to %q, want both %s: the keys never land on the prompt", got.UpFromFirst, got.Home, first)
509 }
510 if got.End != last {
511 t.Errorf("End highlighted %q, want the last country %s", got.End, last)
512 }
513 if got.PromptSelected != "false" {
514 t.Errorf("the prompt reads aria-selected=%q on an unanswered box, want false: a prompt is never selected", got.PromptSelected)
515 }
516 if got.GBSelected != "true" {
517 t.Errorf("after picking GB its row reads aria-selected=%q, want true", got.GBSelected)
518 }
519 // Opening lands where opening() says: the pick, on an answered box.
520 if got.ReopenActive != got.GBRow {
521 t.Errorf("the answered box reopened on %q, want its pick %s", got.ReopenActive, got.GBRow)
522 }
523 if got.SearchActive == "" || got.SearchActive == got.GBRow || got.AnsweredClearedActive != got.GBRow {
524 t.Errorf("on the answered box, \"ger\" highlighted %q and clearing it %q, want another row and then the pick %s", got.SearchActive, got.AnsweredClearedActive, got.GBRow)
525 }
526}
527
528// A select that becomes required (or stops) re-derives its prompts: an
529// optional select's "None" is a real answer — shown, listed, selected —
530// and once the select is required the same blank is a prompt, so the box
531// empties and no row is selected. And a highlighted row a page takes
532// away (its extra suggestions replaced) is never left as the highlight.
533func TestSelectFollowsRequiredAndExtras(t *testing.T) {
534 t.Parallel()
535 var got struct {
536 OptionalShown string `json:"optionalShown"`
537 OptionalSelected string `json:"optionalSelected"`
538 RequiredShown string `json:"requiredShown"`
539 RequiredSelected string `json:"requiredSelected"`
540 BackShown string `json:"backShown"`
541 ExtraActive string `json:"extraActive"`
542 AfterRemove string `json:"afterRemove"`
543 AfterRemoveInDOM bool `json:"afterRemoveInDOM"`
544 BorrowedCleared bool `json:"borrowedCleared"`
545 AfterRemoval string `json:"afterRemovalValue"`
546 AfterCancel string `json:"afterCancelValue"`
547 Requery string `json:"requeryValue"`
548 RemovedShown string `json:"removedSelectedShown"`
549 RemovedValue string `json:"removedSelectedValue"`
550 NoneActive string `json:"noneActive"`
551 NoneIndex int `json:"noneIndex"`
552 BorrowedDisabled bool `json:"borrowedClearedDisabled"`
553 AriaRequired string `json:"ariaRequired"`
554 OpenFlipListed bool `json:"openFlipListed"`
555 OpenFlipActive string `json:"openFlipActive"`
556 OpenFlipShown int `json:"openFlipShown"`
557 OpenFlipText string `json:"openFlipText"`
558 IdleExtrasShown int `json:"idleExtrasShown"`
559 ScreenOrder string `json:"screenOrder"`
560 KeyOrder string `json:"keyOrder"`
561 }
562 drive(t, `
563 let input = document.querySelector('#size-combo');
564 // Changing the options rebuilds the box, so the drive re-reads it.
565 const again = async () => { await frame(); input = document.querySelector('#size-combo'); };
566 const select = document.querySelector('#size');
567 const blank = document.querySelector('#size-listbox-0');
568 const frame = () => `+afterFrame+`;
569 const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
570 const out = {};
571 out.optionalShown = input.value;
572 // Read with the list open, which is when rows are marked.
573 input.focus();
574 input.click();
575 await frame();
576 out.optionalSelected = blank.getAttribute('aria-selected');
577 key('Escape');
578 select.required = true;
579 await frame();
580 out.requiredShown = input.value;
581 out.requiredSelected = blank.getAttribute('aria-selected');
582 out.ariaRequired = input.getAttribute('aria-required');
583 select.required = false;
584 await frame();
585 // And with the list OPEN when it flips: the blank leaves the list
586 // and is not left highlighted.
587 input.focus();
588 input.click();
589 await frame();
590 select.required = true;
591 await frame();
592 out.openFlipListed = blank.style.display !== 'none';
593 out.openFlipActive = input.getAttribute('aria-activedescendant') || '';
594 // Idle (nothing typed), the refresh is not a search for the pick's
595 // own text: every real choice stays, and the box drops "None".
596 out.openFlipShown = document.querySelectorAll('#size-listbox [rst-combo-option]:not([style*="none"])').length;
597 out.openFlipText = input.value;
598 key('Escape');
599 select.required = false;
600 await frame();
601 // And extras handed over while the box shows the pick hide nothing.
602 input.focus();
603 input.click();
604 await frame();
605 input.closest('[rst-combo]').rstExtras([{ label: 'Something else', run: () => {} }]);
606 out.idleExtrasShown = document.querySelectorAll('#size-listbox [rst-combo-option]:not([style*="none"])').length;
607 input.closest('[rst-combo]').rstExtras([]);
608 key('Escape');
609 select.required = false;
610 await frame();
611 out.backShown = input.value;
612
613 // Extras: arrow onto one, then have the page take them all away.
614 const wrap = input.closest('[rst-combo]');
615 input.focus();
616 input.click();
617 await frame();
618 wrap.rstExtras([{ label: 'Something else', run: () => {} }]);
619 key('End');
620 out.extraActive = input.getAttribute('aria-activedescendant') || '';
621 wrap.rstExtras([]);
622 const id = input.getAttribute('aria-activedescendant') || '';
623 out.afterRemove = id;
624 out.afterRemoveInDOM = id === '' || !!document.getElementById(id);
625 key('Escape');
626
627 // An option the app removes after enhancement moves every index:
628 // the row chosen is still the option submitted.
629 const small = select.querySelector('option[value="s"]');
630 small.remove();
631 await again();
632 input.focus();
633 input.click();
634 await frame();
635 const medium = [...document.querySelectorAll('#size-listbox [rst-combo-option]')].find((li) => li.textContent === 'Medium');
636 medium.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
637 out.afterRemovalValue = select.value;
638 select.insertBefore(small, select.querySelector('option[value="m"]'));
639 await again();
640 select.value = '';
641 select.dispatchEvent(new Event('change', { bubbles: true }));
642
643 // The SELECTED option removed: the next pick is what the box shows.
644 select.value = 's';
645 select.dispatchEvent(new Event('change', { bubbles: true }));
646 const small2 = select.querySelector('option[value="s"]');
647 small2.remove();
648 await again();
649 input.focus();
650 input.click();
651 await frame();
652 const large = [...document.querySelectorAll('#size-listbox [rst-combo-option]')].find((li) => li.textContent === 'Large');
653 large.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
654 out.removedSelectedShown = input.value;
655 out.removedSelectedValue = select.value;
656 select.insertBefore(small2, select.querySelector('option[value="m"]'));
657 await again();
658
659 // Nothing selected at all is unanswered: nothing is highlighted, and
660 // a bare Enter answers nothing.
661 select.selectedIndex = -1;
662 input.focus();
663 input.click();
664 await frame();
665 out.noneActive = input.getAttribute('aria-activedescendant') || '';
666 key('Enter');
667 out.noneIndex = select.selectedIndex;
668 key('Escape');
669 select.value = '';
670 select.dispatchEvent(new Event('change', { bubbles: true }));
671
672 // A cancelled search takes its steering with it: type, arrow,
673 // Escape, type the same again, leave — nothing is committed.
674 input.focus();
675 input.click();
676 await frame();
677 const typeSize = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); };
678 typeSize('m');
679 await frame();
680 key('ArrowDown');
681 key('Escape');
682 typeSize('m');
683 await frame();
684 key('Tab');
685 out.afterCancelValue = select.value;
686 key('Escape');
687
688 // And the same words after a cancelled search are a NEW search: it
689 // re-ranks, so Enter takes its best match, not the pick the old one
690 // left highlighted. Large is picked; "a" ranks Small first.
691 select.value = 'l';
692 select.dispatchEvent(new Event('change', { bubbles: true }));
693 input.focus();
694 input.click();
695 await frame();
696 typeSize('a');
697 await frame();
698 key('Escape');
699 typeSize('a');
700 await frame();
701 key('Enter');
702 out.requeryValue = select.value;
703 select.value = '';
704 select.dispatchEvent(new Event('change', { bubbles: true }));
705
706 // A borrowed "please pick one" goes the moment the select stops
707 // objecting, even while the box keeps focus.
708 select.required = true;
709 select.value = '';
710 select.dispatchEvent(new Event('change', { bubbles: true }));
711 select.required = true;
712 input.setCustomValidity(select.validationMessage || 'Please pick one');
713 select.required = false;
714 await frame();
715 out.borrowedCleared = input.validity.valid;
716 // And when it is disabled instead: a disabled select is never
717 // validated, whatever its validity says.
718 select.required = true;
719 input.setCustomValidity(select.validationMessage || 'Please pick one');
720 select.disabled = true;
721 await frame();
722 out.borrowedClearedDisabled = input.validity.valid;
723 select.disabled = false;
724 select.required = false;
725 await frame();
726
727 // Grouped hits under a search sit above the page's extra rows, in
728 // the order the keys walk them.
729 const dInput = document.querySelector('#dept-combo');
730 const dWrap = dInput.closest('[rst-combo]');
731 const dList = document.querySelector('#dept-listbox');
732 dInput.focus();
733 dInput.click();
734 await frame();
735 dWrap.rstExtras([{ label: 'Other', run: () => {} }]);
736 dInput.value = 'a';
737 dInput.dispatchEvent(new Event('input', { bubbles: true }));
738 await frame();
739 out.screenOrder = [...dList.querySelectorAll('[rst-combo-option]:not([style*="none"])')].map((li) => li.textContent).join('|');
740 const walked = [];
741 const dkey = (k) => dInput.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
742 dkey('Home');
743 for (let i = 0; i < 6; i++) {
744 const a = dInput.getAttribute('aria-activedescendant');
745 const t = a ? document.getElementById(a).textContent : '';
746 if (!walked.includes(t)) walked.push(t);
747 dkey('ArrowDown');
748 }
749 out.keyOrder = walked.join('|');
750 dkey('Escape');
751 return out;
752 `, &got)
753 if got.OptionalShown != "None" || got.OptionalSelected != "true" {
754 t.Errorf("an optional select's blank shows %q and reads aria-selected=%q, want \"None\" and true: it is a real answer", got.OptionalShown, got.OptionalSelected)
755 }
756 if got.RequiredShown != "" || got.RequiredSelected != "false" {
757 t.Errorf("made required, the blank shows %q and reads aria-selected=%q, want empty and false: the observer did not re-derive the prompt", got.RequiredShown, got.RequiredSelected)
758 }
759 if !got.BorrowedDisabled {
760 t.Error("the box kept the select's borrowed message after the select was disabled: a disabled field would still block the form")
761 }
762 if got.AriaRequired != "true" {
763 t.Errorf("the box of a required select reads aria-required=%q, want \"true\": an ARIA boolean is a string", got.AriaRequired)
764 }
765 if got.OpenFlipListed || got.OpenFlipActive == "size-listbox-0" {
766 t.Errorf("made required with the list open, the blank is still listed (%v) or highlighted (%q): the open list did not re-read its prompts", got.OpenFlipListed, got.OpenFlipActive)
767 }
768 if got.OpenFlipShown != 3 || got.OpenFlipText != "" {
769 t.Errorf("made required with the list open and nothing typed, %d rows show and the box reads %q, want the 3 real choices and an empty box: the refresh searched for the old pick's text", got.OpenFlipShown, got.OpenFlipText)
770 }
771 if got.IdleExtrasShown != 5 {
772 t.Errorf("extras handed to an idle open list left %d rows showing, want 5 (the 4 options and the extra)", got.IdleExtrasShown)
773 }
774 if got.BackShown != "None" {
775 t.Errorf("made optional again, the box shows %q, want \"None\"", got.BackShown)
776 }
777 if !strings.Contains(got.ExtraActive, "-extra-") {
778 t.Fatalf("End did not reach the extra row (highlighted %q): the fence below needs it", got.ExtraActive)
779 }
780 if !got.AfterRemoveInDOM {
781 t.Errorf("after the page took its extra rows away the highlight still names %q, a row that is gone", got.AfterRemove)
782 }
783 if got.AfterRemoval != "m" {
784 t.Errorf("after the app removed an option, choosing the Medium row submitted %q, want m: a cached index picked another option", got.AfterRemoval)
785 }
786 if got.RemovedShown != "Large" || got.RemovedValue != "l" {
787 t.Errorf("after the selected option was removed, choosing Large shows %q and submits %q, want Large and l: a detached option still read as selected", got.RemovedShown, got.RemovedValue)
788 }
789 if got.NoneActive != "" || got.NoneIndex != -1 {
790 t.Errorf("with nothing selected the list opened on %q and Enter left selectedIndex %d, want nothing highlighted and -1: no selection is unanswered", got.NoneActive, got.NoneIndex)
791 }
792 if got.Requery != "s" {
793 t.Errorf("\"a\" typed again after a cancelled \"a\" and Enter picked %q, want s (Small, its best match): the cancelled search's query survived, so it did not re-rank", got.Requery)
794 }
795 if got.AfterCancel != "" {
796 t.Errorf("a search typed, arrowed, cancelled and typed again committed %q on leaving, want nothing: the cancelled search's steering survived", got.AfterCancel)
797 }
798 if !got.BorrowedCleared {
799 t.Error("the box kept the select's borrowed message after the select stopped being required: the form would still refuse to submit")
800 }
801 if got.ScreenOrder == "" || got.ScreenOrder != got.KeyOrder || !strings.HasSuffix(got.ScreenOrder, "|Other") {
802 t.Errorf("a grouped search shows %q but the keys walk %q: the matches must sit above the page's extra row, in the keys' order", got.ScreenOrder, got.KeyOrder)
803 }
804}
805
806// A page may replace a whole <select> (a question flow swapping its
807// answer control). The box that spoke for the old one steps aside and the
808// new one is enhanced afresh: before, Tito Go's box kept driving the
809// detached select, showing "Green" while the form posted "Blue".
810func TestSelectStepsAsideWhenReplaced(t *testing.T) {
811 t.Parallel()
812 var got struct {
813 Boxes int `json:"boxes"`
814 Shown string `json:"shown"`
815 LabelFor string `json:"labelFor"`
816 PostedNow string `json:"postedNow"`
817 Picked string `json:"picked"`
818 PostedPick string `json:"postedPick"`
819 SameBoxes int `json:"sameTaskBoxes"`
820 SameShown string `json:"sameTaskShown"`
821 SameErrors int `json:"sameTaskErrors"`
822 BothLabelled bool `json:"bothLabelled"`
823 CloneBoxes int `json:"cloneBoxes"`
824 }
825 drive(t, `
826 const frame = () => `+afterFrame+`;
827 const form = document.querySelector('form');
828 const posted = () => new FormData(form).get('colour');
829 // Pick Green through the box, then the page swaps the select for
830 // one that holds Blue.
831 let input = document.querySelector('#colour-combo');
832 input.focus();
833 input.value = 'green';
834 input.dispatchEvent(new Event('input', { bubbles: true }));
835 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
836 input.blur();
837 const fresh = document.createElement('select');
838 fresh.id = 'colour';
839 fresh.name = 'colour';
840 fresh.setAttribute('rst-input', '');
841 fresh.setAttribute('data-rst-select', '');
842 fresh.innerHTML = '<option value="red">Red</option><option value="green">Green</option><option value="blue" selected>Blue</option>';
843 document.querySelector('#colour').replaceWith(fresh);
844 await frame();
845 const out = {};
846 out.boxes = document.querySelectorAll('#colour-host [rst-combo]').length;
847 input = document.querySelector('#colour-combo');
848 out.shown = input ? input.value : '';
849 out.labelFor = document.querySelector('label[for="colour-combo"]') ? 'colour-combo' : '';
850 out.postedNow = posted();
851 input.focus();
852 input.value = 'red';
853 input.dispatchEvent(new Event('input', { bubbles: true }));
854 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
855 out.picked = input.value;
856 out.postedPick = posted();
857 // Two selects replaced in one task: each new box is named by its label.
858 const swapIn = (id, opts) => {
859 const n = document.createElement('select');
860 n.id = id;
861 n.name = id;
862 n.setAttribute('data-rst-select', '');
863 n.innerHTML = opts;
864 document.querySelector('#' + id).replaceWith(n);
865 };
866 swapIn('colour', '<option value="red">Red</option><option value="blue">Blue</option>');
867 swapIn('flavour', '<option value="fig">Fig</option><option value="lime">Lime</option>');
868 await frame();
869 out.bothLabelled = !!document.querySelector('label[for="colour-combo"]') && !!document.querySelector('label[for="flavour-combo"]');
870 // Changed and replaced in the same task: a handover, and no error.
871 let errors = 0;
872 window.addEventListener('error', () => errors++);
873 const again = document.createElement('select');
874 again.id = 'colour';
875 again.name = 'colour';
876 again.setAttribute('data-rst-select', '');
877 again.innerHTML = '<option value="teal" selected>Teal</option><option value="navy">Navy</option>';
878 const current = document.querySelector('#colour');
879 current.innerHTML = '';
880 current.replaceWith(again);
881 await frame();
882 out.sameTaskBoxes = document.querySelectorAll('#colour-host [rst-combo]').length;
883 out.sameTaskShown = (document.querySelector('#colour-combo') || {}).value || '';
884 // Replaced with a clone of itself, which copies the enhancement
885 // marker: the clone is still enhanced.
886 const orig = document.querySelector('#colour');
887 orig.replaceWith(orig.cloneNode(true));
888 await frame();
889 out.cloneBoxes = document.querySelectorAll('#colour-host [rst-combo]').length;
890 // Options changed and the whole host cleared in one task: nothing
891 // is left to enhance, and nothing throws.
892 const last = document.querySelector('#colour');
893 last.append(new Option('Grey', 'grey'));
894 document.querySelector('#colour-host').innerHTML = '';
895 await frame();
896 out.sameTaskErrors = errors;
897 return out;
898 `, &got)
899 if got.Boxes != 1 {
900 t.Errorf("after the select was replaced its host holds %d boxes, want 1: the old box did not step aside, or the new select was not enhanced", got.Boxes)
901 }
902 if got.Shown != "Blue" || got.PostedNow != "blue" {
903 t.Errorf("the box shows %q while the form posts %q, want Blue and blue: the box speaks for a select no longer in the form", got.Shown, got.PostedNow)
904 }
905 if got.LabelFor != "colour-combo" {
906 t.Error("the label does not name the new box")
907 }
908 if got.CloneBoxes != 1 {
909 t.Errorf("a select replaced with its own clone left %d boxes, want 1: the clone's copied marker was trusted", got.CloneBoxes)
910 }
911 if !got.BothLabelled {
912 t.Error("two selects replaced in one task: a new box is left without its label, which an old box handed back to the hidden select")
913 }
914 if got.SameBoxes != 1 || got.SameShown != "Teal" || got.SameErrors != 0 {
915 t.Errorf("options changed and select replaced in one task: %d boxes showing %q, %d errors; want 1 box showing Teal and none", got.SameBoxes, got.SameShown, got.SameErrors)
916 }
917 if got.Picked != "Red" || got.PostedPick != "red" {
918 t.Errorf("picking Red on the new box shows %q and posts %q, want Red and red", got.Picked, got.PostedPick)
919 }
920}
921
922// A page may rebuild a select's options while somebody is searching it.
923// The box is rebuilt from what the select now holds — every row an option
924// it really has — and the person keeps their focus and their words.
925func TestSelectRebuildsWhenItsOptionsChange(t *testing.T) {
926 t.Parallel()
927 var got struct {
928 Boxes int `json:"boxes"`
929 Focused bool `json:"focused"`
930 Typed string `json:"typed"`
931 Rows string `json:"rows"`
932 Picked string `json:"picked"`
933 BoxesAfterTwo int `json:"boxesAfterTwo"`
934 StillHidden bool `json:"stillHidden"`
935 LiveListeners int `json:"liveListeners"`
936 Recorded int `json:"recorded"`
937 ExtraKept bool `json:"extraKept"`
938 RebuildInputs int `json:"rebuildInputs"`
939 StayedClosed bool `json:"stayedClosed"`
940 KeptFocus bool `json:"keptFocus"`
941 Caret int `json:"caret"`
942 StillOn string `json:"stillOn"`
943 }
944 drive(t, `
945 const frame = () => `+afterFrame+`;
946 const select = document.querySelector('#flavour');
947 // Record every listener a box puts on the select from here on, so a
948 // retired box's can be counted.
949 const listeners = [];
950 const add = select.addEventListener;
951 select.addEventListener = function (type, fn, opt) {
952 listeners.push({ type, signal: opt && opt.signal });
953 return add.call(this, type, fn, opt);
954 };
955 let input = document.querySelector('#flavour-combo');
956 input.focus();
957 input.value = 'bl';
958 input.dispatchEvent(new Event('input', { bubbles: true }));
959 await frame();
960 // The caret mid-word, as when somebody goes back to correct a letter.
961 input.setSelectionRange(1, 1);
962 input.closest('[rst-combo]').rstExtras([{ label: 'Other', run: () => {} }]);
963 // A rebuild restores the search quietly: an input event would let a
964 // page that refreshes options on input loop forever.
965 let inputs = 0;
966 document.addEventListener('input', () => inputs++, true);
967 select.innerHTML = '<option value="blueberry">Blueberry</option><option value="blackberry">Blackberry</option><option value="kiwi">Kiwi</option>';
968 await frame();
969 const out = {};
970 out.boxes = document.querySelectorAll('#flavour-host [rst-combo]').length;
971 input = document.querySelector('#flavour-combo');
972 out.focused = document.activeElement === input;
973 out.typed = input.value;
974 out.caret = input.selectionStart;
975 out.rows = [...document.querySelectorAll('#flavour-listbox [rst-combo-option]:not([style*="none"])')].map((li) => li.textContent).join('|');
976 out.extraKept = [...document.querySelectorAll('#flavour-listbox [rst-combo-option]')].some((li) => li.textContent === 'Other');
977 out.rebuildInputs = inputs;
978 input.closest('[rst-combo]').rstExtras([]);
979 // Arrow to the second row, then the page changes the options again:
980 // the row somebody is on is still the one Enter takes.
981 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
982 select.append(new Option('Bilberry', 'bilberry'));
983 await frame();
984 input = document.querySelector('#flavour-combo');
985 const on = input.getAttribute('aria-activedescendant');
986 out.stillOn = on ? document.getElementById(on).textContent : '';
987 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
988 out.picked = select.value;
989 // A second change: only the current box may answer it. An old box
990 // still listening would step aside again and hand the select back
991 // half-restored, visible and unenhanced.
992 // Enter closed the list and focus stayed: a rebuild now must not
993 // reopen it (and take the next Enter from the form).
994 select.append(new Option('Lime', 'lime'));
995 await frame();
996 out.stayedClosed = document.querySelector('#flavour-combo').getAttribute('aria-expanded') === 'false';
997 out.keptFocus = document.activeElement === document.querySelector('#flavour-combo');
998 out.boxesAfterTwo = document.querySelectorAll('#flavour-host [rst-combo]').length;
999 out.stillHidden = select.classList.contains('rst-sr-only') && select.dataset.rstEnhanced === 'true';
1000 out.liveListeners = listeners.filter((l) => !(l.signal && l.signal.aborted)).length;
1001 out.recorded = listeners.length;
1002 return out;
1003 `, &got)
1004 if got.Boxes != 1 {
1005 t.Errorf("after the options were rebuilt the host holds %d boxes, want 1", got.Boxes)
1006 }
1007 if got.Recorded < 9 || got.LiveListeners != 3 {
1008 t.Errorf("after three rebuilds %d of the %d listeners boxes put on the select are live, want only the current box's 3: a retired box still answers the select's events", got.LiveListeners, got.Recorded)
1009 }
1010 if got.Caret != 1 {
1011 t.Errorf("after the rebuild the caret is at %d, want 1: it jumped to the end of the words being corrected", got.Caret)
1012 }
1013 if got.StillOn != "Blackberry" {
1014 t.Errorf("after arrowing to Blackberry and a rebuild, %q is highlighted, want Blackberry: Enter would take another row", got.StillOn)
1015 }
1016 if got.BoxesAfterTwo != 1 || !got.StillHidden {
1017 t.Errorf("after a second change the host holds %d boxes and the select is enhanced-and-hidden %v, want 1 and true: an earlier box was still listening", got.BoxesAfterTwo, got.StillHidden)
1018 }
1019 if !got.Focused || got.Typed != "bl" {
1020 t.Errorf("after the rebuild the box has focus %v and reads %q, want true and \"bl\": the person lost their place", got.Focused, got.Typed)
1021 }
1022 if got.RebuildInputs != 0 {
1023 t.Errorf("rebuilding fired %d input events, want 0: a page refreshing options on input would loop", got.RebuildInputs)
1024 }
1025 if !got.StayedClosed || !got.KeptFocus {
1026 t.Errorf("a rebuild after a pick left the list closed=%v and focus kept=%v, want both: a closed list stays closed", got.StayedClosed, got.KeptFocus)
1027 }
1028 if !got.ExtraKept {
1029 t.Error("the page's extra row was lost when the options were rebuilt")
1030 }
1031 if got.Rows != "Blueberry|Blackberry|Other" {
1032 t.Errorf("after the rebuild \"bl\" shows %q, want Blueberry|Blackberry|Other: the rows are not the options the select now holds (and the page's extra)", got.Rows)
1033 }
1034 if got.Picked != "blackberry" {
1035 t.Errorf("Enter after the rebuilds picked %q, want blackberry", got.Picked)
1036 }
1037}
1038
1039// Near the bottom of the viewport the list opens UPWARD and fits above
1040// the box; near the top it opens below. It is re-placed when the viewport
1041// changes while open (a phone's keyboard shrinking the visual viewport,
1042// or a resize), and the keyboard order is the same either way.
1043func TestSelectOpensUpWhenThereIsNoRoomBelow(t *testing.T) {
1044 t.Parallel()
1045 var got struct {
1046 BottomUp bool `json:"bottomUp"`
1047 BottomListTop float64 `json:"bottomListTop"`
1048 BottomListEnd float64 `json:"bottomListEnd"`
1049 BottomBoxTop float64 `json:"bottomBoxTop"`
1050 TopUp bool `json:"topUp"`
1051 TopListTop float64 `json:"topListTop"`
1052 TopBoxEnd float64 `json:"topBoxEnd"`
1053 MovedUp bool `json:"movedUp"`
1054 ScrolledDown bool `json:"scrolledDown"`
1055 PanelUp bool `json:"panelUp"`
1056 NarrowUp bool `json:"narrowUp"`
1057 BroadUp bool `json:"broadUp"`
1058 DroppedOnRemoval int `json:"droppedOnRemoval"`
1059 FirstDownUp string `json:"firstDownUp"`
1060 FirstDownBelow string `json:"firstDownBelow"`
1061 }
1062 drive(t, `
1063 const frame = () => `+afterFrame+`;
1064 const input = document.querySelector('#tz-combo');
1065 const wrap = input.closest('[rst-combo]');
1066 const list = document.querySelector('#tz-listbox');
1067 const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
1068 const pin = (edge) => {
1069 wrap.style.position = 'fixed';
1070 wrap.style.left = '10px';
1071 wrap.style.width = '300px';
1072 wrap.style.top = edge === 'top' ? '10px' : '';
1073 wrap.style.bottom = edge === 'bottom' ? '10px' : '';
1074 };
1075 const out = {};
1076 pin('bottom');
1077 input.focus();
1078 input.click();
1079 await frame();
1080 await frame();
1081 out.bottomUp = wrap.hasAttribute('rst-combo-up');
1082 out.bottomListTop = list.getBoundingClientRect().top;
1083 out.bottomListEnd = list.getBoundingClientRect().bottom;
1084 out.bottomBoxTop = input.getBoundingClientRect().top;
1085 key('ArrowDown');
1086 out.firstDownUp = input.getAttribute('aria-activedescendant') || '';
1087 key('Escape');
1088 input.blur();
1089 pin('top');
1090 input.focus();
1091 input.click();
1092 await frame();
1093 await frame();
1094 out.topUp = wrap.hasAttribute('rst-combo-up');
1095 out.topListTop = list.getBoundingClientRect().top;
1096 out.topBoxEnd = input.getBoundingClientRect().bottom;
1097 key('ArrowDown');
1098 out.firstDownBelow = input.getAttribute('aria-activedescendant') || '';
1099 // Moved to the top while open: a scroll in any panel re-places it.
1100 key('Escape');
1101 pin('bottom');
1102 input.blur();
1103 input.focus();
1104 input.click();
1105 await frame();
1106 await frame();
1107 pin('top');
1108 document.querySelector('form').dispatchEvent(new Event('scroll'));
1109 await frame();
1110 await frame();
1111 out.scrolledDown = !wrap.hasAttribute('rst-combo-up');
1112 // Moved to the bottom while open: a resize re-places it.
1113 pin('bottom');
1114 window.dispatchEvent(new Event('resize'));
1115 await frame();
1116 await frame();
1117 out.movedUp = wrap.hasAttribute('rst-combo-up');
1118 key('Escape');
1119 // Opened low with one match, then cleared: the list's own height
1120 // does not fit below either way, so it opens up either way.
1121 input.blur();
1122 wrap.style.cssText = 'position:fixed;left:10px;width:300px;top:' + (window.innerHeight - 130) + 'px';
1123 input.focus();
1124 input.value = 'Europe/Lond';
1125 input.dispatchEvent(new Event('input', { bubbles: true }));
1126 await frame();
1127 await frame();
1128 out.narrowUp = wrap.hasAttribute('rst-combo-up');
1129 input.value = '';
1130 input.dispatchEvent(new Event('input', { bubbles: true }));
1131 await frame();
1132 await frame();
1133 out.broadUp = wrap.hasAttribute('rst-combo-up');
1134 key('Escape');
1135 wrap.removeAttribute('style');
1136 // Near the top of a scrolling panel that sits low on the screen:
1137 // the room above the box is outside the panel, so it opens down.
1138 const panel = document.createElement('div');
1139 panel.style.cssText = 'position:fixed;left:10px;width:320px;height:200px;overflow-y:auto;top:' + (window.innerHeight - 220) + 'px';
1140 wrap.parentNode.insertBefore(panel, wrap);
1141 panel.append(wrap, document.querySelector('#tz'));
1142 input.focus();
1143 input.click();
1144 await frame();
1145 await frame();
1146 out.panelUp = wrap.hasAttribute('rst-combo-up');
1147 key('Escape');
1148 // And a box removed with its fragment while open lets go of the
1149 // page-wide listeners at the next event.
1150 let dropped = 0;
1151 const rm = window.removeEventListener;
1152 window.removeEventListener = function (type, ...a) { if (type === 'resize') dropped++; return rm.call(this, type, ...a); };
1153 // Opened without focus (a synthetic click gives none), so removal
1154 // fires no blur here, as on a browser that never does.
1155 input.blur();
1156 input.click();
1157 await frame();
1158 panel.remove();
1159 window.dispatchEvent(new Event('resize'));
1160 window.removeEventListener = rm;
1161 out.droppedOnRemoval = dropped;
1162 return out;
1163 `, &got)
1164 if !got.BottomUp || got.BottomListTop < 0 || got.BottomListEnd > got.BottomBoxTop+1 {
1165 t.Errorf("at the bottom of the viewport the list opened up=%v spanning %.0f..%.0f with the box at %.0f, want upward, above the box and inside the viewport", got.BottomUp, got.BottomListTop, got.BottomListEnd, got.BottomBoxTop)
1166 }
1167 if got.TopUp || got.TopListTop < got.TopBoxEnd-1 {
1168 t.Errorf("at the top of the viewport the list opened up=%v starting at %.0f with the box ending at %.0f, want downward, below the box", got.TopUp, got.TopListTop, got.TopBoxEnd)
1169 }
1170 // Placement is decided by the room against the list's own height, not
1171 // by how many rows a search shows, so typing never re-places it.
1172 if !got.NarrowUp || !got.BroadUp {
1173 t.Errorf("low on the screen, one match opened up=%v and clearing the search left up=%v, want up both times: the room, not the search, decides", got.NarrowUp, got.BroadUp)
1174 }
1175 if got.PanelUp {
1176 t.Error("near the top of a low scrolling panel the list opened upward, into the part of the panel that is clipped")
1177 }
1178 if got.DroppedOnRemoval == 0 {
1179 t.Error("a box removed with its fragment while open kept its page-wide resize listener")
1180 }
1181 if !got.ScrolledDown {
1182 t.Error("moved to the top while open and a panel scrolled, the list still opened upward: a scrolling ancestor must re-place it")
1183 }
1184 if !got.MovedUp {
1185 t.Error("moved to the bottom while open and resized, the list was not re-placed upward")
1186 }
1187 if got.FirstDownUp == "" || got.FirstDownUp != got.FirstDownBelow {
1188 t.Errorf("ArrowDown highlighted %q opening up and %q opening down, want the same row: only placement flips", got.FirstDownUp, got.FirstDownBelow)
1189 }
1190}
1191
1192// Typing never rebuilds a box (its own writes must not look like the page
1193// changing the options), and reads layout at most once a frame: on a long
1194// form with several boxes, forced reflow per keystroke was what made
1195// typing slow on a phone.
1196func TestSelectTypingIsCheap(t *testing.T) {
1197 t.Parallel()
1198 var got struct {
1199 Rebuilds int `json:"rebuilds"`
1200 HiddenWrites int `json:"hiddenWrites"`
1201 Outside []string `json:"outside"`
1202 Noops []string `json:"noops"`
1203 PerFrame []int `json:"perFrame"`
1204 Searched bool `json:"searched"`
1205 }
1206 drive(t, `
1207 const frame = () => `+afterFrame+`;
1208 const out = { rebuilds: 0, perFrame: [] };
1209 const hosts = new MutationObserver((m) => {
1210 for (const r of m) for (const n of r.addedNodes) if (n.nodeType === 1 && n.hasAttribute('rst-combo')) out.rebuilds++;
1211 });
1212 hosts.observe(document.body, { childList: true, subtree: true });
1213 // Every attribute written anywhere while typing: a page whose
1214 // stylesheet has [hidden] inside a :has() pays for each one.
1215 const tzWrap = document.querySelector('#tz-combo').closest('[rst-combo]');
1216 out.hiddenWrites = 0;
1217 out.outside = [];
1218 let counting = false;
1219 const attrs = new MutationObserver((m) => {
1220 if (!counting) return;
1221 for (const r of m) {
1222 if (r.attributeName === 'hidden') out.hiddenWrites++;
1223 if (!tzWrap.contains(r.target)) out.outside.push((r.target.id || r.target.tagName) + '@' + (r.attributeName || r.type));
1224 }
1225 });
1226 attrs.observe(document.documentElement, { attributes: true, childList: true, characterData: true, subtree: true });
1227 // And every attribute written inside the box to the value it already
1228 // had: still a mutation the page's stylesheet is asked about.
1229 out.noops = [];
1230 // Records delivered to the callback (a search run in the frame) are
1231 // kept for drain, not dropped.
1232 const pendingRecords = [];
1233 const same = new MutationObserver((m) => { if (counting) pendingRecords.push(...m); });
1234 same.observe(tzWrap, { attributes: true, attributeOldValue: true, subtree: true });
1235 const drain = () => {
1236 const by = new Map();
1237 const recs = [...pendingRecords.splice(0), ...same.takeRecords()];
1238 for (const r of recs) {
1239 if (!counting) continue;
1240 const k = r.target;
1241 if (!by.has(k)) by.set(k, new Map());
1242 const m = by.get(k);
1243 if (!m.has(r.attributeName)) m.set(r.attributeName, []);
1244 m.get(r.attributeName).push(r.oldValue);
1245 }
1246 for (const [el, m] of by) for (const [name, olds] of m) olds.forEach((was, j) => {
1247 const now = j + 1 < olds.length ? olds[j + 1] : el.getAttribute(name);
1248 if (was === now) out.noops.push((el.id || el.tagName) + '@' + name);
1249 });
1250 };
1251 let reads = 0;
1252 const count = (proto, name) => {
1253 const was = proto[name];
1254 proto[name] = function (...a) { reads++; return was.apply(this, a); };
1255 };
1256 count(Element.prototype, 'getBoundingClientRect');
1257 count(Element.prototype, 'scrollIntoView');
1258 const st = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop');
1259 Object.defineProperty(Element.prototype, 'scrollTop', { configurable: true, get() { return st.get.call(this); }, set(v) { reads++; st.set.call(this, v); } });
1260 // With a told band and a floor in play, typing still reads layout at
1261 // most once a frame: placement is not redone per keystroke.
1262 document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 0, bottom: window.innerHeight - 10 } }));
1263 const bar = document.createElement('div');
1264 bar.setAttribute('data-rst-select-floor', '');
1265 bar.style.cssText = 'position:fixed;left:0;right:0;bottom:0;height:8px';
1266 document.body.append(bar);
1267 const input = document.querySelector('#tz-combo');
1268 input.focus();
1269 input.click();
1270 await frame();
1271 const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); };
1272 counting = true;
1273 for (const [a, b] of [['E', 'Eu'], ['Eur', 'Euro'], ['Europ', 'Europe'], ['Europe/', 'Europe/L']]) {
1274 reads = 0;
1275 // Two keystrokes inside one frame, as a phone keyboard sends
1276 // them: each a new query, each searched (one at once, one in the
1277 // frame).
1278 type(a);
1279 drain();
1280 type(b);
1281 drain();
1282 await frame();
1283 drain();
1284 out.perFrame.push(reads);
1285 }
1286 attrs.takeRecords();
1287 counting = false;
1288 attrs.disconnect();
1289 out.searched = document.querySelectorAll('#tz-listbox [rst-combo-option]:not([style*="none"])').length < 70;
1290 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
1291 hosts.disconnect();
1292 Object.defineProperty(Element.prototype, 'scrollTop', st);
1293 return out;
1294 `, &got)
1295 if got.HiddenWrites != 0 {
1296 t.Errorf("typing wrote the hidden attribute %d times, want 0: a page with [hidden] in a :has() pays for every write", got.HiddenWrites)
1297 }
1298 if len(got.Noops) != 0 {
1299 t.Errorf("typing rewrote %d attributes inside the box to the value they had (first: %s), want 0", len(got.Noops), got.Noops[0])
1300 }
1301 if len(got.Outside) != 0 {
1302 t.Errorf("typing mutated %d things outside the combobox (first: %s), want none", len(got.Outside), got.Outside[0])
1303 }
1304 if got.Rebuilds != 0 {
1305 t.Errorf("typing rebuilt a box %d times, want 0: the box's own writes were taken for the page changing its options", got.Rebuilds)
1306 }
1307 if !got.Searched {
1308 t.Fatal("typing did not narrow the list: the layout fence below would prove nothing")
1309 }
1310 for i, n := range got.PerFrame {
1311 if n > 1 {
1312 t.Errorf("frame %d of typing read layout %d times, want at most 1", i, n)
1313 }
1314 }
1315}
1316
1317// A rebuild in the middle of something: typing not yet searched, an Enter
1318// whose character has not arrived, a highlighted extra row, a highlighted
1319// row the page just disabled, a box whose pick is selected for replacing.
1320// Each must come through the rebuild meaning what it meant before it.
1321func TestSelectRebuildKeepsWhatIsInFlight(t *testing.T) {
1322 t.Parallel()
1323 var got struct {
1324 FlushKept string `json:"flushKept"`
1325 EnterGuarded bool `json:"enterGuarded"`
1326 ExtraOn string `json:"extraOn"`
1327 DisabledOn string `json:"disabledOn"`
1328 PickSelection string `json:"pickSelection"`
1329 }
1330 drive(t, `
1331 const frame = () => `+afterFrame+`;
1332 const select = document.querySelector('#flavour');
1333 const box = () => document.querySelector('#flavour-combo');
1334 const type = (v) => { const i = box(); i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); };
1335 const key = (k) => { const e = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); box().dispatchEvent(e); return e; };
1336 const on = () => { const a = box().getAttribute('aria-activedescendant'); return a ? document.getElementById(a).textContent : ''; };
1337 const out = {};
1338 select.innerHTML = '<option value="blueberry">Blueberry</option><option value="blackberry">Blackberry</option><option value="kiwi">Kiwi</option>';
1339 await frame();
1340
1341 // "ber", an arrow to its second row, then "berry" in the same frame —
1342 // not yet searched — and an options change in the same task. "berry" ties every row, so leaving settles nothing;
1343 // the old search's arrowed row must not ride through.
1344 box().focus();
1345 box().click();
1346 await frame();
1347 const before = select.value;
1348 type('ber');
1349 key('ArrowDown');
1350 type('berry');
1351 select.append(new Option('Bilberry', 'bilberry'));
1352 await frame();
1353 key('Tab');
1354 out.flushKept = select.value === before ? '' : select.value;
1355 key('Escape');
1356
1357 // Enter picks a row; the page's change handler then rebuilds the
1358 // options; the Enter's character arrives on the new box and must not
1359 // submit the form.
1360 select.addEventListener('change', () => select.append(new Option('Fig', 'fig')), { once: true });
1361 box().focus();
1362 box().click();
1363 await frame();
1364 type('kiwi');
1365 await frame();
1366 key('Enter');
1367 await Promise.resolve();
1368 const kp = new KeyboardEvent('keypress', { key: 'Enter', bubbles: true, cancelable: true });
1369 box().dispatchEvent(kp);
1370 out.enterGuarded = kp.defaultPrevented;
1371 box().dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
1372
1373 // The highlight on one of the page's extra rows survives a rebuild.
1374 box().focus();
1375 box().click();
1376 await frame();
1377 box().closest('[rst-combo]').rstExtras([{ label: 'Other', run: () => {} }]);
1378 key('End');
1379 select.append(new Option('Lime', 'lime'));
1380 await frame();
1381 out.extraOn = on();
1382 box().closest('[rst-combo]').rstExtras([]);
1383 key('Escape');
1384
1385 // A highlighted row the page disables is not restored as the highlight.
1386 box().focus();
1387 box().click();
1388 await frame();
1389 type('kiwi');
1390 await frame();
1391 select.querySelector('option[value="kiwi"]').disabled = true;
1392 await frame();
1393 out.disabledOn = on();
1394 key('Escape');
1395 select.querySelector('option[value="kiwi"]').disabled = false;
1396 await frame();
1397
1398 // Focusing an answered box selects its pick, so the first keystroke
1399 // replaces it; a rebuild keeps that.
1400 box().blur();
1401 box().focus();
1402 const len = box().value.length;
1403 select.append(new Option('Plum', 'plum'));
1404 await frame();
1405 out.pickSelection = box().selectionStart + '..' + box().selectionEnd + '/' + len;
1406 return out;
1407 `, &got)
1408 if got.FlushKept != "" {
1409 t.Errorf("leaving after \"berry\" committed %q: the rebuild restored the arrowed row of a search the typing had already replaced", got.FlushKept)
1410 }
1411 if !got.EnterGuarded {
1412 t.Error("an Enter that picked a row, whose change handler rebuilt the box, went on to submit the form")
1413 }
1414 if got.ExtraOn != "Other" {
1415 t.Errorf("a highlighted extra row came through a rebuild as %q, want Other: Enter would do something else", got.ExtraOn)
1416 }
1417 if got.DisabledOn == "Kiwi" {
1418 t.Error("a row the page disabled was restored as the highlight")
1419 }
1420 if !strings.HasPrefix(got.PickSelection, "0..") || strings.Split(strings.TrimPrefix(got.PickSelection, "0.."), "/")[0] != strings.Split(got.PickSelection, "/")[1] {
1421 t.Errorf("a focused answered box reads selection %s after a rebuild, want its whole pick selected", got.PickSelection)
1422 }
1423}
1424
1425// A select added after load stays native until the page asks with
1426// rst:select-scan. Asked, the new select is enhanced once; asked again,
1427// nothing is enhanced twice; and a select outside the root it names is
1428// left alone.
1429func TestSelectScanEvent(t *testing.T) {
1430 t.Parallel()
1431 var got struct {
1432 Unasked int `json:"unasked"`
1433 Asked int `json:"asked"`
1434 AskedTwice int `json:"askedTwice"`
1435 OutsideRoot int `json:"outsideRoot"`
1436 WholeDoc int `json:"wholeDoc"`
1437 PageBoxes int `json:"pageBoxes"`
1438 PageBoxesNow int `json:"pageBoxesNow"`
1439 ReplacedLabelled bool `json:"replacedLabelled"`
1440 }
1441 drive(t, `
1442 const frame = () => `+afterFrame+`;
1443 const boxes = (el) => el.querySelectorAll('[rst-combo]').length;
1444 const add = () => {
1445 const host = document.createElement('div');
1446 host.innerHTML = '<select data-rst-select name="late"><option value="a">A</option><option value="b">B</option></select>';
1447 document.querySelector('form').append(host);
1448 return host;
1449 };
1450 const out = { pageBoxes: boxes(document) };
1451 const one = add();
1452 await frame();
1453 out.unasked = boxes(one);
1454 const ask = async (root) => {
1455 document.dispatchEvent(new CustomEvent('rst:select-scan', root ? { detail: { root } } : {}));
1456 await Promise.resolve();
1457 };
1458 await ask(one);
1459 out.asked = boxes(one);
1460 await ask(one);
1461 out.askedTwice = boxes(one);
1462 const other = add();
1463 const third = add();
1464 await ask(third);
1465 out.outsideRoot = boxes(other);
1466 await ask();
1467 out.wholeDoc = boxes(other);
1468 out.pageBoxesNow = boxes(document);
1469 // Replaced and asked for in the same task: the new box is named by
1470 // the label, which the old box has handed back by then.
1471 const fresh = document.createElement('select');
1472 fresh.id = 'colour';
1473 fresh.name = 'colour';
1474 fresh.setAttribute('data-rst-select', '');
1475 fresh.innerHTML = '<option value="red">Red</option><option value="blue">Blue</option>';
1476 document.querySelector('#colour').replaceWith(fresh);
1477 await ask(document.querySelector('#colour-host'));
1478 await frame();
1479 out.replacedLabelled = !!document.querySelector('label[for="colour-combo"]');
1480 return out;
1481 `, &got)
1482 if got.Unasked != 0 {
1483 t.Errorf("a select added after load was enhanced %d times without being asked, want 0: added content stays native by default", got.Unasked)
1484 }
1485 if got.Asked != 1 || got.AskedTwice != 1 {
1486 t.Errorf("asked once the new select has %d boxes, asked twice %d, want 1 and 1", got.Asked, got.AskedTwice)
1487 }
1488 if got.OutsideRoot != 0 {
1489 t.Errorf("a select outside the named root was enhanced (%d boxes), want 0", got.OutsideRoot)
1490 }
1491 if got.WholeDoc != 1 {
1492 t.Errorf("a scan with no root left the other added select with %d boxes, want 1", got.WholeDoc)
1493 }
1494 if !got.ReplacedLabelled {
1495 t.Error("a select replaced and scanned for in the same task: the new box lost its label to the old box's teardown")
1496 }
1497 if got.PageBoxesNow != got.PageBoxes+3 {
1498 t.Errorf("the page went from %d boxes to %d after three added selects, want %d: something was enhanced twice", got.PageBoxes, got.PageBoxesNow, got.PageBoxes+3)
1499 }
1500}
1501
1502// Which way the list opens, and how tall, under Tito Go #3146's rule: up
1503// only when the list does not fit below and there is more room above;
1504// held to the room on its side, never past it. The room is the frame's
1505// own viewport, cut to a band a host tells it (rst:select-viewport), and
1506// to the top of any bar marked data-rst-select-floor below the box.
1507func TestSelectPlacement(t *testing.T) {
1508 t.Parallel()
1509 var got struct {
1510 FrameDown bool `json:"frameDown"`
1511 FrameUp bool `json:"frameUp"`
1512 FrameBack bool `json:"frameBack"`
1513 FloorUp bool `json:"floorUp"`
1514 FloorRoom float64 `json:"floorRoom"`
1515 FloorMax float64 `json:"floorMax"`
1516 AboveFloorMax float64 `json:"aboveFloorMax"`
1517 ThreeUp bool `json:"threeUp"`
1518 ThreeMax float64 `json:"threeMax"`
1519 ThreeRoom float64 `json:"threeRoom"`
1520 ClampUp bool `json:"clampUp"`
1521 ClampMax float64 `json:"clampMax"`
1522 ClampRoom float64 `json:"clampRoom"`
1523 ClampTop float64 `json:"clampTop"`
1524 BandTop float64 `json:"bandTop"`
1525 TieUp bool `json:"tieUp"`
1526 CeilingUp bool `json:"ceilingUp"`
1527 CeilingTop float64 `json:"ceilingTop"`
1528 CeilingEnd float64 `json:"ceilingEnd"`
1529 NoCeilingUp bool `json:"noCeilingUp"`
1530 BelowCeilUp bool `json:"belowCeilUp"`
1531 }
1532 drive(t, `
1533 const frame = () => `+afterFrame+`;
1534 const out = {};
1535
1536 // A framed page: its own viewport is 600px tall and the box sits in
1537 // the middle, so the list opens down. The host then says only the
1538 // top of the frame is on screen (a keyboard over the rest): the
1539 // open list is re-placed upward on receipt. Null goes back.
1540 const iframe = document.createElement('iframe');
1541 iframe.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:600px;border:0';
1542 iframe.src = '/';
1543 await new Promise((r) => { iframe.onload = r; document.body.append(iframe); });
1544 const fw = iframe.contentWindow, fd = iframe.contentDocument;
1545 await new Promise((r) => fw.requestAnimationFrame(() => fw.setTimeout(r, 0)));
1546 const ffr = () => new Promise((r) => fw.requestAnimationFrame(() => fw.setTimeout(r, 0)));
1547 const fin = fd.querySelector('#tz-combo');
1548 const fwrap = fin.closest('[rst-combo]');
1549 fwrap.style.cssText = 'position:fixed;left:10px;width:300px;top:250px';
1550 fin.focus();
1551 fin.click();
1552 await ffr(); await ffr();
1553 out.frameDown = !fwrap.hasAttribute('rst-combo-up');
1554 const fbox = fwrap.getBoundingClientRect();
1555 fd.dispatchEvent(new fw.CustomEvent('rst:select-viewport', { detail: { top: 0, bottom: fbox.bottom + 40 } }));
1556 await ffr(); await ffr();
1557 out.frameUp = fwrap.hasAttribute('rst-combo-up');
1558 fd.dispatchEvent(new fw.CustomEvent('rst:select-viewport', { detail: null }));
1559 await ffr(); await ffr();
1560 out.frameBack = !fwrap.hasAttribute('rst-combo-up');
1561 iframe.remove();
1562
1563 const input = document.querySelector('#tz-combo');
1564 const wrap = input.closest('[rst-combo]');
1565 const list = document.querySelector('#tz-listbox');
1566 const open = async () => { input.blur(); input.focus(); input.click(); await frame(); await frame(); };
1567 const close = () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
1568 // The height the list actually takes on screen, padding and borders
1569 // in: what has to fit the room.
1570 const max = () => list.getBoundingClientRect().height;
1571 const at = (top) => { wrap.style.cssText = 'position:fixed;left:10px;width:300px;top:' + top + 'px'; };
1572
1573 // A floor 60px under a box near the top of the screen: the room
1574 // below is what is above the bar.
1575 at(10);
1576 const bar = document.createElement('div');
1577 bar.setAttribute('data-rst-select-floor', '');
1578 const boxBottom = () => wrap.getBoundingClientRect().bottom;
1579 bar.style.cssText = 'position:fixed;left:0;right:0;height:40px;top:' + (boxBottom() + 60) + 'px';
1580 document.body.append(bar);
1581 await open();
1582 out.floorUp = wrap.hasAttribute('rst-combo-up');
1583 out.floorRoom = bar.getBoundingClientRect().top - boxBottom();
1584 out.floorMax = max();
1585 close();
1586 // The same bar ABOVE the box is ignored.
1587 bar.style.top = '0px';
1588 bar.style.height = '5px';
1589 at(40);
1590 await open();
1591 out.aboveFloorMax = max();
1592 close();
1593 bar.remove();
1594
1595 // Neither side fits the list: it takes the bigger side (above,
1596 // 100px) and is held to it.
1597 const h = wrap.getBoundingClientRect().height;
1598 at(200);
1599 document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 100, bottom: 200 + h + 80 } }));
1600 await open();
1601 out.threeUp = wrap.hasAttribute('rst-combo-up');
1602 out.threeMax = max();
1603 out.threeRoom = 100;
1604 close();
1605 // Neither side has room for three rows: the bigger side, clamped to
1606 // it, and never past the band.
1607 document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 200 - 46, bottom: 200 + h + 30 } }));
1608 await open();
1609 out.clampUp = wrap.hasAttribute('rst-combo-up');
1610 out.clampMax = max();
1611 out.clampRoom = 46;
1612 out.clampTop = list.getBoundingClientRect().top;
1613 out.bandTop = 200 - 46;
1614 close();
1615 // A tie goes down.
1616 document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 200 - 50, bottom: 200 + h + 50 } }));
1617 await open();
1618 out.tieUp = wrap.hasAttribute('rst-combo-up');
1619 close();
1620
1621 // A header pinned above the box (data-rst-select-ceiling) is not
1622 // room either: with 70px below and the header over all but 6px
1623 // above, the list opens down. Without the header it opens up, and
1624 // one marked below the box is ignored.
1625 at(150);
1626 document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 0, bottom: 150 + h + 70 } }));
1627 await open();
1628 out.noCeilingUp = wrap.hasAttribute('rst-combo-up');
1629 close();
1630 const header = document.createElement('div');
1631 header.setAttribute('data-rst-select-ceiling', '');
1632 header.style.cssText = 'position:fixed;left:0;right:0;top:0;height:140px';
1633 document.body.append(header);
1634 await open();
1635 out.ceilingUp = wrap.hasAttribute('rst-combo-up');
1636 out.ceilingTop = list.getBoundingClientRect().top;
1637 out.ceilingEnd = header.getBoundingClientRect().bottom;
1638 close();
1639 header.style.top = (150 + h + 10) + 'px';
1640 header.style.height = '5px';
1641 await open();
1642 out.belowCeilUp = wrap.hasAttribute('rst-combo-up');
1643 close();
1644 header.remove();
1645 document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: null }));
1646 wrap.removeAttribute('style');
1647 return out;
1648 `, &got)
1649 if !got.FrameDown || !got.FrameUp || !got.FrameBack {
1650 t.Errorf("in a frame: down %v, then told a short band up %v, then null down %v; want all true", got.FrameDown, got.FrameUp, got.FrameBack)
1651 }
1652 if got.FloorUp || got.FloorMax > got.FloorRoom || got.FloorMax < got.FloorRoom-12 {
1653 t.Errorf("a floor %.0fpx below the box: up=%v height %.0fpx, want down and held to the room above the bar", got.FloorRoom, got.FloorUp, got.FloorMax)
1654 }
1655 if got.AboveFloorMax < 200 {
1656 t.Errorf("a floor above the box cut the room below to %.0fpx: it should be ignored", got.AboveFloorMax)
1657 }
1658 if !got.ThreeUp || got.ThreeMax > got.ThreeRoom || got.ThreeMax < got.ThreeRoom-12 {
1659 t.Errorf("100px above and 80px below: up=%v height %.0fpx, want up, held to the room above", got.ThreeUp, got.ThreeMax)
1660 }
1661 if !got.ClampUp || got.ClampMax > got.ClampRoom || got.ClampTop < got.BandTop-1 {
1662 t.Errorf("46px above and 30px below: up=%v height %.0fpx, top %.0f vs band %.0f; want up, clamped to the room, never past the band", got.ClampUp, got.ClampMax, got.ClampTop, got.BandTop)
1663 }
1664 if got.TieUp {
1665 t.Error("equal room above and below opened upward: a tie goes down")
1666 }
1667 if !got.NoCeilingUp || got.CeilingUp || got.CeilingTop < got.CeilingEnd || !got.BelowCeilUp {
1668 t.Errorf("70px below the box and 146px above it: up %v with no header; under a header ending at %.0f, up %v starting at %.0f; with the header marked below the box, up %v. Want up, then down below the header, then up: a ceiling is not room, and one below the box is ignored", got.NoCeilingUp, got.CeilingEnd, got.CeilingUp, got.CeilingTop, got.BelowCeilUp)
1669 }
1670}
1671
1672// The room is cut only by the ancestors that really clip the list, by
1673// CSS's rules, row by row. A bar that scrolls sideways (overflow-x: auto,
1674// so its overflow-y computes to auto too) clips a list it is the
1675// containing block of, and not one fixed or anchored out of it: Docs'
1676// phone toolbar is such a bar, and its lists opened 0px tall. "clip" rows
1677// are a box that holds the list, so the list must end inside it; "escape"
1678// rows are one that does not, so the list keeps its own height. Either
1679// way the list is scrolled to its end and its last row must be the thing
1680// at its centre: a list taller than what really shows it fails there,
1681// which is how rastrillo's review of the fix found content-visibility
1682// and the will-change triggers missed, then containment on a box that
1683// is not there (display: contents, a plain inline), preserve-3d, and
1684// will-change written in capitals or with -webkit-, and then
1685// containment on table rows and ruby, a boxless ancestor's position,
1686// will-change: filter on an inline, motion paths, and an alias the
1687// engine lacks.
1688func TestSelectRoomIsCutOnlyByWhatClipsTheList(t *testing.T) {
1689 t.Parallel()
1690 type row struct {
1691 Name string `json:"name"`
1692 Want string `json:"want"`
1693 Height float64 `json:"height"`
1694 Bottom float64 `json:"bottom"`
1695 BarBottom float64 `json:"barBottom"`
1696 Last bool `json:"last"`
1697 Err string `json:"err"`
1698 }
1699 var got struct {
1700 Preferred float64 `json:"preferred"`
1701 Rows []row `json:"rows"`
1702 }
1703 drive(t, `
1704 const frame = () => `+afterFrame+`;
1705 const input = document.querySelector('#tz-combo');
1706 const wrap = input.closest('[rst-combo]');
1707 const list = document.querySelector('#tz-listbox');
1708 const native = document.querySelector('#tz');
1709 const home = wrap.parentNode, next = native.nextSibling;
1710 const open = async () => { input.blur(); input.focus(); input.click(); await frame(); await frame(); };
1711 const close = () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
1712 await open();
1713 const preferred = list.getBoundingClientRect().height;
1714 close();
1715
1716 // Each row: the bar's own style, the list's position, and how the
1717 // bar is built. The list is anchored under its box, as Docs anchors
1718 // it, so it sits there whichever box holds it.
1719 const bar = 'width:300px;height:200px;overflow-x:auto;';
1720 const rows = [
1721 ['fixed, plain bar', 'escape', 'fixed', bar],
1722 ['fixed, transform', 'clip', 'fixed', bar + 'transform:translateX(0)'],
1723 ['fixed, translate', 'clip', 'fixed', bar + 'translate:0px'],
1724 ['fixed, rotate', 'clip', 'fixed', bar + 'rotate:0deg'],
1725 ['fixed, scale', 'clip', 'fixed', bar + 'scale:1'],
1726 ['fixed, perspective', 'clip', 'fixed', bar + 'perspective:100px'],
1727 ['fixed, filter', 'clip', 'fixed', bar + 'filter:blur(0)'],
1728 ['fixed, backdrop-filter', 'clip', 'fixed', bar + 'backdrop-filter:blur(0)'],
1729 ['fixed, contain: layout', 'clip', 'fixed', bar + 'contain:layout'],
1730 ['fixed, contain: paint', 'clip', 'fixed', bar + 'contain:paint'],
1731 ['fixed, contain: strict', 'clip', 'fixed', bar + 'contain:strict'],
1732 ['fixed, contain: content', 'clip', 'fixed', bar + 'contain:content'],
1733 ['fixed, content-visibility: auto', 'clip', 'fixed', bar + 'content-visibility:auto'],
1734 ['fixed, will-change: transform', 'clip', 'fixed', bar + 'will-change:transform'],
1735 ['fixed, will-change: translate', 'clip', 'fixed', bar + 'will-change:translate'],
1736 ['fixed, will-change: rotate', 'clip', 'fixed', bar + 'will-change:rotate'],
1737 ['fixed, will-change: scale', 'clip', 'fixed', bar + 'will-change:scale'],
1738 ['fixed, will-change: perspective', 'clip', 'fixed', bar + 'will-change:perspective'],
1739 ['fixed, will-change: filter', 'clip', 'fixed', bar + 'will-change:filter'],
1740 ['fixed, will-change: backdrop-filter', 'clip', 'fixed', bar + 'will-change:backdrop-filter'],
1741 ['fixed, will-change: contain', 'clip', 'fixed', bar + 'will-change:contain'],
1742 ['fixed, will-change: position', 'escape', 'fixed', bar + 'will-change:position'],
1743 ['fixed, will-change: scroll-position', 'escape', 'fixed', bar + 'will-change:scroll-position'],
1744 ['fixed, container-type: inline-size', 'escape', 'fixed', bar + 'container-type:inline-size'],
1745 ['fixed, container-type: size', 'escape', 'fixed', bar + 'container-type:size'],
1746 ['fixed, contain: paint, overflow visible', 'clip', 'fixed', 'width:300px;height:200px;contain:paint'],
1747 ['fixed, content-visibility: auto, overflow visible', 'clip', 'fixed', 'width:300px;height:200px;content-visibility:auto'],
1748 ['absolute, held by its own box', 'clip', 'absolute', bar],
1749 ['absolute, held outside the bar', 'escape', 'absolute static', bar],
1750 ['absolute, bar positioned', 'clip', 'absolute static', bar + 'position:relative'],
1751 ['absolute, will-change: position', 'clip', 'absolute static', bar + 'will-change:position'],
1752 ['fixed, display: contents with contain: paint', 'escape', 'fixed', 'display:contents;contain:paint'],
1753 ['fixed, display: contents with content-visibility: auto', 'escape', 'fixed', 'display:contents;content-visibility:auto'],
1754 ['absolute, held by its own box, in display: contents with contain: paint', 'escape', 'absolute', 'display:contents;contain:paint'],
1755 ['fixed, display: inline with contain: paint', 'escape', 'fixed', 'display:inline;contain:paint'],
1756 // inline flow-root list-item is an inline-block with a marker:
1757 // atomic, so all of it applies; inline list-item is not, which
1758 // rastrillo's review of the fix found last. WebKit has neither
1759 // and draws a block.
1760 ['fixed, display: inline list-item with contain: paint', 'escape', 'fixed', 'display:inline list-item;contain:paint'],
1761 ['fixed, display: inline flow-root list-item with contain: paint', 'clip', 'fixed', 'width:300px;height:200px;display:inline flow-root list-item;contain:paint'],
1762 ['fixed, display: inline flow-root list-item with transform', 'clip', 'fixed', bar + 'display:inline flow-root list-item;transform:translateX(0)'],
1763 ['absolute, held by its own box, in display: inline flow-root list-item', 'clip', 'absolute', bar + 'display:inline flow-root list-item'],
1764 ['fixed, transform-style: preserve-3d', 'clip', 'fixed', bar + 'transform-style:preserve-3d'],
1765 ['fixed, transform-style: preserve-3d, overflow: clip', 'clip', 'fixed', 'width:300px;height:200px;overflow:clip;transform-style:preserve-3d'],
1766 ['fixed, will-change: transform-style', 'clip', 'fixed', bar + 'will-change:transform-style'],
1767 ['fixed, will-change: TrAnSfOrM', 'clip', 'fixed', bar + 'will-change:TrAnSfOrM'],
1768 ['fixed, will-change: -webkit-transform', 'clip', 'fixed', bar + 'will-change:-webkit-transform'],
1769 ['fixed, will-change: -webkit-perspective', 'clip', 'fixed', bar + 'will-change:-webkit-perspective'],
1770 ['fixed, will-change: -webkit-filter', 'clip', 'fixed', bar + 'will-change:-webkit-filter'],
1771 // Only an alias the engine has: Chromium has no -webkit-backdrop-filter.
1772 ['fixed, will-change: -webkit-backdrop-filter', CSS.supports('-webkit-backdrop-filter', 'initial') ? 'clip' : 'escape', 'fixed', bar + 'will-change:-webkit-backdrop-filter'],
1773 ['fixed, in a modal dialog in a transformed clip', 'escape', 'fixed', 'dialog'],
1774 ['fixed, in a popover in a transformed clip', 'escape', 'fixed', 'popover'],
1775 ['absolute, slotted into a shadow scroller', 'clip', 'absolute', 'shadow'],
1776 // Markup rows: the box goes in [data-in]; [data-held], or else the
1777 // first element, is what must hold a "clip" list.
1778 // Containment skips table rows, row groups and non-atomic ruby;
1779 // a cell keeps it.
1780 ['fixed, table row with contain: paint', 'escape', 'fixed', '<table><tbody><tr style="contain:paint"><td data-in></td></tr></tbody></table>'],
1781 ['absolute, held by its own box, in a table row with contain: paint', 'escape', 'absolute', '<table><tbody><tr style="contain:paint"><td data-in></td></tr></tbody></table>'],
1782 ['fixed, row group with content-visibility: auto', 'escape', 'fixed', '<table><tbody style="content-visibility:auto"><tr><td data-in></td></tr></tbody></table>'],
1783 ['fixed, table row with contain: layout, in a scroller', 'escape', 'fixed', '<div style="' + bar + '"><table><tbody><tr style="contain:layout"><td data-in></td></tr></tbody></table></div>'],
1784 ['fixed, table cell with contain: paint', 'clip', 'fixed', '<table><tbody><tr><td data-in data-held style="contain:paint;height:200px;vertical-align:top"></td></tr></tbody></table>'],
1785 ['fixed, display: ruby with contain: paint', 'escape', 'fixed', '<div style="display:ruby;contain:paint" data-in></div>'],
1786 ['fixed, display: ruby-text with contain: paint', 'escape', 'fixed', '<div style="display:ruby-text;contain:paint" data-in></div>'],
1787 // A boxless ancestor's position is not the chain's.
1788 ['absolute, held by its own box, in display: contents with position: fixed, in a scroller', 'clip', 'absolute', '<div style="' + bar + 'overflow:hidden"><div style="display:contents;position:fixed" data-in></div></div>'],
1789 ['absolute, held by its own box, in display: contents with position: absolute, in a scroller', 'clip', 'absolute', '<div style="' + bar + 'overflow:hidden"><div style="display:contents;position:absolute" data-in></div></div>'],
1790 // will-change: filter applies where filter does, inlines too.
1791 ['fixed, inline with will-change: filter, in a scroller', 'clip', 'fixed', '<div style="' + bar + 'overflow:hidden"><span style="will-change:filter" data-in></span></div>'],
1792 ['fixed, inline with will-change: backdrop-filter, in a scroller', 'clip', 'fixed', '<div style="' + bar + 'overflow:hidden"><span style="will-change:backdrop-filter" data-in></span></div>'],
1793 // A motion path transforms, and its will-change names, the
1794 // offset shorthand among them, hold.
1795 ['fixed, offset-path', 'clip', 'fixed', bar + 'offset-path:path("M 0 0 L 100 0");offset-position:auto;offset-anchor:0 0'],
1796 ['fixed, will-change: offset-path', 'clip', 'fixed', bar + 'will-change:offset-path'],
1797 ['fixed, will-change: offset', 'clip', 'fixed', bar + 'will-change:offset'],
1798 ];
1799 const out = [];
1800 for (const [name, want, position, style] of rows) {
1801 const r = { name, want };
1802 try {
1803 // Positioned and outside every bar: what an absolute list with a
1804 // static box is held by when the bar does not hold it.
1805 const outer = document.createElement('div');
1806 outer.style.cssText = 'position:absolute;left:0;top:100px;width:320px;height:600px';
1807 document.body.append(outer);
1808 let held, closeTop = () => {};
1809 if (style === 'dialog' || style === 'popover') {
1810 const clipper = document.createElement('div');
1811 clipper.style.cssText = 'transform:translateX(0);overflow:hidden;width:300px;height:60px';
1812 held = document.createElement(style === 'dialog' ? 'dialog' : 'div');
1813 held.style.cssText = 'margin:0;padding:0;border:0;inset:auto;left:0;top:100px;width:300px;height:200px;overflow:auto';
1814 clipper.append(held);
1815 outer.append(clipper);
1816 held.append(wrap, native);
1817 if (style === 'dialog') { held.showModal(); closeTop = () => held.close(); }
1818 else { held.setAttribute('popover', 'manual'); held.showPopover(); closeTop = () => held.hidePopover(); }
1819 } else if (style === 'shadow') {
1820 const host = document.createElement('div');
1821 outer.append(host);
1822 host.attachShadow({ mode: 'open' }).innerHTML = '<div style="width:300px;height:200px;overflow:auto"><slot></slot></div>';
1823 host.append(wrap, native);
1824 held = host.shadowRoot.firstChild;
1825 } else if (style[0] === '<') {
1826 outer.insertAdjacentHTML('beforeend', style);
1827 held = outer.querySelector('[data-held]') || outer.firstElementChild;
1828 outer.querySelector('[data-in]').append(wrap, native);
1829 } else {
1830 held = document.createElement('div');
1831 held.style.cssText = style;
1832 outer.append(held);
1833 held.append(wrap, native);
1834 }
1835 wrap.style.cssText = 'display:block;width:200px;anchor-name:--rst-room' + (position.endsWith('static') ? ';position:static' : '');
1836 list.style.cssText = 'position:' + position.split(' ')[0] + ';inset:auto;position-anchor:--rst-room;top:anchor(bottom);left:anchor(left);width:200px';
1837 await open();
1838 const lr = list.getBoundingClientRect();
1839 r.height = lr.height;
1840 r.bottom = lr.bottom;
1841 r.barBottom = held.getBoundingClientRect().bottom;
1842 list.scrollTop = list.scrollHeight;
1843 await frame();
1844 const lis = [...list.querySelectorAll('[rst-combo-option]')].filter((li) => !li.hidden);
1845 const last = lis[lis.length - 1].getBoundingClientRect();
1846 const hit = document.elementFromPoint(last.left + last.width / 2, last.top + last.height / 2);
1847 r.last = !!hit && lis[lis.length - 1].contains(hit);
1848 close();
1849 closeTop();
1850 outer.remove();
1851 } catch (e) {
1852 r.err = String(e);
1853 }
1854 list.removeAttribute('style');
1855 wrap.removeAttribute('style');
1856 home.insertBefore(wrap, next);
1857 home.insertBefore(native, next);
1858 out.push(r);
1859 }
1860 return { preferred, rows: out };
1861 `, &got)
1862 if got.Preferred < 100 {
1863 t.Fatalf("the list opened %.0fpx tall on its own, too short to tell anything apart", got.Preferred)
1864 }
1865 for _, r := range got.Rows {
1866 switch {
1867 case r.Err != "":
1868 t.Errorf("%s: %s", r.Name, r.Err)
1869 case r.Want == "escape" && r.Height < got.Preferred-1:
1870 t.Errorf("%s: the list opened %.0fpx tall, want its own %.0fpx: nothing here clips it", r.Name, r.Height, got.Preferred)
1871 case r.Want == "clip" && r.Bottom > r.BarBottom+1:
1872 t.Errorf("%s: the list ends at %.0f, past the end of the box that clips it at %.0f", r.Name, r.Bottom, r.BarBottom)
1873 case !r.Last:
1874 t.Errorf("%s: scrolled to its end, the list's last row is not what shows at its place: the list is taller than what shows it", r.Name)
1875 }
1876 }
1877 if len(got.Rows) != 64 {
1878 t.Errorf("ran %d rows, want 64", len(got.Rows))
1879 }
1880}
1881
1882// data-rst-class: a row wears its option's classes, the closed box wears
1883// the pick's while it shows the pick, takes them off once somebody types
1884// (their words are a search, not the pick), and wears the new pick's
1885// once one is chosen. An option without the attribute adds nothing, and
1886// is-active, the highlight's own class, is never taken from an option.
1887func TestSelectOptionClassStylesRowAndBox(t *testing.T) {
1888 t.Parallel()
1889 var got struct {
1890 Rows []string `json:"rows"`
1891 Closed string `json:"closed"`
1892 External []string `json:"external"`
1893 Typing string `json:"typing"`
1894 Escaped string `json:"escaped"`
1895 Chosen string `json:"chosen"`
1896 Plain string `json:"plain"`
1897 Submitted string `json:"submitted"`
1898 }
1899 drive(t, `
1900 const frame = () => `+afterFrame+`;
1901 const host = document.createElement('div');
1902 host.innerHTML = '<label for="face">Face</label><select data-rst-select id="face" name="face">' +
1903 '<option value="serif" data-rst-class="f-serif big" selected>Serif</option>' +
1904 '<option value="mono" data-rst-class="f-mono is-active">Mono</option>' +
1905 '<option value="none">None</option></select>';
1906 document.querySelector('form').append(host);
1907 document.dispatchEvent(new CustomEvent('rst:select-scan', { detail: { root: host } }));
1908 await Promise.resolve();
1909 const input = document.querySelector('#face-combo');
1910 const native = document.querySelector('#face');
1911 const own = () => [...input.classList].sort().join(' ');
1912 const out = {};
1913 out.rows = [...document.querySelectorAll('#face-listbox [role=option]')].map((li) => [...li.classList].sort().join(' '));
1914 out.closed = own();
1915 // The page changing the pick, with nobody typing: each pick's own
1916 // classes, and none left over from the one before.
1917 const set = (v) => { native.value = v; native.dispatchEvent(new Event('change', { bubbles: true })); return own(); };
1918 out.external = [set('mono'), set('none'), set('serif')];
1919 input.focus();
1920 input.value = 'mo';
1921 input.dispatchEvent(new Event('input', { bubbles: true }));
1922 await frame();
1923 out.typing = own();
1924 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
1925 await frame();
1926 out.escaped = own();
1927 input.value = 'mono';
1928 input.dispatchEvent(new Event('input', { bubbles: true }));
1929 await frame();
1930 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
1931 await frame();
1932 out.chosen = own();
1933 out.submitted = document.querySelector('#face').value;
1934 document.querySelector('#face').value = 'none';
1935 document.querySelector('#face').dispatchEvent(new Event('change', { bubbles: true }));
1936 input.focus();
1937 input.value = 'none';
1938 input.dispatchEvent(new Event('input', { bubbles: true }));
1939 await frame();
1940 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
1941 await frame();
1942 out.plain = own();
1943 return out;
1944 `, &got)
1945 if strings.Join(got.Rows, "|") != "big f-serif|f-mono|" {
1946 t.Errorf("rows wear %q, want each its own option's classes and the plain row none", got.Rows)
1947 }
1948 if got.Closed != "big f-serif" {
1949 t.Errorf("the closed box showing Serif wears %q, want %q", got.Closed, "big f-serif")
1950 }
1951 if strings.Join(got.External, "|") != "f-mono||big f-serif" {
1952 t.Errorf("the page picking Mono, None, then Serif left the box wearing %q, want each pick's classes and nothing else", got.External)
1953 }
1954 if got.Typing != "" {
1955 t.Errorf("the box wears %q while somebody types, want nothing: a query is not the pick", got.Typing)
1956 }
1957 if got.Escaped != "big f-serif" {
1958 t.Errorf("Escape put the pick back but the box wears %q, want %q", got.Escaped, "big f-serif")
1959 }
1960 if got.Submitted != "mono" || got.Chosen != "f-mono" {
1961 t.Errorf("after choosing Mono the select holds %q and the box wears %q, want mono and %q", got.Submitted, got.Chosen, "f-mono")
1962 }
1963 if got.Plain != "" {
1964 t.Errorf("a pick with no data-rst-class leaves the box wearing %q, want nothing", got.Plain)
1965 }
1966}
1967
1968// A select the page cannot change is a box that cannot change it. The
1969// box takes the select's disabled state and follows it; a row is
1970// refused while the select is disabled, whichever way it was reached;
1971// and a select inside a disabled <fieldset>, which sets no attribute,
1972// is read as disabled at the moment of the choice. Each leg tries every
1973// way of choosing and reads the select's value and what the form posts.
1974func TestADisabledSelectCannotBeChangedThroughTheBox(t *testing.T) {
1975 t.Parallel()
1976 var got struct {
1977 Before string `json:"before"`
1978 BoxDisabled bool `json:"boxDisabled"`
1979 ListOpen bool `json:"listOpen"`
1980 ValueDisabled string `json:"valueDisabled"`
1981 BoxEnabled bool `json:"boxEnabled"`
1982 PostedAfter string `json:"postedAfter"`
1983 ChangedOnce string `json:"changedOnce"`
1984 InFieldset bool `json:"inFieldset"`
1985 ValueInSet string `json:"valueInSet"`
1986 ListOpenInSet bool `json:"listOpenInSet"`
1987 PostedOutOfSet string `json:"postedOutOfSet"`
1988 }
1989 drive(t, `
1990 const frame = () => `+afterFrame+`;
1991 const out = {};
1992 const form = document.querySelector('form');
1993 const posted = () => new FormData(form).get('colour');
1994 const select = document.querySelector('#colour');
1995 const input = document.querySelector('#colour-combo');
1996 const list = document.querySelector('#colour-listbox');
1997 // Every way of choosing Green: focus and click (a disabled box
1998 // refuses both natively), words and Enter, and a row.
1999 const tryGreen = () => {
2000 input.focus();
2001 input.click();
2002 input.value = 'green';
2003 input.dispatchEvent(new Event('input', { bubbles: true }));
2004 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
2005 const row = [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === 'Green');
2006 row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
2007 row.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
2008 input.blur();
2009 };
2010 out.before = posted();
2011 select.disabled = true;
2012 await frame();
2013 out.boxDisabled = input.disabled;
2014 tryGreen();
2015 await frame();
2016 out.listOpen = !list.hidden;
2017 out.valueDisabled = select.value;
2018 select.disabled = false;
2019 await frame();
2020 out.boxEnabled = !input.disabled;
2021 out.postedAfter = posted();
2022 // Enabled again, the same moves choose Green: the legs above
2023 // were refused by the state, not by a box that cannot choose.
2024 tryGreen();
2025 await frame();
2026 out.changedOnce = posted();
2027 // A disabled fieldset around the select sets no attribute.
2028 const fs = document.createElement('fieldset');
2029 const host = document.querySelector('#colour-host');
2030 host.replaceWith(fs);
2031 fs.append(host);
2032 await frame();
2033 fs.disabled = true;
2034 await frame();
2035 out.inFieldset = input.matches(':disabled');
2036 const blue = [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === 'Blue');
2037 input.value = 'blue';
2038 input.dispatchEvent(new Event('input', { bubbles: true }));
2039 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
2040 blue.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
2041 blue.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
2042 await frame();
2043 out.listOpenInSet = !list.hidden;
2044 out.valueInSet = select.value;
2045 fs.disabled = false;
2046 await frame();
2047 out.postedOutOfSet = posted();
2048 return out;
2049 `, &got)
2050 if got.Before != "red" {
2051 t.Fatalf("the colour select starts at %q, want red", got.Before)
2052 }
2053 if !got.BoxDisabled {
2054 t.Error("the select was disabled and its box was not")
2055 }
2056 if got.ListOpen || got.ValueDisabled != "red" || got.PostedAfter != "red" {
2057 t.Errorf("disabled, the box let Green through: list open %v, value %q, posted once enabled %q; want closed, red, red", got.ListOpen, got.ValueDisabled, got.PostedAfter)
2058 }
2059 if !got.BoxEnabled {
2060 t.Error("the select was enabled again and its box stayed disabled")
2061 }
2062 if got.ChangedOnce != "green" {
2063 t.Errorf("enabled again, the same moves posted %q, want green: the box could not choose at all", got.ChangedOnce)
2064 }
2065 if !got.InFieldset {
2066 t.Fatal("inside a disabled fieldset, the box does not read as :disabled; the fieldset leg is not set up")
2067 }
2068 if got.ListOpenInSet || got.ValueInSet != "green" || got.PostedOutOfSet != "green" {
2069 t.Errorf("in a disabled fieldset, the box let Blue through: list open %v, value %q, posted once enabled %q; want closed, green, green", got.ListOpenInSet, got.ValueInSet, got.PostedOutOfSet)
2070 }
2071}
2072
2073// An option under a disabled <optgroup> is disabled with it: the native
2074// select will not pick it, so its row is marked disabled and refused,
2075// by words and Enter and by a click. A sibling option under an enabled
2076// group still chooses, so the refusal is the group's, not the box's.
2077func TestAnOptionUnderADisabledGroupCannotBeChosen(t *testing.T) {
2078 t.Parallel()
2079 var got struct {
2080 Before string `json:"before"`
2081 RowDisabled string `json:"rowDisabled"`
2082 AfterBill string `json:"afterBill"`
2083 AfterRet string `json:"afterRet"`
2084 Posted string `json:"posted"`
2085 }
2086 drive(t, `
2087 const frame = () => `+afterFrame+`;
2088 const out = {};
2089 const form = document.querySelector('form');
2090 const select = document.querySelector('#dept');
2091 out.before = select.value;
2092 select.querySelector('optgroup[label="Support"]').disabled = true;
2093 await frame();
2094 await frame();
2095 const input = document.querySelector('#dept-combo');
2096 const list = document.querySelector('#dept-listbox');
2097 const row = (text) => [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === text);
2098 out.rowDisabled = row('Billing').getAttribute('aria-disabled') || '';
2099 const choose = (words, text) => {
2100 input.focus();
2101 input.value = words;
2102 input.dispatchEvent(new Event('input', { bubbles: true }));
2103 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
2104 const li = row(text);
2105 li.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
2106 li.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
2107 input.blur();
2108 };
2109 choose('bil', 'Billing');
2110 await frame();
2111 out.afterBill = select.value;
2112 choose('ret', 'Retail');
2113 await frame();
2114 out.afterRet = select.value;
2115 out.posted = new FormData(form).get('dept');
2116 return out;
2117 `, &got)
2118 if got.Before != "acc" {
2119 t.Fatalf("the department select starts at %q, want acc", got.Before)
2120 }
2121 if got.RowDisabled != "true" {
2122 t.Errorf("Billing's row is aria-disabled=%q under a disabled group, want true", got.RowDisabled)
2123 }
2124 if got.AfterBill != "acc" {
2125 t.Errorf("Billing, under a disabled group, was chosen: the select is %q, want acc", got.AfterBill)
2126 }
2127 if got.AfterRet != "ret" || got.Posted != "ret" {
2128 t.Errorf("Retail, under an enabled group, was not chosen: the select is %q and the form posts %q, want ret", got.AfterRet, got.Posted)
2129 }
2130}
2131
2132// A form reset puts the select back to its default without a change
2133// event, and the box, whose own default is empty, resets to blank: the
2134// page showed nothing while the form posted the default. After an
2135// uncancelled reset, through form.reset() or a reset button, the box
2136// shows the select's value again and an open list closes; a reset a
2137// listener cancelled leaves the pick in place.
2138func TestAFormResetRepaintsTheBox(t *testing.T) {
2139 t.Parallel()
2140 var got struct {
2141 Picked string `json:"picked"`
2142 PostedPick string `json:"postedPick"`
2143 ShownReset string `json:"shownReset"`
2144 PostedReset string `json:"postedReset"`
2145 ListReset bool `json:"listReset"`
2146 ShownButton string `json:"shownButton"`
2147 PostedButton string `json:"postedButton"`
2148 ShownKept string `json:"shownKept"`
2149 PostedKept string `json:"postedKept"`
2150 }
2151 drive(t, `
2152 const frame = () => `+afterFrame+`;
2153 const tick = () => new Promise((r) => setTimeout(r, 0));
2154 const out = {};
2155 const form = document.querySelector('form');
2156 const posted = () => new FormData(form).get('colour');
2157 const input = document.querySelector('#colour-combo');
2158 const list = document.querySelector('#colour-listbox');
2159 const pick = (words) => {
2160 input.focus();
2161 input.value = words;
2162 input.dispatchEvent(new Event('input', { bubbles: true }));
2163 input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
2164 input.blur();
2165 };
2166 pick('green');
2167 out.picked = input.value;
2168 out.postedPick = posted();
2169 // Open the list, then reset through the API.
2170 input.focus();
2171 input.click();
2172 await frame();
2173 form.reset();
2174 await tick();
2175 await frame();
2176 out.listReset = !list.hidden;
2177 out.shownReset = input.value;
2178 out.postedReset = posted();
2179 input.blur();
2180 // Again through a reset button.
2181 pick('blue');
2182 const button = document.createElement('button');
2183 button.type = 'reset';
2184 form.append(button);
2185 button.click();
2186 await tick();
2187 await frame();
2188 out.shownButton = input.value;
2189 out.postedButton = posted();
2190 // A cancelled reset changes nothing.
2191 pick('green');
2192 form.addEventListener('reset', (e) => e.preventDefault(), { once: true });
2193 form.reset();
2194 await tick();
2195 await frame();
2196 out.shownKept = input.value;
2197 out.postedKept = posted();
2198 return out;
2199 `, &got)
2200 if got.Picked != "Green" || got.PostedPick != "green" {
2201 t.Fatalf("picking Green shows %q and posts %q; the drive's setup did not take", got.Picked, got.PostedPick)
2202 }
2203 if got.ListReset || got.ShownReset != "Red" || got.PostedReset != "red" {
2204 t.Errorf("after form.reset(): list open %v, box shows %q, form posts %q; want closed, Red, red", got.ListReset, got.ShownReset, got.PostedReset)
2205 }
2206 if got.ShownButton != "Red" || got.PostedButton != "red" {
2207 t.Errorf("after a reset button: box shows %q, form posts %q; want Red, red", got.ShownButton, got.PostedButton)
2208 }
2209 if got.ShownKept != "Green" || got.PostedKept != "green" {
2210 t.Errorf("after a cancelled reset: box shows %q, form posts %q; want Green, green", got.ShownKept, got.PostedKept)
2211 }
2212}
2213