mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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.
This commit is contained in:
1 parent
5bf797ef71
commit
6cb01ff975
9 files changed
+627
-335
No files matched your search
@@ -0,0 +1,351 @@
|
||||
// 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);
|
||||
});
|
||||
Reference in new issue
Block a user