Files
gods-eye-view/src/ui/localePickerView.test.mjs
T
Sameh Khamis 6cb01ff975 feat(ui): render the language picker as a Vue component
LocalePicker renders the existing picker model with the same markup and
listbox behaviour; ProviderSettingsView shows it while the shell publishes
the active language and its choice handler to the gev-locale store. A host
passing localePicker: false publishes nothing. A choice reaches the shell
after the list is closed on screen, and every opening keeps the active
option in view.
2026-10-31 18:03:25 -07:00

352 lines
12 KiB
JavaScript

// src/ui/localePickerView.test.mjs — the language picker renders the
// model in localePicker.js and wires the listbox pattern, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, h, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useLocaleStore } from './stores/locale.js';
import ProviderSettingsView from './views/ProviderSettingsView.vue';
import LocalePicker from './views/panels/LocalePicker.vue';
const NAMES = {
en: 'English',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
'zh-CN': '简体中文',
};
function fixture(t, { searchThreshold = 8 } = {}) {
document.body.replaceChildren();
const outside = document.body.appendChild(document.createElement('button'));
const host = document.body.appendChild(document.createElement('div'));
const chosen = [];
// Whether the list was closed on screen when each choice arrived.
const closedAtChoice = [];
let picker = null;
const app = createApp({
render: () =>
h(LocalePicker, {
ref: (instance) => {
picker = instance;
},
current: 'en',
locales: Object.keys(NAMES),
nativeNames: NAMES,
searchThreshold,
onSelect: (locale) => {
chosen.push(locale);
closedAtChoice.push(
document.getElementById('locale-picker-popup').hidden,
);
},
}),
});
app.mount(host);
let mounted = true;
const unmount = () => {
if (mounted) app.unmount();
mounted = false;
};
t.after(unmount);
const $ = (selector) => document.querySelector(selector);
const key = (target, init) =>
target.dispatchEvent(
new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
...init,
}),
);
const settle = async () => {
await nextTick();
await nextTick();
};
return {
$,
outside,
chosen,
closedAtChoice,
key,
settle,
unmount,
picker: () => picker,
};
}
const optionCodes = () =>
[...document.querySelectorAll('.locale-picker-option')].map(
(option) => option.dataset.locale,
);
test('closed, the button names the active language and the list is hidden', (t) => {
const { $ } = fixture(t);
const button = $('.locale-picker-button');
assert.equal(button.getAttribute('aria-label'), 'Language: English');
assert.equal(button.getAttribute('aria-expanded'), 'false');
assert.equal(button.getAttribute('aria-controls'), 'locale-picker-popup');
assert.equal($('.locale-picker-code').textContent, 'EN');
assert.equal($('#locale-picker-popup').hidden, true);
assert.equal($('#locale-picker').classList.contains('is-open'), false);
// The current language first, then the rest by their own names.
assert.deepEqual(optionCodes(), ['en', 'de', 'es', 'fr', 'zh-CN']);
assert.equal($('[data-locale="en"]').getAttribute('aria-selected'), 'true');
assert.ok($('[data-locale="en"] .locale-picker-check'));
assert.equal($('[data-locale="de"] .locale-picker-check'), null);
const chinese = $('[data-locale="zh-CN"] .locale-picker-option-code');
assert.equal(chinese.textContent, 'Chinese (Simplified)');
assert.equal(chinese.lang, 'en');
assert.equal($('[data-locale="de"] .locale-picker-option-code').lang, '');
assert.equal($('.locale-picker-search'), null);
});
test('opening focuses the list on the current language; keys move and choose', async (t) => {
const { $, chosen, key, settle } = fixture(t);
const button = $('.locale-picker-button');
button.click();
await settle();
const list = $('#locale-picker-listbox');
assert.equal($('#locale-picker-popup').hidden, false);
assert.equal(button.getAttribute('aria-expanded'), 'true');
assert.equal($('#locale-picker').classList.contains('is-open'), true);
assert.equal(document.activeElement?.id, 'locale-picker-listbox');
assert.equal(
list.getAttribute('aria-activedescendant'),
'locale-picker-option-en',
);
key(list, { key: 'ArrowDown' });
await settle();
assert.equal(
list.getAttribute('aria-activedescendant'),
'locale-picker-option-de',
);
assert.equal($('[data-locale="de"]').classList.contains('is-active'), true);
assert.equal($('[data-locale="en"]').classList.contains('is-active'), false);
key(list, { key: 'Enter' });
await settle();
assert.deepEqual(chosen, ['de']);
assert.equal($('#locale-picker-popup').hidden, true);
assert.equal(document.activeElement === button, true);
});
test('choosing the active language closes without a choice; Escape and outside presses close', async (t) => {
const { $, chosen, key, outside, settle } = fixture(t);
const button = $('.locale-picker-button');
button.click();
await settle();
$('[data-locale="en"]').click();
await settle();
assert.deepEqual(chosen, []);
assert.equal($('#locale-picker-popup').hidden, true);
button.click();
await settle();
key(document.body, { key: 'Escape' });
await settle();
assert.equal($('#locale-picker-popup').hidden, true);
assert.equal(document.activeElement === button, true);
button.click();
await settle();
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await settle();
assert.equal($('#locale-picker-popup').hidden, true);
});
test('while open, keys typed in the list stay out of the page shortcuts', async (t) => {
const { $, key, settle } = fixture(t);
let reached = 0;
const shortcut = () => reached++;
document.addEventListener('keydown', shortcut);
t.after(() => document.removeEventListener('keydown', shortcut));
$('.locale-picker-button').click();
await settle();
key($('#locale-picker-listbox'), { key: 'f' });
await settle();
assert.equal(reached, 0);
assert.equal(
$('#locale-picker-listbox').getAttribute('aria-activedescendant'),
'locale-picker-option-fr',
);
$('.locale-picker-button').click();
await settle();
key(document.body, { key: 'f' });
assert.equal(reached, 1);
});
test('above the threshold a search field filters the list and owns the active option', async (t) => {
const { $, chosen, key, settle } = fixture(t, { searchThreshold: 3 });
$('.locale-picker-button').click();
await settle();
const search = $('.locale-picker-search');
assert.equal(document.activeElement === search, true);
assert.equal(
search.getAttribute('aria-activedescendant'),
'locale-picker-option-en',
);
assert.equal(
$('#locale-picker-listbox').hasAttribute('aria-activedescendant'),
false,
);
search.value = 'espa';
search.dispatchEvent(new Event('input', { bubbles: true }));
await settle();
assert.deepEqual(optionCodes(), ['es']);
assert.equal($('.locale-picker-empty').hidden, true);
search.value = 'zzz';
search.dispatchEvent(new Event('input', { bubbles: true }));
await settle();
assert.deepEqual(optionCodes(), []);
assert.equal($('.locale-picker-empty').hidden, false);
assert.equal(search.hasAttribute('aria-activedescendant'), false);
search.value = 'fran';
search.dispatchEvent(new Event('input', { bubbles: true }));
await settle();
key(search, { key: 'Enter' });
await settle();
assert.deepEqual(chosen, ['fr']);
// Reopening starts from an empty search with every language.
$('.locale-picker-button').click();
await settle();
assert.equal($('.locale-picker-search').value, '');
assert.equal(optionCodes().length, 5);
});
test('focus leaving the picker closes it', async (t) => {
const { $, outside, picker, settle } = fixture(t);
$('.locale-picker-button').click();
await settle();
$('#locale-picker').dispatchEvent(
new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }),
);
await settle();
assert.equal($('#locale-picker-popup').hidden, true);
assert.equal(picker().isOpen(), false);
});
test('a choice arrives after the list is closed on screen', async (t) => {
const { $, chosen, closedAtChoice, settle } = fixture(t);
$('.locale-picker-button').click();
await settle();
$('[data-locale="fr"]').click();
await settle();
assert.deepEqual(chosen, ['fr']);
assert.deepEqual(closedAtChoice, [true]);
});
test('every opening scrolls the active option into view', async (t) => {
const { $, settle } = fixture(t);
const scrolled = [];
const original = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = function () {
scrolled.push(this.dataset.locale);
};
t.after(() => {
HTMLElement.prototype.scrollIntoView = original;
});
const button = $('.locale-picker-button');
button.click();
await settle();
button.click();
await settle();
button.click();
await settle();
assert.deepEqual(scrolled, ['en', 'en']);
});
test('closing or unmounting an open picker releases its page listeners', async (t) => {
const { $, settle, unmount } = fixture(t);
const added = [];
const removed = [];
const spy = (target, list) => {
const add = target.addEventListener;
const remove = target.removeEventListener;
target.addEventListener = function (type, fn, capture) {
added.push(type);
return add.call(this, type, fn, capture);
};
target.removeEventListener = function (type, fn, capture) {
list.push(type);
return remove.call(this, type, fn, capture);
};
t.after(() => {
target.addEventListener = add;
target.removeEventListener = remove;
});
};
spy(globalThis, removed);
spy(document, removed);
$('.locale-picker-button').click();
await settle();
assert.deepEqual(added.sort(), ['keydown', 'pointerdown']);
$('.locale-picker-button').click();
await settle();
assert.deepEqual(removed.sort(), ['keydown', 'pointerdown']);
removed.length = 0;
$('.locale-picker-button').click();
await settle();
unmount();
assert.deepEqual(removed.sort(), ['keydown', 'pointerdown']);
});
test('arrow keys on the button open the list; the Space that chose an option does not click it', async (t) => {
const { $, chosen, key, settle } = fixture(t);
const button = $('.locale-picker-button');
key(button, { key: 'ArrowDown' });
await settle();
assert.equal($('#locale-picker-popup').hidden, false);
const list = $('#locale-picker-listbox');
key(list, { key: 'ArrowDown' });
key(list, { key: ' ' });
await settle();
assert.deepEqual(chosen, ['de']);
assert.equal(document.activeElement === button, true);
const keyup = new KeyboardEvent('keyup', {
key: ' ',
bubbles: true,
cancelable: true,
});
button.dispatchEvent(keyup);
assert.equal(keyup.defaultPrevented, true);
const nextKeyup = new KeyboardEvent('keyup', {
key: ' ',
bubbles: true,
cancelable: true,
});
button.dispatchEvent(nextKeyup);
assert.equal(nextKeyup.defaultPrevented, false);
});
test('pointing at an option makes it the active one', async (t) => {
const { $, settle } = fixture(t);
$('.locale-picker-button').click();
await settle();
$('[data-locale="es"] .locale-picker-native').dispatchEvent(
new PointerEvent('pointermove', { bubbles: true }),
);
await settle();
assert.equal(
$('#locale-picker-listbox').getAttribute('aria-activedescendant'),
'locale-picker-option-es',
);
});
test('the provider settings corner shows the picker only while the shell publishes one', async (t) => {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(ProviderSettingsView).use(pinia);
app.mount(host);
t.after(() => app.unmount());
const store = useLocaleStore(pinia);
assert.equal(document.getElementById('locale-picker'), null);
store.publishPicker({ current: 'de', onSelect() {} });
await nextTick();
const code = document.querySelector('#corner-actions .locale-picker-code');
assert.equal(code?.textContent, 'DE');
store.publishPicker(null);
await nextTick();
assert.equal(document.getElementById('locale-picker'), null);
});