//go:build browser package check // Browser drives for select.js's converged behaviour, ported from Tito // Go's searchselect fences (internal/instance/ui_test.go) by way of // rastrillo's ui/select_browser_test.go. Each drive runs its steps // inside one page script and reads the result back as JSON, because // what is being fenced is timing inside a frame and the exact DOM // writes a keystroke makes — things a step-per-round-trip drive would // blur. driveSelectFilter, the type-to-filter-and-commit journey, is // standalone so the release build's tests can run it against the // stripped scripts on a rig of their own built from selectFilterPages. import ( "context" "encoding/json" "fmt" "html" "strings" "testing" "time" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/chromedp" "amadan.net/rastrillo/fiesta/internal/harness" ) // afterFrame resolves once the next frame has been drawn and the task // after it has run: where a keystroke's deferred search has landed. const afterFrame = `new Promise((r) => requestAnimationFrame(() => setTimeout(() => r(0), 0)))` // zones is a time-zone list long enough (over fifty rows) that a // keystroke moving every row would show, with "Europe/Lo…" and "Dubl" // among them. var zones = []string{ "Africa/Abidjan", "Africa/Accra", "Africa/Algiers", "Africa/Cairo", "Africa/Casablanca", "Africa/Johannesburg", "Africa/Lagos", "Africa/Nairobi", "Africa/Tunis", "America/Anchorage", "America/Bogota", "America/Buenos_Aires", "America/Caracas", "America/Chicago", "America/Denver", "America/Halifax", "America/Havana", "America/Lima", "America/Los_Angeles", "America/Mexico_City", "America/New_York", "America/Phoenix", "America/Santiago", "America/Sao_Paulo", "America/Toronto", "America/Vancouver", "Asia/Bangkok", "Asia/Dhaka", "Asia/Dubai", "Asia/Hong_Kong", "Asia/Jakarta", "Asia/Jerusalem", "Asia/Karachi", "Asia/Kathmandu", "Asia/Kolkata", "Asia/Manila", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", "Asia/Tehran", "Asia/Tokyo", "Atlantic/Azores", "Atlantic/Reykjavik", "Australia/Adelaide", "Australia/Brisbane", "Australia/Perth", "Australia/Sydney", "Europe/Amsterdam", "Europe/Athens", "Europe/Berlin", "Europe/Brussels", "Europe/Bucharest", "Europe/Dublin", "Europe/Helsinki", "Europe/Istanbul", "Europe/Lisbon", "Europe/London", "Europe/Madrid", "Europe/Moscow", "Europe/Oslo", "Europe/Paris", "Europe/Prague", "Europe/Rome", "Europe/Stockholm", "Europe/Vienna", "Europe/Warsaw", "Europe/Zurich", "Pacific/Auckland", "Pacific/Fiji", "Pacific/Honolulu", } // flag is a country's regional-indicator pair, the glyph data-rst-lead // carries. func flag(iso string) string { var b strings.Builder for _, r := range iso { b.WriteRune(0x1F1E6 + (r - 'A')) } return b.String() } // selectFilterPages is the one page the drives below open, at "/": // select.js, the stylesheets and one form holding the selects. The // fixture server (sizingMux) serves the scripts and stylesheets it // links; a release rig serves the same page through sizingMuxWith. func selectFilterPages(t *testing.T) map[string]string { t.Helper() var body strings.Builder body.WriteString(`` + `select` + `` + `` + `
`) // A time zone, no blank: the keeps-pace drive. body.WriteString(`` + ``) // A REQUIRED country question: pinned countries above a divider, then // the rest, under a disabled "Choose one". pinned := []string{"AE", "SA", "GB", "IN"} byISO := map[string]countryRow{"SA": {"SA", "Saudi Arabia", "Saudi-Arabien", "966"}} for _, c := range countries { byISO[c.iso] = c } option := func(c countryRow) { fmt.Fprintf(&body, ``, c.iso, flag(c.iso), c.iso, c.code, html.EscapeString(c.en)) } body.WriteString(`` + ``) // A phone number's country: a compact box, a MARKED prompt. body.WriteString(``) // An optional select whose blank is a real answer — until something // makes the select required. body.WriteString(`` + ``) // A grouped select, for the extra rows a page adds under a search. body.WriteString(`` + ``) // A select the page replaces outright, and one whose options it // rebuilds, each in a host of its own. body.WriteString(`
` + `
`) body.WriteString(`
` + `
`) body.WriteString(`
`) return map[string]string{"/": body.String()} } // drive opens the page on a rig of its own and runs one page script, // decoding its result. func drive(t *testing.T, script string, out any) { t.Helper() driveScript(t, sizingRig(t, false, selectFilterPages(t)), script, out) } // driveScript is drive against a rig the caller built from // selectFilterPages. func driveScript(t *testing.T, rig *harness.Rig, script string, out any) { t.Helper() ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) defer cancel() var raw string if err := chromedp.Run(ctx, // A page in a background tab gets no focus events from focus(), // so a handler that reacts to focus could not be observed at all. emulation.SetFocusEmulationEnabled(true), chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible(`#tz-combo`, chromedp.ByQuery), chromedp.Evaluate(`(async () => JSON.stringify(await (async () => {`+script+`})()))()`, &raw, awaitPromise), ); err != nil { t.Fatalf("drive failed: %v", err) } if err := json.Unmarshal([]byte(raw), out); err != nil { t.Fatalf("decode %q: %v", raw, err) } } // driveSelectFilter is TestSelectKeepsPaceWithTyping's body, the // type-to-filter-and-commit journey, against any rig that serves // selectFilterPages. A burst of typing is searched once at the time // and once in the frame, not once per input event; Enter typed in the // same task acts on the words typed; a keystroke moves only the rows // it shows and writes a row's hidden state only when it changes; and // clearing puts every row back in its own place. func driveSelectFilter(t *testing.T, rig *harness.Rig) { t.Helper() var got struct { SyncSearches int `json:"syncSearches"` FrameSearches int `json:"frameSearches"` Picked string `json:"picked"` Rows int `json:"rows"` Moves int `json:"moves"` HiddenMoved []string `json:"hiddenMoved"` NoopWrites []string `json:"noopWrites"` OutOfPlace []string `json:"outOfPlace"` Shown int `json:"shown"` } driveScript(t, rig, ` const input = document.querySelector('#tz-combo'); const select = document.querySelector('#tz'); const list = document.querySelector('#tz-listbox'); const frame = () => `+afterFrame+`; const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; const out = { hiddenMoved: [], noopWrites: [], outOfPlace: [], moves: 0 }; input.focus(); input.click(); await frame(); const seen = []; const mo = new MutationObserver((m) => seen.push(...m)); mo.observe(list, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'], attributeOldValue: true }); // A search shows itself as rows shown and hidden. Four inputs in one // task: the first is searched at once (rows change now) and the rest // once, in the frame (rows change again then). Had every input been // searched at once, the frame would have nothing left to do. Counted // as batches, since the highlight is no longer rewritten when it does // not move. mo.takeRecords(); type('D'); out.syncSearches = mo.takeRecords().length ? 1 : 0; for (const v of ['Du', 'Dub', 'Dubl']) type(v); out.syncSearches += mo.takeRecords().length ? 1 : 0; await frame(); out.frameSearches = (seen.length + mo.takeRecords().length) ? 1 : 0; seen.length = 0; type('Dubl'); type('Lond'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); out.picked = select.value; await frame(); mo.takeRecords(); seen.length = 0; input.click(); await frame(); mo.takeRecords(); seen.length = 0; const word = 'Europe/Lo'; for (let i = 1; i <= word.length; i++) { type(word.slice(0, i)); await frame(); const recs = [...seen, ...mo.takeRecords()]; seen.length = 0; const writes = new Map(); for (const r of recs) { if (r.type === 'childList') { for (const n of r.addedNodes) { out.moves++; if (n.style.display === 'none') out.hiddenMoved.push(word.slice(0, i) + ': ' + (n.id || n.tagName)); } } else { if (!writes.has(r.target)) writes.set(r.target, []); writes.get(r.target).push((r.oldValue || '').includes('none')); } } for (const [el, olds] of writes) { olds.forEach((was, j) => { const now = j + 1 < olds.length ? olds[j + 1] : el.style.display === 'none'; if (was === now) out.noopWrites.push(word.slice(0, i) + ': ' + (el.id || el.tagName)); }); } } mo.disconnect(); out.shown = list.querySelectorAll('[rst-combo-option]:not([style*="none"])').length; type(''); await frame(); const rows = [...list.querySelectorAll('[rst-combo-option]')]; out.rows = rows.length; rows.forEach((li, i) => { if (li.id !== 'tz-listbox-' + i) out.outOfPlace.push(i + ': ' + li.id); }); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); return out; `, &got) if got.SyncSearches != 1 || got.FrameSearches != 1 { 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) } if got.Picked != "Europe/London" { 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) } if len(got.HiddenMoved) > 0 { 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]) } if len(got.NoopWrites) > 0 { t.Errorf("%d hidden-state writes set a row to what it already was (first: %v)", len(got.NoopWrites), got.NoopWrites[0]) } // The fence above is only a fence if the observer saw rows move at all. if got.Shown == 0 || got.Moves == 0 { t.Errorf("typing \"Europe/Lo\" showed %d rows and moved %d: the observer saw no search happen", got.Shown, got.Moves) } if got.Rows < 50 || len(got.OutOfPlace) > 0 { 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) } } func TestSelectKeepsPaceWithTyping(t *testing.T) { t.Parallel() driveSelectFilter(t, sizingRig(t, false, selectFilterPages(t))) } // A required country question with pinned countries above a divider: // clearing a search puts a lifted pinned country back ABOVE the divider // and the divider shows again; the required blank is never a row, even // for words that name it; words typed and cleared on the unanswered // question leave nothing highlighted, so Enter cannot answer it; and a // pick closes the list without leaving the next search waiting on a frame. func TestSelectKeepsItsDividerAndPrompt(t *testing.T) { t.Parallel() var got struct { Sep int `json:"sep"` Restored bool `json:"restored"` Order []string `json:"order"` SepSearching bool `json:"sepSearching"` SepAfter bool `json:"sepAfter"` PromptIdle bool `json:"promptIdle"` PromptTyped bool `json:"promptTyped"` ClearedActive string `json:"clearedActive"` ClearedEnter string `json:"clearedEnter"` ClearedOpen bool `json:"clearedOpen"` Picked string `json:"picked"` ActiveAfter string `json:"activeAfter"` TopAfter string `json:"topAfter"` } drive(t, ` const input = document.querySelector('#country-combo'); const list = document.querySelector('#country-listbox'); const select = document.querySelector('#country'); const frame = () => `+afterFrame+`; const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; const seq = () => [...list.children].map((c) => c.id || c.tagName + (c.hasAttribute('rst-combo-sep') ? ':sep' : '')); const blank = document.querySelector('#country-listbox-0'); const sep = list.querySelector('[rst-combo-sep]'); const out = { sep: list.querySelectorAll('[rst-combo-sep]').length }; input.focus(); input.click(); await frame(); const s0 = seq(); out.promptIdle = blank.style.display !== 'none'; for (const q of ['a', 'i', 'united k']) { type(q); await frame(); if (q === 'a') out.sepSearching = sep.style.display !== 'none'; } type(''); await frame(); out.order = seq(); out.restored = out.order.join() === s0.join(); out.sepAfter = sep.style.display !== 'none'; type('choose'); await frame(); out.promptTyped = blank.style.display !== 'none'; type('ger'); await frame(); type(''); await frame(); out.clearedActive = input.getAttribute('aria-activedescendant') || ''; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); out.clearedEnter = select.value; out.clearedOpen = input.getAttribute('aria-expanded') === 'true'; type('irel'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); out.picked = select.value; type('germ'); const act = input.getAttribute('aria-activedescendant'); out.activeAfter = act ? select.options[Number(act.split('-listbox-')[1])].value : ''; const top = list.querySelector('[rst-combo-option]:not([style*="none"])'); out.topAfter = top ? select.options[Number(top.id.split('-listbox-')[1])].value : ''; input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); return out; `, &got) if got.Sep != 1 { t.Fatalf("the country list drew %d dividers, want 1: the fence below needs one", got.Sep) } if !got.Restored { head := got.Order if len(head) > 8 { head = head[:8] } t.Errorf("after searching \"a\", \"i\" and \"united k\" and clearing, the list is not in its own order: it starts %v", head) } if got.SepSearching || !got.SepAfter { t.Errorf("the divider showed %v while searching and %v after, want false then true", got.SepSearching, got.SepAfter) } if got.PromptIdle || got.PromptTyped { 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) } if got.ClearedActive != "" || got.ClearedEnter != "" || !got.ClearedOpen { 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) } if got.Picked != "IE" { t.Errorf("\"irel\" then Enter picked %q, want IE", got.Picked) } if got.ActiveAfter != "DE" || got.TopAfter != "DE" { 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) } } // A phone number's country: a MARKED prompt, "Country", which is a row // while nothing is typed. Unanswered, it highlights nothing — on opening, // and when a search is cleared — and the box shows only its placeholder, // so Enter picks nothing and the list stays open. The prompt is never a // stop for the arrows, Home or End, and never aria-selected. Answered, // the box reopens on its pick and a cleared search comes back to it. func TestSelectPromptClearsToItsPlaceholder(t *testing.T) { t.Parallel() var got struct { PromptRow bool `json:"promptRow"` OpenActive string `json:"openActive"` ClearedActive string `json:"clearedActive"` ClearedValue string `json:"clearedValue"` Placeholder string `json:"placeholder"` ClearedEnter string `json:"clearedEnter"` ClearedOpen bool `json:"clearedOpen"` Picked string `json:"picked"` Shown string `json:"shown"` PromptSelected string `json:"promptSelected"` DownFromNothing string `json:"downFromNothing"` UpFromFirst string `json:"upFromFirst"` End string `json:"end"` Home string `json:"home"` GBRow string `json:"gbRow"` GBSelected string `json:"gbSelected"` ReopenActive string `json:"reopenActive"` SearchActive string `json:"searchActive"` AnsweredClearedActive string `json:"answeredClearedActive"` } drive(t, ` const input = document.querySelector('#cc-combo'); const select = document.querySelector('#cc'); const frame = () => `+afterFrame+`; const type = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); const act = () => input.getAttribute('aria-activedescendant') || ''; const out = {}; input.focus(); input.click(); await frame(); const prompt = document.querySelector('#cc-listbox-0'); out.promptRow = prompt.style.display !== 'none'; out.openActive = act(); out.promptSelected = prompt.getAttribute('aria-selected'); key('ArrowDown'); out.downFromNothing = act(); key('ArrowUp'); out.upFromFirst = act(); key('End'); out.end = act(); key('Home'); out.home = act(); type('44'); await frame(); type(''); await frame(); out.clearedActive = act(); out.clearedValue = input.value; out.placeholder = input.placeholder; key('Enter'); out.clearedEnter = select.value; out.clearedOpen = input.getAttribute('aria-expanded') === 'true'; type('+44'); key('Enter'); out.picked = select.value; out.shown = input.value; out.gbRow = 'cc-listbox-' + select.selectedIndex; out.gbSelected = document.querySelector('#' + out.gbRow).getAttribute('aria-selected'); input.blur(); input.focus(); input.click(); await frame(); out.reopenActive = act(); type('ger'); await frame(); out.searchActive = act(); type(''); await frame(); out.answeredClearedActive = act(); key('Escape'); return out; `, &got) if !got.PromptRow { t.Error("the \"Country\" prompt is not a row with nothing typed") } if got.OpenActive != "" { t.Errorf("the unanswered box opened with %q highlighted, want nothing", got.OpenActive) } if got.ClearedActive != "" || got.ClearedEnter != "" || !got.ClearedOpen { 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) } if got.ClearedValue != "" || got.Placeholder != "Country" { t.Errorf("the cleared box reads %q under placeholder %q, want empty under \"Country\"", got.ClearedValue, got.Placeholder) } if got.Picked != "GB" || got.Shown != "+44" { t.Errorf("\"+44\" then Enter picked %q showing %q, want GB showing +44", got.Picked, got.Shown) } // The prompt is never a stop for the keys, and never "selected". const first = "cc-listbox-1" last := fmt.Sprintf("cc-listbox-%d", len(countries)) if got.DownFromNothing != first { t.Errorf("ArrowDown from nothing highlighted %q, want the first country %s: the prompt is not a stop", got.DownFromNothing, first) } if got.UpFromFirst != first || got.Home != first { 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) } if got.End != last { t.Errorf("End highlighted %q, want the last country %s", got.End, last) } if got.PromptSelected != "false" { t.Errorf("the prompt reads aria-selected=%q on an unanswered box, want false: a prompt is never selected", got.PromptSelected) } if got.GBSelected != "true" { t.Errorf("after picking GB its row reads aria-selected=%q, want true", got.GBSelected) } // Opening lands where opening() says: the pick, on an answered box. if got.ReopenActive != got.GBRow { t.Errorf("the answered box reopened on %q, want its pick %s", got.ReopenActive, got.GBRow) } if got.SearchActive == "" || got.SearchActive == got.GBRow || got.AnsweredClearedActive != got.GBRow { 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) } } // A select that becomes required (or stops) re-derives its prompts: an // optional select's "None" is a real answer — shown, listed, selected — // and once the select is required the same blank is a prompt, so the box // empties and no row is selected. And a highlighted row a page takes // away (its extra suggestions replaced) is never left as the highlight. func TestSelectFollowsRequiredAndExtras(t *testing.T) { t.Parallel() var got struct { OptionalShown string `json:"optionalShown"` OptionalSelected string `json:"optionalSelected"` RequiredShown string `json:"requiredShown"` RequiredSelected string `json:"requiredSelected"` BackShown string `json:"backShown"` ExtraActive string `json:"extraActive"` AfterRemove string `json:"afterRemove"` AfterRemoveInDOM bool `json:"afterRemoveInDOM"` BorrowedCleared bool `json:"borrowedCleared"` AfterRemoval string `json:"afterRemovalValue"` AfterCancel string `json:"afterCancelValue"` Requery string `json:"requeryValue"` RemovedShown string `json:"removedSelectedShown"` RemovedValue string `json:"removedSelectedValue"` NoneActive string `json:"noneActive"` NoneIndex int `json:"noneIndex"` BorrowedDisabled bool `json:"borrowedClearedDisabled"` AriaRequired string `json:"ariaRequired"` OpenFlipListed bool `json:"openFlipListed"` OpenFlipActive string `json:"openFlipActive"` OpenFlipShown int `json:"openFlipShown"` OpenFlipText string `json:"openFlipText"` IdleExtrasShown int `json:"idleExtrasShown"` ScreenOrder string `json:"screenOrder"` KeyOrder string `json:"keyOrder"` } drive(t, ` let input = document.querySelector('#size-combo'); // Changing the options rebuilds the box, so the drive re-reads it. const again = async () => { await frame(); input = document.querySelector('#size-combo'); }; const select = document.querySelector('#size'); const blank = document.querySelector('#size-listbox-0'); const frame = () => `+afterFrame+`; const key = (k) => input.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); const out = {}; out.optionalShown = input.value; // Read with the list open, which is when rows are marked. input.focus(); input.click(); await frame(); out.optionalSelected = blank.getAttribute('aria-selected'); key('Escape'); select.required = true; await frame(); out.requiredShown = input.value; out.requiredSelected = blank.getAttribute('aria-selected'); out.ariaRequired = input.getAttribute('aria-required'); select.required = false; await frame(); // And with the list OPEN when it flips: the blank leaves the list // and is not left highlighted. input.focus(); input.click(); await frame(); select.required = true; await frame(); out.openFlipListed = blank.style.display !== 'none'; out.openFlipActive = input.getAttribute('aria-activedescendant') || ''; // Idle (nothing typed), the refresh is not a search for the pick's // own text: every real choice stays, and the box drops "None". out.openFlipShown = document.querySelectorAll('#size-listbox [rst-combo-option]:not([style*="none"])').length; out.openFlipText = input.value; key('Escape'); select.required = false; await frame(); // And extras handed over while the box shows the pick hide nothing. input.focus(); input.click(); await frame(); input.closest('[rst-combo]').rstExtras([{ label: 'Something else', run: () => {} }]); out.idleExtrasShown = document.querySelectorAll('#size-listbox [rst-combo-option]:not([style*="none"])').length; input.closest('[rst-combo]').rstExtras([]); key('Escape'); select.required = false; await frame(); out.backShown = input.value; // Extras: arrow onto one, then have the page take them all away. const wrap = input.closest('[rst-combo]'); input.focus(); input.click(); await frame(); wrap.rstExtras([{ label: 'Something else', run: () => {} }]); key('End'); out.extraActive = input.getAttribute('aria-activedescendant') || ''; wrap.rstExtras([]); const id = input.getAttribute('aria-activedescendant') || ''; out.afterRemove = id; out.afterRemoveInDOM = id === '' || !!document.getElementById(id); key('Escape'); // An option the app removes after enhancement moves every index: // the row chosen is still the option submitted. const small = select.querySelector('option[value="s"]'); small.remove(); await again(); input.focus(); input.click(); await frame(); const medium = [...document.querySelectorAll('#size-listbox [rst-combo-option]')].find((li) => li.textContent === 'Medium'); medium.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); out.afterRemovalValue = select.value; select.insertBefore(small, select.querySelector('option[value="m"]')); await again(); select.value = ''; select.dispatchEvent(new Event('change', { bubbles: true })); // The SELECTED option removed: the next pick is what the box shows. select.value = 's'; select.dispatchEvent(new Event('change', { bubbles: true })); const small2 = select.querySelector('option[value="s"]'); small2.remove(); await again(); input.focus(); input.click(); await frame(); const large = [...document.querySelectorAll('#size-listbox [rst-combo-option]')].find((li) => li.textContent === 'Large'); large.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); out.removedSelectedShown = input.value; out.removedSelectedValue = select.value; select.insertBefore(small2, select.querySelector('option[value="m"]')); await again(); // Nothing selected at all is unanswered: nothing is highlighted, and // a bare Enter answers nothing. select.selectedIndex = -1; input.focus(); input.click(); await frame(); out.noneActive = input.getAttribute('aria-activedescendant') || ''; key('Enter'); out.noneIndex = select.selectedIndex; key('Escape'); select.value = ''; select.dispatchEvent(new Event('change', { bubbles: true })); // A cancelled search takes its steering with it: type, arrow, // Escape, type the same again, leave — nothing is committed. input.focus(); input.click(); await frame(); const typeSize = (v) => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); }; typeSize('m'); await frame(); key('ArrowDown'); key('Escape'); typeSize('m'); await frame(); key('Tab'); out.afterCancelValue = select.value; key('Escape'); // And the same words after a cancelled search are a NEW search: it // re-ranks, so Enter takes its best match, not the pick the old one // left highlighted. Large is picked; "a" ranks Small first. select.value = 'l'; select.dispatchEvent(new Event('change', { bubbles: true })); input.focus(); input.click(); await frame(); typeSize('a'); await frame(); key('Escape'); typeSize('a'); await frame(); key('Enter'); out.requeryValue = select.value; select.value = ''; select.dispatchEvent(new Event('change', { bubbles: true })); // A borrowed "please pick one" goes the moment the select stops // objecting, even while the box keeps focus. select.required = true; select.value = ''; select.dispatchEvent(new Event('change', { bubbles: true })); select.required = true; input.setCustomValidity(select.validationMessage || 'Please pick one'); select.required = false; await frame(); out.borrowedCleared = input.validity.valid; // And when it is disabled instead: a disabled select is never // validated, whatever its validity says. select.required = true; input.setCustomValidity(select.validationMessage || 'Please pick one'); select.disabled = true; await frame(); out.borrowedClearedDisabled = input.validity.valid; select.disabled = false; select.required = false; await frame(); // Grouped hits under a search sit above the page's extra rows, in // the order the keys walk them. const dInput = document.querySelector('#dept-combo'); const dWrap = dInput.closest('[rst-combo]'); const dList = document.querySelector('#dept-listbox'); dInput.focus(); dInput.click(); await frame(); dWrap.rstExtras([{ label: 'Other', run: () => {} }]); dInput.value = 'a'; dInput.dispatchEvent(new Event('input', { bubbles: true })); await frame(); out.screenOrder = [...dList.querySelectorAll('[rst-combo-option]:not([style*="none"])')].map((li) => li.textContent).join('|'); const walked = []; const dkey = (k) => dInput.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true })); dkey('Home'); for (let i = 0; i < 6; i++) { const a = dInput.getAttribute('aria-activedescendant'); const t = a ? document.getElementById(a).textContent : ''; if (!walked.includes(t)) walked.push(t); dkey('ArrowDown'); } out.keyOrder = walked.join('|'); dkey('Escape'); return out; `, &got) if got.OptionalShown != "None" || got.OptionalSelected != "true" { 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) } if got.RequiredShown != "" || got.RequiredSelected != "false" { 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) } if !got.BorrowedDisabled { t.Error("the box kept the select's borrowed message after the select was disabled: a disabled field would still block the form") } if got.AriaRequired != "true" { t.Errorf("the box of a required select reads aria-required=%q, want \"true\": an ARIA boolean is a string", got.AriaRequired) } if got.OpenFlipListed || got.OpenFlipActive == "size-listbox-0" { 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) } if got.OpenFlipShown != 3 || got.OpenFlipText != "" { 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) } if got.IdleExtrasShown != 5 { t.Errorf("extras handed to an idle open list left %d rows showing, want 5 (the 4 options and the extra)", got.IdleExtrasShown) } if got.BackShown != "None" { t.Errorf("made optional again, the box shows %q, want \"None\"", got.BackShown) } if !strings.Contains(got.ExtraActive, "-extra-") { t.Fatalf("End did not reach the extra row (highlighted %q): the fence below needs it", got.ExtraActive) } if !got.AfterRemoveInDOM { t.Errorf("after the page took its extra rows away the highlight still names %q, a row that is gone", got.AfterRemove) } if got.AfterRemoval != "m" { t.Errorf("after the app removed an option, choosing the Medium row submitted %q, want m: a cached index picked another option", got.AfterRemoval) } if got.RemovedShown != "Large" || got.RemovedValue != "l" { 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) } if got.NoneActive != "" || got.NoneIndex != -1 { 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) } if got.Requery != "s" { 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) } if got.AfterCancel != "" { t.Errorf("a search typed, arrowed, cancelled and typed again committed %q on leaving, want nothing: the cancelled search's steering survived", got.AfterCancel) } if !got.BorrowedCleared { t.Error("the box kept the select's borrowed message after the select stopped being required: the form would still refuse to submit") } if got.ScreenOrder == "" || got.ScreenOrder != got.KeyOrder || !strings.HasSuffix(got.ScreenOrder, "|Other") { 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) } } // A page may replace a whole '; document.querySelector('form').append(host); return host; }; const out = { pageBoxes: boxes(document) }; const one = add(); await frame(); out.unasked = boxes(one); const ask = async (root) => { document.dispatchEvent(new CustomEvent('rst:select-scan', root ? { detail: { root } } : {})); await Promise.resolve(); }; await ask(one); out.asked = boxes(one); await ask(one); out.askedTwice = boxes(one); const other = add(); const third = add(); await ask(third); out.outsideRoot = boxes(other); await ask(); out.wholeDoc = boxes(other); out.pageBoxesNow = boxes(document); // Replaced and asked for in the same task: the new box is named by // the label, which the old box has handed back by then. const fresh = document.createElement('select'); fresh.id = 'colour'; fresh.name = 'colour'; fresh.setAttribute('data-rst-select', ''); fresh.innerHTML = ''; document.querySelector('#colour').replaceWith(fresh); await ask(document.querySelector('#colour-host')); await frame(); out.replacedLabelled = !!document.querySelector('label[for="colour-combo"]'); return out; `, &got) if got.Unasked != 0 { t.Errorf("a select added after load was enhanced %d times without being asked, want 0: added content stays native by default", got.Unasked) } if got.Asked != 1 || got.AskedTwice != 1 { t.Errorf("asked once the new select has %d boxes, asked twice %d, want 1 and 1", got.Asked, got.AskedTwice) } if got.OutsideRoot != 0 { t.Errorf("a select outside the named root was enhanced (%d boxes), want 0", got.OutsideRoot) } if got.WholeDoc != 1 { t.Errorf("a scan with no root left the other added select with %d boxes, want 1", got.WholeDoc) } if !got.ReplacedLabelled { t.Error("a select replaced and scanned for in the same task: the new box lost its label to the old box's teardown") } if got.PageBoxesNow != got.PageBoxes+3 { 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) } } // Which way the list opens, and how tall, under Tito Go #3146's rule: up // only when the list does not fit below and there is more room above; // held to the room on its side, never past it. The room is the frame's // own viewport, cut to a band a host tells it (rst:select-viewport), and // to the top of any bar marked data-rst-select-floor below the box. func TestSelectPlacement(t *testing.T) { t.Parallel() var got struct { FrameDown bool `json:"frameDown"` FrameUp bool `json:"frameUp"` FrameBack bool `json:"frameBack"` FloorUp bool `json:"floorUp"` FloorRoom float64 `json:"floorRoom"` FloorMax float64 `json:"floorMax"` AboveFloorMax float64 `json:"aboveFloorMax"` ThreeUp bool `json:"threeUp"` ThreeMax float64 `json:"threeMax"` ThreeRoom float64 `json:"threeRoom"` ClampUp bool `json:"clampUp"` ClampMax float64 `json:"clampMax"` ClampRoom float64 `json:"clampRoom"` ClampTop float64 `json:"clampTop"` BandTop float64 `json:"bandTop"` TieUp bool `json:"tieUp"` CeilingUp bool `json:"ceilingUp"` CeilingTop float64 `json:"ceilingTop"` CeilingEnd float64 `json:"ceilingEnd"` NoCeilingUp bool `json:"noCeilingUp"` BelowCeilUp bool `json:"belowCeilUp"` } drive(t, ` const frame = () => `+afterFrame+`; const out = {}; // A framed page: its own viewport is 600px tall and the box sits in // the middle, so the list opens down. The host then says only the // top of the frame is on screen (a keyboard over the rest): the // open list is re-placed upward on receipt. Null goes back. const iframe = document.createElement('iframe'); iframe.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:600px;border:0'; iframe.src = '/'; await new Promise((r) => { iframe.onload = r; document.body.append(iframe); }); const fw = iframe.contentWindow, fd = iframe.contentDocument; await new Promise((r) => fw.requestAnimationFrame(() => fw.setTimeout(r, 0))); const ffr = () => new Promise((r) => fw.requestAnimationFrame(() => fw.setTimeout(r, 0))); const fin = fd.querySelector('#tz-combo'); const fwrap = fin.closest('[rst-combo]'); fwrap.style.cssText = 'position:fixed;left:10px;width:300px;top:250px'; fin.focus(); fin.click(); await ffr(); await ffr(); out.frameDown = !fwrap.hasAttribute('rst-combo-up'); const fbox = fwrap.getBoundingClientRect(); fd.dispatchEvent(new fw.CustomEvent('rst:select-viewport', { detail: { top: 0, bottom: fbox.bottom + 40 } })); await ffr(); await ffr(); out.frameUp = fwrap.hasAttribute('rst-combo-up'); fd.dispatchEvent(new fw.CustomEvent('rst:select-viewport', { detail: null })); await ffr(); await ffr(); out.frameBack = !fwrap.hasAttribute('rst-combo-up'); iframe.remove(); const input = document.querySelector('#tz-combo'); const wrap = input.closest('[rst-combo]'); const list = document.querySelector('#tz-listbox'); const open = async () => { input.blur(); input.focus(); input.click(); await frame(); await frame(); }; const close = () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); // The height the list actually takes on screen, padding and borders // in: what has to fit the room. const max = () => list.getBoundingClientRect().height; const at = (top) => { wrap.style.cssText = 'position:fixed;left:10px;width:300px;top:' + top + 'px'; }; // A floor 60px under a box near the top of the screen: the room // below is what is above the bar. at(10); const bar = document.createElement('div'); bar.setAttribute('data-rst-select-floor', ''); const boxBottom = () => wrap.getBoundingClientRect().bottom; bar.style.cssText = 'position:fixed;left:0;right:0;height:40px;top:' + (boxBottom() + 60) + 'px'; document.body.append(bar); await open(); out.floorUp = wrap.hasAttribute('rst-combo-up'); out.floorRoom = bar.getBoundingClientRect().top - boxBottom(); out.floorMax = max(); close(); // The same bar ABOVE the box is ignored. bar.style.top = '0px'; bar.style.height = '5px'; at(40); await open(); out.aboveFloorMax = max(); close(); bar.remove(); // Neither side fits the list: it takes the bigger side (above, // 100px) and is held to it. const h = wrap.getBoundingClientRect().height; at(200); document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 100, bottom: 200 + h + 80 } })); await open(); out.threeUp = wrap.hasAttribute('rst-combo-up'); out.threeMax = max(); out.threeRoom = 100; close(); // Neither side has room for three rows: the bigger side, clamped to // it, and never past the band. document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 200 - 46, bottom: 200 + h + 30 } })); await open(); out.clampUp = wrap.hasAttribute('rst-combo-up'); out.clampMax = max(); out.clampRoom = 46; out.clampTop = list.getBoundingClientRect().top; out.bandTop = 200 - 46; close(); // A tie goes down. document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 200 - 50, bottom: 200 + h + 50 } })); await open(); out.tieUp = wrap.hasAttribute('rst-combo-up'); close(); // A header pinned above the box (data-rst-select-ceiling) is not // room either: with 70px below and the header over all but 6px // above, the list opens down. Without the header it opens up, and // one marked below the box is ignored. at(150); document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: { top: 0, bottom: 150 + h + 70 } })); await open(); out.noCeilingUp = wrap.hasAttribute('rst-combo-up'); close(); const header = document.createElement('div'); header.setAttribute('data-rst-select-ceiling', ''); header.style.cssText = 'position:fixed;left:0;right:0;top:0;height:140px'; document.body.append(header); await open(); out.ceilingUp = wrap.hasAttribute('rst-combo-up'); out.ceilingTop = list.getBoundingClientRect().top; out.ceilingEnd = header.getBoundingClientRect().bottom; close(); header.style.top = (150 + h + 10) + 'px'; header.style.height = '5px'; await open(); out.belowCeilUp = wrap.hasAttribute('rst-combo-up'); close(); header.remove(); document.dispatchEvent(new CustomEvent('rst:select-viewport', { detail: null })); wrap.removeAttribute('style'); return out; `, &got) if !got.FrameDown || !got.FrameUp || !got.FrameBack { 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) } if got.FloorUp || got.FloorMax > got.FloorRoom || got.FloorMax < got.FloorRoom-12 { 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) } if got.AboveFloorMax < 200 { t.Errorf("a floor above the box cut the room below to %.0fpx: it should be ignored", got.AboveFloorMax) } if !got.ThreeUp || got.ThreeMax > got.ThreeRoom || got.ThreeMax < got.ThreeRoom-12 { t.Errorf("100px above and 80px below: up=%v height %.0fpx, want up, held to the room above", got.ThreeUp, got.ThreeMax) } if !got.ClampUp || got.ClampMax > got.ClampRoom || got.ClampTop < got.BandTop-1 { 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) } if got.TieUp { t.Error("equal room above and below opened upward: a tie goes down") } if !got.NoCeilingUp || got.CeilingUp || got.CeilingTop < got.CeilingEnd || !got.BelowCeilUp { 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) } } // The room is cut only by the ancestors that really clip the list, by // CSS's rules, row by row. A bar that scrolls sideways (overflow-x: auto, // so its overflow-y computes to auto too) clips a list it is the // containing block of, and not one fixed or anchored out of it: Docs' // phone toolbar is such a bar, and its lists opened 0px tall. "clip" rows // are a box that holds the list, so the list must end inside it; "escape" // rows are one that does not, so the list keeps its own height. Either // way the list is scrolled to its end and its last row must be the thing // at its centre: a list taller than what really shows it fails there, // which is how rastrillo's review of the fix found content-visibility // and the will-change triggers missed, then containment on a box that // is not there (display: contents, a plain inline), preserve-3d, and // will-change written in capitals or with -webkit-, and then // containment on table rows and ruby, a boxless ancestor's position, // will-change: filter on an inline, motion paths, and an alias the // engine lacks. func TestSelectRoomIsCutOnlyByWhatClipsTheList(t *testing.T) { t.Parallel() type row struct { Name string `json:"name"` Want string `json:"want"` Height float64 `json:"height"` Bottom float64 `json:"bottom"` BarBottom float64 `json:"barBottom"` Last bool `json:"last"` Err string `json:"err"` } var got struct { Preferred float64 `json:"preferred"` Rows []row `json:"rows"` } drive(t, ` const frame = () => `+afterFrame+`; const input = document.querySelector('#tz-combo'); const wrap = input.closest('[rst-combo]'); const list = document.querySelector('#tz-listbox'); const native = document.querySelector('#tz'); const home = wrap.parentNode, next = native.nextSibling; const open = async () => { input.blur(); input.focus(); input.click(); await frame(); await frame(); }; const close = () => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await open(); const preferred = list.getBoundingClientRect().height; close(); // Each row: the bar's own style, the list's position, and how the // bar is built. The list is anchored under its box, as Docs anchors // it, so it sits there whichever box holds it. const bar = 'width:300px;height:200px;overflow-x:auto;'; const rows = [ ['fixed, plain bar', 'escape', 'fixed', bar], ['fixed, transform', 'clip', 'fixed', bar + 'transform:translateX(0)'], ['fixed, translate', 'clip', 'fixed', bar + 'translate:0px'], ['fixed, rotate', 'clip', 'fixed', bar + 'rotate:0deg'], ['fixed, scale', 'clip', 'fixed', bar + 'scale:1'], ['fixed, perspective', 'clip', 'fixed', bar + 'perspective:100px'], ['fixed, filter', 'clip', 'fixed', bar + 'filter:blur(0)'], ['fixed, backdrop-filter', 'clip', 'fixed', bar + 'backdrop-filter:blur(0)'], ['fixed, contain: layout', 'clip', 'fixed', bar + 'contain:layout'], ['fixed, contain: paint', 'clip', 'fixed', bar + 'contain:paint'], ['fixed, contain: strict', 'clip', 'fixed', bar + 'contain:strict'], ['fixed, contain: content', 'clip', 'fixed', bar + 'contain:content'], ['fixed, content-visibility: auto', 'clip', 'fixed', bar + 'content-visibility:auto'], ['fixed, will-change: transform', 'clip', 'fixed', bar + 'will-change:transform'], ['fixed, will-change: translate', 'clip', 'fixed', bar + 'will-change:translate'], ['fixed, will-change: rotate', 'clip', 'fixed', bar + 'will-change:rotate'], ['fixed, will-change: scale', 'clip', 'fixed', bar + 'will-change:scale'], ['fixed, will-change: perspective', 'clip', 'fixed', bar + 'will-change:perspective'], ['fixed, will-change: filter', 'clip', 'fixed', bar + 'will-change:filter'], ['fixed, will-change: backdrop-filter', 'clip', 'fixed', bar + 'will-change:backdrop-filter'], ['fixed, will-change: contain', 'clip', 'fixed', bar + 'will-change:contain'], ['fixed, will-change: position', 'escape', 'fixed', bar + 'will-change:position'], ['fixed, will-change: scroll-position', 'escape', 'fixed', bar + 'will-change:scroll-position'], ['fixed, container-type: inline-size', 'escape', 'fixed', bar + 'container-type:inline-size'], ['fixed, container-type: size', 'escape', 'fixed', bar + 'container-type:size'], ['fixed, contain: paint, overflow visible', 'clip', 'fixed', 'width:300px;height:200px;contain:paint'], ['fixed, content-visibility: auto, overflow visible', 'clip', 'fixed', 'width:300px;height:200px;content-visibility:auto'], ['absolute, held by its own box', 'clip', 'absolute', bar], ['absolute, held outside the bar', 'escape', 'absolute static', bar], ['absolute, bar positioned', 'clip', 'absolute static', bar + 'position:relative'], ['absolute, will-change: position', 'clip', 'absolute static', bar + 'will-change:position'], ['fixed, display: contents with contain: paint', 'escape', 'fixed', 'display:contents;contain:paint'], ['fixed, display: contents with content-visibility: auto', 'escape', 'fixed', 'display:contents;content-visibility:auto'], ['absolute, held by its own box, in display: contents with contain: paint', 'escape', 'absolute', 'display:contents;contain:paint'], ['fixed, display: inline with contain: paint', 'escape', 'fixed', 'display:inline;contain:paint'], // inline flow-root list-item is an inline-block with a marker: // atomic, so all of it applies; inline list-item is not, which // rastrillo's review of the fix found last. WebKit has neither // and draws a block. ['fixed, display: inline list-item with contain: paint', 'escape', 'fixed', 'display:inline list-item;contain:paint'], ['fixed, display: inline flow-root list-item with contain: paint', 'clip', 'fixed', 'width:300px;height:200px;display:inline flow-root list-item;contain:paint'], ['fixed, display: inline flow-root list-item with transform', 'clip', 'fixed', bar + 'display:inline flow-root list-item;transform:translateX(0)'], ['absolute, held by its own box, in display: inline flow-root list-item', 'clip', 'absolute', bar + 'display:inline flow-root list-item'], ['fixed, transform-style: preserve-3d', 'clip', 'fixed', bar + 'transform-style:preserve-3d'], ['fixed, transform-style: preserve-3d, overflow: clip', 'clip', 'fixed', 'width:300px;height:200px;overflow:clip;transform-style:preserve-3d'], ['fixed, will-change: transform-style', 'clip', 'fixed', bar + 'will-change:transform-style'], ['fixed, will-change: TrAnSfOrM', 'clip', 'fixed', bar + 'will-change:TrAnSfOrM'], ['fixed, will-change: -webkit-transform', 'clip', 'fixed', bar + 'will-change:-webkit-transform'], ['fixed, will-change: -webkit-perspective', 'clip', 'fixed', bar + 'will-change:-webkit-perspective'], ['fixed, will-change: -webkit-filter', 'clip', 'fixed', bar + 'will-change:-webkit-filter'], // Only an alias the engine has: Chromium has no -webkit-backdrop-filter. ['fixed, will-change: -webkit-backdrop-filter', CSS.supports('-webkit-backdrop-filter', 'initial') ? 'clip' : 'escape', 'fixed', bar + 'will-change:-webkit-backdrop-filter'], ['fixed, in a modal dialog in a transformed clip', 'escape', 'fixed', 'dialog'], ['fixed, in a popover in a transformed clip', 'escape', 'fixed', 'popover'], ['absolute, slotted into a shadow scroller', 'clip', 'absolute', 'shadow'], // Markup rows: the box goes in [data-in]; [data-held], or else the // first element, is what must hold a "clip" list. // Containment skips table rows, row groups and non-atomic ruby; // a cell keeps it. ['fixed, table row with contain: paint', 'escape', 'fixed', '
'], ['absolute, held by its own box, in a table row with contain: paint', 'escape', 'absolute', '
'], ['fixed, row group with content-visibility: auto', 'escape', 'fixed', '
'], ['fixed, table row with contain: layout, in a scroller', 'escape', 'fixed', '
'], ['fixed, table cell with contain: paint', 'clip', 'fixed', '
'], ['fixed, display: ruby with contain: paint', 'escape', 'fixed', '
'], ['fixed, display: ruby-text with contain: paint', 'escape', 'fixed', '
'], // A boxless ancestor's position is not the chain's. ['absolute, held by its own box, in display: contents with position: fixed, in a scroller', 'clip', 'absolute', '
'], ['absolute, held by its own box, in display: contents with position: absolute, in a scroller', 'clip', 'absolute', '
'], // will-change: filter applies where filter does, inlines too. ['fixed, inline with will-change: filter, in a scroller', 'clip', 'fixed', '
'], ['fixed, inline with will-change: backdrop-filter, in a scroller', 'clip', 'fixed', '
'], // A motion path transforms, and its will-change names, the // offset shorthand among them, hold. ['fixed, offset-path', 'clip', 'fixed', bar + 'offset-path:path("M 0 0 L 100 0");offset-position:auto;offset-anchor:0 0'], ['fixed, will-change: offset-path', 'clip', 'fixed', bar + 'will-change:offset-path'], ['fixed, will-change: offset', 'clip', 'fixed', bar + 'will-change:offset'], ]; const out = []; for (const [name, want, position, style] of rows) { const r = { name, want }; try { // Positioned and outside every bar: what an absolute list with a // static box is held by when the bar does not hold it. const outer = document.createElement('div'); outer.style.cssText = 'position:absolute;left:0;top:100px;width:320px;height:600px'; document.body.append(outer); let held, closeTop = () => {}; if (style === 'dialog' || style === 'popover') { const clipper = document.createElement('div'); clipper.style.cssText = 'transform:translateX(0);overflow:hidden;width:300px;height:60px'; held = document.createElement(style === 'dialog' ? 'dialog' : 'div'); held.style.cssText = 'margin:0;padding:0;border:0;inset:auto;left:0;top:100px;width:300px;height:200px;overflow:auto'; clipper.append(held); outer.append(clipper); held.append(wrap, native); if (style === 'dialog') { held.showModal(); closeTop = () => held.close(); } else { held.setAttribute('popover', 'manual'); held.showPopover(); closeTop = () => held.hidePopover(); } } else if (style === 'shadow') { const host = document.createElement('div'); outer.append(host); host.attachShadow({ mode: 'open' }).innerHTML = '
'; host.append(wrap, native); held = host.shadowRoot.firstChild; } else if (style[0] === '<') { outer.insertAdjacentHTML('beforeend', style); held = outer.querySelector('[data-held]') || outer.firstElementChild; outer.querySelector('[data-in]').append(wrap, native); } else { held = document.createElement('div'); held.style.cssText = style; outer.append(held); held.append(wrap, native); } wrap.style.cssText = 'display:block;width:200px;anchor-name:--rst-room' + (position.endsWith('static') ? ';position:static' : ''); list.style.cssText = 'position:' + position.split(' ')[0] + ';inset:auto;position-anchor:--rst-room;top:anchor(bottom);left:anchor(left);width:200px'; await open(); const lr = list.getBoundingClientRect(); r.height = lr.height; r.bottom = lr.bottom; r.barBottom = held.getBoundingClientRect().bottom; list.scrollTop = list.scrollHeight; await frame(); const lis = [...list.querySelectorAll('[rst-combo-option]')].filter((li) => !li.hidden); const last = lis[lis.length - 1].getBoundingClientRect(); const hit = document.elementFromPoint(last.left + last.width / 2, last.top + last.height / 2); r.last = !!hit && lis[lis.length - 1].contains(hit); close(); closeTop(); outer.remove(); } catch (e) { r.err = String(e); } list.removeAttribute('style'); wrap.removeAttribute('style'); home.insertBefore(wrap, next); home.insertBefore(native, next); out.push(r); } return { preferred, rows: out }; `, &got) if got.Preferred < 100 { t.Fatalf("the list opened %.0fpx tall on its own, too short to tell anything apart", got.Preferred) } for _, r := range got.Rows { switch { case r.Err != "": t.Errorf("%s: %s", r.Name, r.Err) case r.Want == "escape" && r.Height < got.Preferred-1: t.Errorf("%s: the list opened %.0fpx tall, want its own %.0fpx: nothing here clips it", r.Name, r.Height, got.Preferred) case r.Want == "clip" && r.Bottom > r.BarBottom+1: 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) case !r.Last: 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) } } if len(got.Rows) != 64 { t.Errorf("ran %d rows, want 64", len(got.Rows)) } } // data-rst-class: a row wears its option's classes, the closed box wears // the pick's while it shows the pick, takes them off once somebody types // (their words are a search, not the pick), and wears the new pick's // once one is chosen. An option without the attribute adds nothing, and // is-active, the highlight's own class, is never taken from an option. func TestSelectOptionClassStylesRowAndBox(t *testing.T) { t.Parallel() var got struct { Rows []string `json:"rows"` Closed string `json:"closed"` External []string `json:"external"` Typing string `json:"typing"` Escaped string `json:"escaped"` Chosen string `json:"chosen"` Plain string `json:"plain"` Submitted string `json:"submitted"` } drive(t, ` const frame = () => `+afterFrame+`; const host = document.createElement('div'); host.innerHTML = ''; document.querySelector('form').append(host); document.dispatchEvent(new CustomEvent('rst:select-scan', { detail: { root: host } })); await Promise.resolve(); const input = document.querySelector('#face-combo'); const native = document.querySelector('#face'); const own = () => [...input.classList].sort().join(' '); const out = {}; out.rows = [...document.querySelectorAll('#face-listbox [role=option]')].map((li) => [...li.classList].sort().join(' ')); out.closed = own(); // The page changing the pick, with nobody typing: each pick's own // classes, and none left over from the one before. const set = (v) => { native.value = v; native.dispatchEvent(new Event('change', { bubbles: true })); return own(); }; out.external = [set('mono'), set('none'), set('serif')]; input.focus(); input.value = 'mo'; input.dispatchEvent(new Event('input', { bubbles: true })); await frame(); out.typing = own(); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await frame(); out.escaped = own(); input.value = 'mono'; input.dispatchEvent(new Event('input', { bubbles: true })); await frame(); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); await frame(); out.chosen = own(); out.submitted = document.querySelector('#face').value; document.querySelector('#face').value = 'none'; document.querySelector('#face').dispatchEvent(new Event('change', { bubbles: true })); input.focus(); input.value = 'none'; input.dispatchEvent(new Event('input', { bubbles: true })); await frame(); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); await frame(); out.plain = own(); return out; `, &got) if strings.Join(got.Rows, "|") != "big f-serif|f-mono|" { t.Errorf("rows wear %q, want each its own option's classes and the plain row none", got.Rows) } if got.Closed != "big f-serif" { t.Errorf("the closed box showing Serif wears %q, want %q", got.Closed, "big f-serif") } if strings.Join(got.External, "|") != "f-mono||big f-serif" { t.Errorf("the page picking Mono, None, then Serif left the box wearing %q, want each pick's classes and nothing else", got.External) } if got.Typing != "" { t.Errorf("the box wears %q while somebody types, want nothing: a query is not the pick", got.Typing) } if got.Escaped != "big f-serif" { t.Errorf("Escape put the pick back but the box wears %q, want %q", got.Escaped, "big f-serif") } if got.Submitted != "mono" || got.Chosen != "f-mono" { t.Errorf("after choosing Mono the select holds %q and the box wears %q, want mono and %q", got.Submitted, got.Chosen, "f-mono") } if got.Plain != "" { t.Errorf("a pick with no data-rst-class leaves the box wearing %q, want nothing", got.Plain) } } // A select the page cannot change is a box that cannot change it. The // box takes the select's disabled state and follows it; a row is // refused while the select is disabled, whichever way it was reached; // and a select inside a disabled
, which sets no attribute, // is read as disabled at the moment of the choice. Each leg tries every // way of choosing and reads the select's value and what the form posts. func TestADisabledSelectCannotBeChangedThroughTheBox(t *testing.T) { t.Parallel() var got struct { Before string `json:"before"` BoxDisabled bool `json:"boxDisabled"` ListOpen bool `json:"listOpen"` ValueDisabled string `json:"valueDisabled"` BoxEnabled bool `json:"boxEnabled"` PostedAfter string `json:"postedAfter"` ChangedOnce string `json:"changedOnce"` InFieldset bool `json:"inFieldset"` ValueInSet string `json:"valueInSet"` ListOpenInSet bool `json:"listOpenInSet"` PostedOutOfSet string `json:"postedOutOfSet"` } drive(t, ` const frame = () => `+afterFrame+`; const out = {}; const form = document.querySelector('form'); const posted = () => new FormData(form).get('colour'); const select = document.querySelector('#colour'); const input = document.querySelector('#colour-combo'); const list = document.querySelector('#colour-listbox'); // Every way of choosing Green: focus and click (a disabled box // refuses both natively), words and Enter, and a row. const tryGreen = () => { input.focus(); input.click(); input.value = 'green'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); const row = [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === 'Green'); row.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); row.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); input.blur(); }; out.before = posted(); select.disabled = true; await frame(); out.boxDisabled = input.disabled; tryGreen(); await frame(); out.listOpen = !list.hidden; out.valueDisabled = select.value; select.disabled = false; await frame(); out.boxEnabled = !input.disabled; out.postedAfter = posted(); // Enabled again, the same moves choose Green: the legs above // were refused by the state, not by a box that cannot choose. tryGreen(); await frame(); out.changedOnce = posted(); // A disabled fieldset around the select sets no attribute. const fs = document.createElement('fieldset'); const host = document.querySelector('#colour-host'); host.replaceWith(fs); fs.append(host); await frame(); fs.disabled = true; await frame(); out.inFieldset = input.matches(':disabled'); const blue = [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === 'Blue'); input.value = 'blue'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); blue.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); blue.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await frame(); out.listOpenInSet = !list.hidden; out.valueInSet = select.value; fs.disabled = false; await frame(); out.postedOutOfSet = posted(); return out; `, &got) if got.Before != "red" { t.Fatalf("the colour select starts at %q, want red", got.Before) } if !got.BoxDisabled { t.Error("the select was disabled and its box was not") } if got.ListOpen || got.ValueDisabled != "red" || got.PostedAfter != "red" { 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) } if !got.BoxEnabled { t.Error("the select was enabled again and its box stayed disabled") } if got.ChangedOnce != "green" { t.Errorf("enabled again, the same moves posted %q, want green: the box could not choose at all", got.ChangedOnce) } if !got.InFieldset { t.Fatal("inside a disabled fieldset, the box does not read as :disabled; the fieldset leg is not set up") } if got.ListOpenInSet || got.ValueInSet != "green" || got.PostedOutOfSet != "green" { 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) } } // An option under a disabled is disabled with it: the native // select will not pick it, so its row is marked disabled and refused, // by words and Enter and by a click. A sibling option under an enabled // group still chooses, so the refusal is the group's, not the box's. func TestAnOptionUnderADisabledGroupCannotBeChosen(t *testing.T) { t.Parallel() var got struct { Before string `json:"before"` RowDisabled string `json:"rowDisabled"` AfterBill string `json:"afterBill"` AfterRet string `json:"afterRet"` Posted string `json:"posted"` } drive(t, ` const frame = () => `+afterFrame+`; const out = {}; const form = document.querySelector('form'); const select = document.querySelector('#dept'); out.before = select.value; select.querySelector('optgroup[label="Support"]').disabled = true; await frame(); await frame(); const input = document.querySelector('#dept-combo'); const list = document.querySelector('#dept-listbox'); const row = (text) => [...list.querySelectorAll('[rst-combo-option]')].find((li) => li.textContent.trim() === text); out.rowDisabled = row('Billing').getAttribute('aria-disabled') || ''; const choose = (words, text) => { input.focus(); input.value = words; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); const li = row(text); li.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); li.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); input.blur(); }; choose('bil', 'Billing'); await frame(); out.afterBill = select.value; choose('ret', 'Retail'); await frame(); out.afterRet = select.value; out.posted = new FormData(form).get('dept'); return out; `, &got) if got.Before != "acc" { t.Fatalf("the department select starts at %q, want acc", got.Before) } if got.RowDisabled != "true" { t.Errorf("Billing's row is aria-disabled=%q under a disabled group, want true", got.RowDisabled) } if got.AfterBill != "acc" { t.Errorf("Billing, under a disabled group, was chosen: the select is %q, want acc", got.AfterBill) } if got.AfterRet != "ret" || got.Posted != "ret" { 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) } } // A form reset puts the select back to its default without a change // event, and the box, whose own default is empty, resets to blank: the // page showed nothing while the form posted the default. After an // uncancelled reset, through form.reset() or a reset button, the box // shows the select's value again and an open list closes; a reset a // listener cancelled leaves the pick in place. func TestAFormResetRepaintsTheBox(t *testing.T) { t.Parallel() var got struct { Picked string `json:"picked"` PostedPick string `json:"postedPick"` ShownReset string `json:"shownReset"` PostedReset string `json:"postedReset"` ListReset bool `json:"listReset"` ShownButton string `json:"shownButton"` PostedButton string `json:"postedButton"` ShownKept string `json:"shownKept"` PostedKept string `json:"postedKept"` } drive(t, ` const frame = () => `+afterFrame+`; const tick = () => new Promise((r) => setTimeout(r, 0)); const out = {}; const form = document.querySelector('form'); const posted = () => new FormData(form).get('colour'); const input = document.querySelector('#colour-combo'); const list = document.querySelector('#colour-listbox'); const pick = (words) => { input.focus(); input.value = words; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); input.blur(); }; pick('green'); out.picked = input.value; out.postedPick = posted(); // Open the list, then reset through the API. input.focus(); input.click(); await frame(); form.reset(); await tick(); await frame(); out.listReset = !list.hidden; out.shownReset = input.value; out.postedReset = posted(); input.blur(); // Again through a reset button. pick('blue'); const button = document.createElement('button'); button.type = 'reset'; form.append(button); button.click(); await tick(); await frame(); out.shownButton = input.value; out.postedButton = posted(); // A cancelled reset changes nothing. pick('green'); form.addEventListener('reset', (e) => e.preventDefault(), { once: true }); form.reset(); await tick(); await frame(); out.shownKept = input.value; out.postedKept = posted(); return out; `, &got) if got.Picked != "Green" || got.PostedPick != "green" { t.Fatalf("picking Green shows %q and posts %q; the drive's setup did not take", got.Picked, got.PostedPick) } if got.ListReset || got.ShownReset != "Red" || got.PostedReset != "red" { t.Errorf("after form.reset(): list open %v, box shows %q, form posts %q; want closed, Red, red", got.ListReset, got.ShownReset, got.PostedReset) } if got.ShownButton != "Red" || got.PostedButton != "red" { t.Errorf("after a reset button: box shows %q, form posts %q; want Red, red", got.ShownButton, got.PostedButton) } if got.ShownKept != "Green" || got.PostedKept != "green" { t.Errorf("after a cancelled reset: box shows %q, form posts %q; want Green, green", got.ShownKept, got.PostedKept) } }