const test = require('node:test');
const assert = require('node:assert/strict');
const { listenerDocument } = require('./test-dom');
const SCRIPT = require.resolve('./selects.js');
function standInDocument(filters) {
return listenerDocument({
activeElement: null,
documentElement: {},
querySelectorAll: (selector) =>
selector === '[data-options-filter]' ? filters : [],
createElement: () => listItem(),
});
}
function listItem() {
const attrs = {};
return {
dataset: {},
textContent: '',
className: '',
id: '',
getAttribute: (name) => (name in attrs ? attrs[name] : null),
setAttribute: (name, value) => {
attrs[name] = value;
},
removeAttribute: (name) => {
delete attrs[name];
},
scrollIntoView() {},
};
}
function load(document) {
global.document = document;
delete require.cache[SCRIPT];
return require(SCRIPT);
}
function searchableField({ server = null } = {}) {
const options = [
{ value: '', textContent: '-- Select --' },
{ value: 'pk-ada', textContent: 'Ada Author' },
{ value: 'pk-alan', textContent: 'Alan Author' },
];
const rows = options.slice(1).map((option) => {
const attrs = { role: 'option', 'aria-selected': 'false' };
return {
dataset: { value: option.value },
textContent: option.textContent,
getAttribute: (name) => (name in attrs ? attrs[name] : null),
setAttribute: (name, value) => {
attrs[name] = value;
},
scrollIntoView() {},
};
});
const combo = {};
const wrap = {};
const wrapAttrs = server
? { 'data-options-server': 'true', 'data-options-field': server }
: {};
const filterAttrs = {};
const filter = {
value: '',
closest: (selector) =>
selector === '[data-options-filter]' ? filter
: selector === '[data-options-combobox]' ? combo
: selector === '[data-select-filterable]' ? wrap
: null,
getAttribute: (name) => (name in filterAttrs ? filterAttrs[name] : null),
setAttribute: (name, value) => {
filterAttrs[name] = value;
},
removeAttribute: (name) => {
delete filterAttrs[name];
},
};
const list = {
id: 'name-options-list',
hidden: false,
querySelector: (selector) => {
if (selector === '[role="option"][aria-selected="true"]') {
return rows.find((row) => row.getAttribute('aria-selected') === 'true') || null;
}
return selector === '[role="option"]' ? rows[0] || null : null;
},
querySelectorAll: (selector) => (selector === '[role="option"]' ? rows : []),
replaceChildren: () => rows.splice(0),
appendChild: (row) => rows.push(row),
};
combo.querySelector = (selector) =>
selector === '[data-options-filter]' ? filter
: selector === '[data-options-list]' ? list
: null;
const selectAttrs = {};
const select = {
value: '',
options,
required: false,
events: [],
getAttribute: (name) => (name in selectAttrs ? selectAttrs[name] : null),
setAttribute: (name, value) => {
selectAttrs[name] = value;
},
querySelectorAll: (selector) => (selector === 'option' ? options : []),
dispatchEvent(event) {
this.events.push(event);
return true;
},
};
const control = { hidden: false };
select.parentElement = control;
wrap.querySelector = (selector) => (selector === 'select' ? select : null);
wrap.getAttribute = (name) => (name in wrapAttrs ? wrapAttrs[name] : null);
wrap.hasAttribute = (name) => name in wrapAttrs;
wrap.dataset = {};
rows.forEach((row) => {
row.closest = (selector) =>
selector === '[data-options-list] [role="option"]' ? row
: selector === '[data-options-combobox]' ? combo
: selector === '[data-select-filterable]' ? wrap
: null;
});
return { combo, wrap, filter, list, select, control, rows };
}
function plainField() {
const select = { value: '', required: true };
const control = { hidden: false };
select.parentElement = control;
const wrap = { querySelector: (selector) => (selector === 'select' ? select : null) };
return { wrap, select, control };
}
const {
choiceMatches,
matchingOptions,
MAX_LIST_ITEMS,
preservedOption,
shouldHideNativeSelect,
} = load(standInDocument([]));
const PLACEHOLDER = { value: '', label: '-- Select --', selected: false };
const ada = { value: 'pk-ada', label: 'Ada Author', selected: false };
const alan = { value: 'pk-alan', label: 'Alan Author', selected: false };
const june = { value: 'pk-june', label: 'June Writer', selected: false };
const options = [PLACEHOLDER, ada, alan, june];
const labels = (needle) => matchingOptions(options, needle, MAX_LIST_ITEMS).map((r) => r.label);
test('an empty needle offers everything and the placeholder', () => {
assert.deepEqual(labels(''), ['-- Select --', 'Ada Author', 'Alan Author', 'June Writer']);
});
test('a needle narrows by label substring', () => {
assert.deepEqual(labels('ada'), ['Ada Author']);
assert.deepEqual(labels('author'), ['Ada Author', 'Alan Author']);
});
test('matching is case-insensitive, in both directions', () => {
assert.deepEqual(labels('ADA'), ['Ada Author']);
assert.deepEqual(labels('wRiTeR'), ['June Writer']);
});
test('the needle is trimmed before matching', () => {
assert.deepEqual(labels(' june '), ['June Writer']);
});
test('the placeholder is offered only while the needle is empty', () => {
assert.equal(matchingOptions(options, 'ada', MAX_LIST_ITEMS)[0].label, 'Ada Author');
assert.ok(!labels('ada').includes('-- Select --'));
});
test('the placeholder never counts against the cap', () => {
const many = [PLACEHOLDER];
for (let i = 0; i < 10; i += 1) {
many.push({ value: `pk-${i}`, label: `Option ${i}`, selected: false });
}
const rows = matchingOptions(many, '', 3);
assert.equal(rows.length, 4, 'the placeholder plus the cap');
assert.equal(rows[0].label, '-- Select --');
assert.deepEqual(rows.slice(1).map((r) => r.label), ['Option 0', 'Option 1', 'Option 2']);
});
test('selection state rides along, so the list can mark the current choice', () => {
const chosen = [{ ...PLACEHOLDER }, { ...ada, selected: true }];
const rows = matchingOptions(chosen, '', MAX_LIST_ITEMS);
assert.equal(rows.find((r) => r.value === 'pk-ada').selected, true);
assert.equal(rows.find((r) => r.value === '').selected, false);
});
test('no match yields an empty list, which the caller reports as such', () => {
assert.deepEqual(labels('zzz'), []);
});
test("a multiple choice's row shows while its label holds the trimmed needle, in any case", () => {
assert.equal(choiceMatches('Databases', ''), true, 'an empty needle shows every row');
assert.equal(choiceMatches('Databases', ' BASE '), true);
assert.equal(choiceMatches('Databases', 'engineering'), false);
});
test('the native select is hidden only when the combobox over it is wired', () => {
const wired = { combo: {}, wrap: {}, filter: {}, list: {}, select: {} };
assert.equal(shouldHideNativeSelect(wired), true);
for (const part of ['combo', 'filter', 'list', 'select']) {
assert.equal(
shouldHideNativeSelect({ ...wired, [part]: null }),
false,
`must not hide without ${part}`,
);
}
});
test('a wired field hides the replaced control, not the field', () => {
const world = searchableField();
load(standInDocument([world.filter]));
assert.equal(world.control.hidden, true, 'the replaced control leaves the display');
assert.notEqual(world.wrap.hidden, true, 'the field wrapper keeps the combobox');
assert.equal(world.select.options.length, 3, 'the select stays the value carrier');
});
test('hiding drops the required attribute the browser would block on', () => {
const world = searchableField();
world.select.required = true;
load(standInDocument([world.filter]));
assert.equal(world.select.required, false, 'the unfocusable control is not required');
assert.equal(
world.filter.getAttribute('aria-required'),
'true',
'the combobox carries the field requiredness',
);
});
test('a plain select keeps its control and its required attribute', () => {
const searchable = searchableField();
const plain = plainField();
load(standInDocument([searchable.filter]));
assert.equal(searchable.control.hidden, true, 'the wired field hides its control');
assert.notEqual(plain.control.hidden, true, 'nothing replaces a select with no combobox');
assert.equal(plain.select.required, true, 'native validation stays on the visible control');
});
test('a swapped-in field is hidden again', () => {
const world = searchableField();
const document = standInDocument([world.filter]);
const observers = [];
global.MutationObserver = class {
constructor(callback) {
this.callback = callback;
observers.push(this);
}
observe() {}
};
try {
load(document);
assert.equal(observers.length, 1, 'the script watches for swapped markup');
world.control.hidden = false;
observers[0].callback([
{ addedNodes: [{ nodeType: 1, querySelectorAll: () => [world.filter] }] },
]);
assert.equal(world.control.hidden, true, 'the swapped field is hidden again');
} finally {
delete global.MutationObserver;
}
});
test('every document listener is registered once', () => {
const world = searchableField();
const document = standInDocument([world.filter]);
load(document);
assert.deepEqual(
document.types().sort().map((type) => `${type}:${document.listeners(type).length}`),
['change:1', 'click:1', 'focusin:1', 'focusout:1', 'input:1', 'keydown:1', 'mousedown:1'],
'one listener per type, so one wiring block',
);
});
test('an activation dispatches one change', () => {
const world = searchableField();
const document = standInDocument([world.filter]);
load(document);
document.listeners('mousedown').forEach((handler) => {
handler({ target: world.rows[0], preventDefault() {} });
});
assert.equal(world.select.events.length, 1, 'one change per activation');
assert.equal(world.select.events[0].type, 'change');
assert.equal(world.select.value, 'pk-ada');
});
test('an arrow key advances one row', () => {
const world = searchableField();
const document = standInDocument([world.filter]);
load(document);
document.listeners('keydown').forEach((handler) => {
handler({ key: 'ArrowDown', target: world.filter, preventDefault() {} });
});
const selected = world.rows.filter((row) => row.getAttribute('aria-selected') === 'true');
assert.equal(selected.length, 1, 'one row per arrow key');
assert.equal(selected[0].dataset.value, 'pk-alan', 'the row after the first');
});
test('an edit form shows the current option label in the box', () => {
const world = searchableField();
world.select.value = 'pk-ada';
load(standInDocument([world.filter]));
assert.equal(world.filter.value, 'Ada Author');
});
test("entering an edit form's field lists every option, not only the current one", () => {
const world = searchableField();
world.select.value = 'pk-ada';
const document = standInDocument([world.filter]);
load(document);
document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
assert.ok(
world.rows.some((row) => row.textContent === 'Alan Author'),
'the box shows the current label, which filters nothing',
);
world.filter.value = 'Ala';
document.listeners('input').forEach((handler) => handler({ target: world.filter }));
assert.deepEqual(
world.rows.map((row) => row.textContent),
['Alan Author'],
'typed text still filters',
);
});
test("the box carries the hidden select's error state and description", () => {
const world = searchableField();
world.select.setAttribute('aria-invalid', 'true');
world.select.setAttribute('aria-describedby', 'author-error');
load(standInDocument([world.filter]));
assert.equal(world.filter.getAttribute('aria-invalid'), 'true');
assert.equal(world.filter.getAttribute('aria-describedby'), 'author-error');
});
test('a field with no current option leaves the box empty', () => {
const world = searchableField();
load(standInDocument([world.filter]));
assert.equal(world.filter.value, '');
});
test('a swap keeps the current option under its own label', () => {
assert.equal(
preservedOption('pk-ada', 'Ada Author', '<option value="pk-ken">Ken</option>'),
'<option value="pk-ada" selected data-options-kept="">Ada Author</option>',
);
});
test('a swap that already carries the current option does not duplicate it', () => {
assert.equal(
preservedOption('pk-ada', 'Ada Author', '<option value="pk-ada">Ada Author</option>'),
null,
);
});
test('an empty selection preserves nothing', () => {
assert.equal(preservedOption('', 'Ada Author', ''), null);
});
test('a record label cannot break out of the option markup', () => {
assert.equal(
preservedOption('pk-1', 'A "quoted" <name>', ''),
'<option value="pk-1" selected data-options-kept="">A "quoted" <name></option>',
);
});
test('opening the combobox expands it and names its active row', () => {
const world = searchableField();
const document = standInDocument([world.filter]);
load(document);
document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
assert.equal(world.list.hidden, false, 'the list is showing');
assert.equal(world.filter.getAttribute('aria-expanded'), 'true');
assert.equal(
world.filter.getAttribute('aria-activedescendant'),
'name-options-list-option-0',
'the first row is the active descendant',
);
});
test('an arrow key moves the active descendant with the selection', () => {
const world = searchableField();
const document = standInDocument([world.filter]);
load(document);
document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
document.listeners('keydown').forEach((handler) => {
handler({ key: 'ArrowDown', target: world.filter, preventDefault() {} });
});
assert.equal(
world.filter.getAttribute('aria-activedescendant'),
'name-options-list-option-1',
'the active row follows the arrow',
);
});
function escape(document, world) {
const event = {
key: 'Escape',
target: world.filter,
prevented: false,
preventDefault() {
this.prevented = true;
},
};
document.listeners('keydown').forEach((handler) => handler(event));
return event.prevented;
}
test('closing the combobox collapses it and clears the active row', () => {
const world = searchableField();
const document = standInDocument([world.filter]);
load(document);
document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
assert.equal(escape(document, world), true, 'Escape closes the list alone');
assert.equal(world.list.hidden, true);
assert.equal(world.filter.getAttribute('aria-expanded'), 'false');
assert.equal(world.filter.getAttribute('aria-activedescendant'), null);
assert.equal(escape(document, world), false, 'a closed list leaves Escape to the page');
});
test('Escape closes a list showing a status line, and the search landing keeps it closed', async () => {
const world = searchableField({ server: 'author_id' });
const document = standInDocument([world.filter]);
const realSetTimeout = global.setTimeout;
let search;
global.setTimeout = (callback) => {
search = callback;
return 0;
};
global.window = { location: { pathname: '/admin/books/-/create' } };
global.fetch = async () => ({ ok: false, headers: { get: () => null }, text: async () => '' });
try {
load(document);
document.listeners('input').forEach((handler) => handler({ target: world.filter }));
assert.equal(world.list.hidden, false, 'the status row shows the search');
assert.equal(escape(document, world), true);
assert.equal(world.list.hidden, true);
await search();
assert.equal(world.list.hidden, true, 'the landed search does not reopen the list');
} finally {
global.setTimeout = realSetTimeout;
delete global.fetch;
delete global.window;
}
});
test('Enter while the combobox has focus never submits the form', () => {
const world = searchableField();
const document = standInDocument([world.filter]);
load(document);
world.list.hidden = true;
const event = {
key: 'Enter',
target: world.filter,
prevented: false,
preventDefault() {
this.prevented = true;
},
};
document.listeners('keydown').forEach((handler) => handler(event));
assert.equal(event.prevented, true, 'the form must not submit');
assert.equal(world.select.events.length, 0, 'nothing was chosen');
});
test('Enter while the server is searching does not submit', () => {
const world = searchableField({ server: 'author_id' });
const document = standInDocument([world.filter]);
const realSetTimeout = global.setTimeout;
const realClearTimeout = global.clearTimeout;
global.setTimeout = () => 0;
global.clearTimeout = () => {};
try {
load(document);
document.listeners('input').forEach((handler) => handler({ target: world.filter }));
assert.equal(world.list.hidden, false, 'the status row shows the search');
const event = {
key: 'Enter',
target: world.filter,
prevented: false,
preventDefault() {
this.prevented = true;
},
};
document.listeners('keydown').forEach((handler) => handler(event));
assert.equal(event.prevented, true, 'the form must not submit');
assert.equal(
world.filter.getAttribute('aria-expanded'),
'true',
'the status popup is expanded',
);
assert.equal(
world.filter.getAttribute('aria-activedescendant'),
null,
'a status line has no active row',
);
} finally {
global.setTimeout = realSetTimeout;
global.clearTimeout = realClearTimeout;
}
});
function answered(value, textContent, attrs = {}) {
return {
value,
textContent,
disabled: 'disabled' in attrs,
selected: 'selected' in attrs,
hasAttribute: (name) => name in attrs,
};
}
function listFor(world, options, needle) {
world.select.options.splice(0, world.select.options.length, ...options);
world.list.replaceChildren = (...items) => {
world.rows.splice(0, world.rows.length, ...items);
};
world.filter.value = needle;
const document = standInDocument([world.filter]);
load(document);
document.listeners('focusin').forEach((handler) => handler({ target: world.filter }));
return world.rows;
}
test('a server answer is offered whatever its labels hold', () => {
const world = searchableField({ server: 'author_id' });
const rows = listFor(
world,
[
answered('', '-- Select --'),
answered('pk-ken', 'Ken Writer', { selected: '', 'data-options-kept': '' }),
answered('pk-ada', 'Ada Author'),
],
'ada@example.com',
);
assert.deepEqual(
rows.map((row) => row.dataset.value),
['pk-ada'],
'the kept current option is judged by its label',
);
});
test("an overflowed server answer shows its status line, not 'no match'", () => {
const world = searchableField({ server: 'author_id' });
const status = answered('', 'Too many results', { disabled: '' });
const rows = listFor(world, [answered('', '-- Select --'), status], 'a');
assert.equal(rows.length, 1);
assert.equal(rows[0].textContent, status.textContent);
assert.equal(rows[0].getAttribute('role'), null, 'a status line is not an option');
});
function dependentWorld({ parentValue, current }) {
const select = {
value: current,
options: [
{ value: '', textContent: '-- Select --', outerHTML: '<option value="">-- Select --</option>' },
{ value: current, textContent: current },
],
events: [],
querySelector(selector) {
return selector === 'option[value=""]' ? this.options[0] : null;
},
set innerHTML(html) {
this.options = Array.from(html.matchAll(/value="([^"]*)"/g), (m) => ({ value: m[1] }));
},
dispatchEvent(event) {
this.events.push(event);
return true;
},
};
const attrs = {
'data-options-parent': 'country_id',
'data-options-field': 'city_id',
'data-options-parent-value': 'fr',
};
const wrap = {
getAttribute: (name) => (name in attrs ? attrs[name] : null),
setAttribute: (name, value) => {
attrs[name] = value;
},
removeAttribute: (name) => {
delete attrs[name];
},
hasAttribute: (name) => name in attrs,
contains: () => false,
querySelector: (selector) => (selector === 'select' ? select : null),
};
const form = {
querySelectorAll: (selector) => (selector === '[data-options-parent]' ? [wrap] : []),
};
const parent = { name: 'country_id', value: parentValue, form, closest: () => null };
return { attrs, select, parent };
}
async function changeParent(document, parent) {
document.listeners('change').forEach((handler) => handler({ target: parent }));
await new Promise((resolve) => setImmediate(resolve));
}
test('a blank parent clears its dependent choice without a fetch', async () => {
const world = dependentWorld({ parentValue: '', current: 'paris' });
const document = standInDocument([]);
load(document);
global.fetch = () => assert.fail('a blank parent fetches nothing');
try {
await changeParent(document, world.parent);
} finally {
delete global.fetch;
}
assert.deepEqual(world.select.options.map((o) => o.value), ['']);
assert.equal(world.select.value, '');
assert.equal(world.attrs['data-options-parent-value'], '');
assert.equal(world.select.events.length, 1, 'the dropped choice announces its change');
});
test('a parent value fetches the options it offers, keeping a choice among them', async () => {
global.window = { location: { pathname: '/admin/addresses/-/create' } };
const urls = [];
global.fetch = async (url) => {
urls.push(url);
return {
ok: true,
headers: { get: () => null },
text: async () => '<option value="paris">Paris</option><option value="lyon">Lyon</option>',
};
};
try {
const kept = dependentWorld({ parentValue: 'fr', current: 'paris' });
const document = standInDocument([]);
load(document);
await changeParent(document, kept.parent);
assert.deepEqual(urls, ['/admin/addresses/-/options?field=city_id&parent=fr']);
assert.equal(kept.select.value, 'paris');
assert.equal(kept.select.events.length, 0, 'a kept choice does not change');
const dropped = dependentWorld({ parentValue: 'fr', current: 'berlin' });
await changeParent(document, dropped.parent);
assert.equal(dropped.select.value, '');
assert.equal(dropped.select.events.length, 1, 'the dropped choice announces its change');
} finally {
delete global.fetch;
delete global.window;
}
});
async function refreshWith(answer) {
global.window = { location: { pathname: '/admin/addresses/-/create' } };
global.fetch = answer;
try {
const world = dependentWorld({ parentValue: 'fr', current: 'paris' });
const document = standInDocument([]);
load(document);
await changeParent(document, world.parent);
return world;
} finally {
delete global.fetch;
delete global.window;
}
}
test('an overflowed answer offers a choice with no filter nothing to type into', async () => {
const world = await refreshWith(async () => ({
ok: true,
headers: { get: (name) => (name === 'x-options-overflow' ? 'true' : null) },
text: async () => '<option value="" disabled>Too many results — keep typing</option>',
}));
assert.deepEqual(world.select.options.map((o) => o.value), ['']);
assert.equal(world.select.value, '');
assert.equal(world.attrs['data-options-server'], undefined, 'no filter, no server search');
});
test('a failed fetch offers nothing rather than the old parent value\'s options', async () => {
for (const answer of [
async () => ({ ok: false, headers: { get: () => null }, text: async () => '' }),
async () => {
throw new TypeError('network');
},
]) {
const world = await refreshWith(answer);
assert.deepEqual(world.select.options.map((o) => o.value), ['']);
assert.equal(world.select.events.length, 1, 'the dropped choice announces its change');
}
});
test('a choice whose wrapper names its options route fetches from it, not the page\'s', async () => {
global.window = { location: { pathname: '/admin/addresses' } };
const urls = [];
global.fetch = async (url) => {
urls.push(url);
return { ok: true, headers: { get: () => null }, text: async () => '' };
};
try {
const world = dependentWorld({ parentValue: 'fr', current: '' });
world.attrs['data-options-url'] = '/admin/addresses/-/actions/move/options';
const document = standInDocument([]);
load(document);
await changeParent(document, world.parent);
assert.deepEqual(urls, ['/admin/addresses/-/actions/move/options?field=city_id&parent=fr']);
} finally {
delete global.fetch;
delete global.window;
}
});