//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` +
`` +
`` +
``)
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