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:
Sameh Khamis committed 2026-10-31 18:03:25 -07:00
1 parent 5bf797ef71
commit 6cb01ff975
9 files changed
+627 -335

No files matched your search

+5
View File
@@ -27,6 +27,8 @@
"src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/RadioPanel.vue",
"src/ui/views/panels/SdrCard.vue", "src/ui/views/panels/SdrCard.vue",
"src/ui/views/panels/RadioTuner.vue", "src/ui/views/panels/RadioTuner.vue",
"src/ui/views/panels/LocalePicker.vue",
"src/ui/localePicker.js",
"src/ui/views/panels/PhoneTabBar.vue", "src/ui/views/panels/PhoneTabBar.vue",
"src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitBriefing.vue",
"src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitContext.vue",
@@ -72,6 +74,7 @@
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/cockpit.js", "src/ui/stores/cockpit.js",
"src/ui/stores/locale.js",
"src/ui/stores/location.js", "src/ui/stores/location.js",
"src/ui/stores/streetLevel.js", "src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue", "src/ui/rail/RailChips.vue",
@@ -998,6 +1001,7 @@
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/cockpit.js", "src/ui/stores/cockpit.js",
"src/ui/stores/locale.js",
"src/ui/stores/location.js", "src/ui/stores/location.js",
"src/ui/stores/display.js" "src/ui/stores/display.js"
], ],
@@ -2975,6 +2979,7 @@
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/cockpit.js", "src/ui/stores/cockpit.js",
"src/ui/stores/locale.js",
"src/ui/stores/location.js", "src/ui/stores/location.js",
"src/ui/stores/display.js" "src/ui/stores/display.js"
], ],
+9 -13
View File
@@ -51,11 +51,12 @@ import {
registerUnsavedWork, registerUnsavedWork,
t, t,
} from '../i18n/index.js'; } from '../i18n/index.js';
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js'; import { applyLocaleChoice } from './localePicker.js';
import { PanelNavigation } from './panelNavigation.js'; import { PanelNavigation } from './panelNavigation.js';
import { applicationPinia, createUiRuntime } from './vueRuntime.js'; import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js'; import { useContextStore } from './stores/context.js';
import { useDisplayStore } from './stores/display.js'; import { useDisplayStore } from './stores/display.js';
import { useLocaleStore } from './stores/locale.js';
import { useLocationStore } from './stores/location.js'; import { useLocationStore } from './stores/location.js';
import { useCockpitStore } from './stores/cockpit.js'; import { useCockpitStore } from './stores/cockpit.js';
@@ -722,8 +723,9 @@ export class StyleManager extends ShellFacade {
} }
/** /**
* Mounts the language picker (src/ui/localePicker.js). A choice reloads the * Publishes the language picker, which ProviderSettingsView renders. A
* page; _confirmLocaleReload asks first when that would lose work. * choice reloads the page; _confirmLocaleReload asks first when that would
* lose work.
* @returns {void} * @returns {void}
*/ */
_initLocaleSelector() { _initLocaleSelector() {
@@ -741,14 +743,9 @@ export class StyleManager extends ShellFacade {
this.cockpitView?.active ? msg('the open cockpit') : null, this.cockpitView?.active ? msg('the open cockpit') : null,
), ),
]; ];
const root = globalThis.document?.getElementById?.('locale-picker'); // A host that does not initialize Core's locale shows no picker.
if (!root) return; if (this._localePickerEnabled === false) return;
if (this._localePickerEnabled === false) { useLocaleStore(applicationPinia()).publishPicker({
root.remove();
return;
}
this._localePicker = mountLocalePicker({
root,
current: getLocale(), current: getLocale(),
onSelect: (locale) => this._chooseLocale(locale), onSelect: (locale) => this._chooseLocale(locale),
}); });
@@ -1772,8 +1769,7 @@ export class StyleManager extends ShellFacade {
this._lifetime.destroy(); this._lifetime.destroy();
for (const unregister of this._unsavedWorkChecks?.splice(0) ?? []) for (const unregister of this._unsavedWorkChecks?.splice(0) ?? [])
unregister(); unregister();
this._localePicker?.destroy(); useLocaleStore(applicationPinia()).publishPicker(null);
this._localePicker = null;
this._recording.destroy(); this._recording.destroy();
this._mobileShell?.destroy(); this._mobileShell?.destroy();
this._panelNavigation?.destroy(); this._panelNavigation?.destroy();
+11 -306
View File
@@ -2,7 +2,7 @@
* Interface language picker: a HUD button in the bottom-right corner that * Interface language picker: a HUD button in the bottom-right corner that
* opens a listbox of the shipped languages. * opens a listbox of the shipped languages.
* *
* The logic is pure and DOM-free; mountLocalePicker() only renders it. The * The logic is pure and DOM-free; the LocalePicker view renders it. The
* shell decides what a choice does (confirm, reload). * shell decides what a choice does (confirm, reload).
* *
* The list follows the ARIA listbox pattern: the button opens it, focus moves * The list follows the ARIA listbox pattern: the button opens it, focus moves
@@ -309,12 +309,10 @@ export function createLocalePickerModel({
}; };
} }
/* ── DOM ───────────────────────────────────────────────────────────────── */ /* ── Presentation ──────────────────────────────────────────────────────── */
const SVG_NS = 'http://www.w3.org/2000/svg';
/** Thin-line globe, check mark and caret, drawn in currentColor. */ /** Thin-line globe, check mark and caret, drawn in currentColor. */
const ICONS = Object.freeze({ export const LOCALE_PICKER_ICONS = Object.freeze({
globe: { globe: {
stroke: [ stroke: [
'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z',
@@ -329,307 +327,14 @@ const ICONS = Object.freeze({
caret: { fill: ['M7 10h10l-5 5.5Z'] }, caret: { fill: ['M7 10h10l-5 5.5Z'] },
}); });
function icon(documentRef, name, className) {
const svg = documentRef.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('focusable', 'false');
svg.setAttribute('class', className);
const { stroke = [], fill = [] } = ICONS[name];
for (const d of stroke) {
const path = documentRef.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'currentColor');
path.setAttribute('stroke-width', '1.4');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.append(path);
}
for (const d of fill) {
const path = documentRef.createElementNS(SVG_NS, 'path');
path.setAttribute('d', d);
path.setAttribute('fill', 'currentColor');
svg.append(path);
}
return svg;
}
function element(documentRef, tag, className) {
const node = documentRef.createElement(tag);
if (className) node.className = className;
return node;
}
/** /**
* Render the picker into `root` and wire it. * The code shown beside a language's name. Chinese variants are named by
* @param {object} input * script rather than region, in English.
* @param {HTMLElement} input.root Empty #locale-picker container. * @param {string} code
* @param {string} input.current Active interface language. * @returns {{text: string, lang: string|undefined}}
* @param {(locale: string) => void} input.onSelect Called after the list
* closes, for a language other than the current one.
* @param {readonly string[]} [input.locales]
* @param {Record<string, string>} [input.nativeNames]
* @param {number} [input.searchThreshold]
* @returns {{open: Function, close: Function, isOpen: () => boolean, destroy: Function}}
*/ */
export function mountLocalePicker({ export function localeOptionCode(code) {
root, return CHINESE_LABELS[code]
current, ? { text: CHINESE_LABELS[code], lang: 'en' }
onSelect, : { text: code.toUpperCase(), lang: undefined };
locales = LOCALES,
nativeNames = LOCALE_NAMES,
searchThreshold = LOCALE_SEARCH_THRESHOLD,
}) {
const documentRef = root.ownerDocument;
const windowRef = documentRef.defaultView;
const model = createLocalePickerModel({
locales,
current,
nativeNames,
localName: createLocalNameReader(current),
searchThreshold,
});
const id = root.id || 'locale-picker';
const ids = {
popup: `${id}-popup`,
heading: `${id}-heading`,
listbox: `${id}-listbox`,
option: (code) => `${id}-option-${code}`,
};
root.textContent = '';
root.classList.add('locale-picker');
const button = element(documentRef, 'button', 'locale-picker-button');
button.type = 'button';
button.setAttribute('aria-haspopup', 'listbox');
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-controls', ids.popup);
button.setAttribute(
'aria-label',
t('Language: {language}', {
language: nativeNames[current] || current,
}),
);
button.title = t('Change the interface language');
const code = element(documentRef, 'span', 'locale-picker-code');
code.setAttribute('translate', 'no');
code.setAttribute('aria-hidden', 'true');
code.textContent = current.toUpperCase();
button.append(
icon(documentRef, 'globe', 'locale-picker-globe'),
code,
icon(documentRef, 'caret', 'locale-picker-caret'),
);
const popup = element(documentRef, 'div', 'locale-picker-popup');
popup.id = ids.popup;
popup.hidden = true;
const heading = element(documentRef, 'div', 'locale-picker-heading');
heading.id = ids.heading;
heading.textContent = t('Interface language');
popup.append(heading);
const listbox = element(documentRef, 'ul', 'locale-picker-list ui-scroll');
listbox.id = ids.listbox;
listbox.setAttribute('role', 'listbox');
listbox.setAttribute('aria-labelledby', ids.heading);
listbox.tabIndex = -1;
let search = null;
if (model.searchable) {
search = element(documentRef, 'input', 'locale-picker-search');
search.type = 'search';
search.autocomplete = 'off';
search.spellcheck = false;
search.setAttribute('role', 'combobox');
search.setAttribute('aria-autocomplete', 'list');
search.setAttribute('aria-expanded', 'true');
search.setAttribute('aria-controls', ids.listbox);
search.setAttribute('aria-label', t('Search languages'));
search.placeholder = t('Search languages');
popup.append(search);
}
popup.append(listbox);
const empty = element(documentRef, 'p', 'locale-picker-empty');
empty.setAttribute('role', 'status');
empty.textContent = t('No matching languages');
empty.hidden = true;
popup.append(empty);
root.append(button, popup);
const optionNodes = new Map();
for (const option of model.options) {
const item = element(documentRef, 'li', 'locale-picker-option');
item.id = ids.option(option.code);
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', String(option.selected));
item.lang = option.code;
item.dataset.locale = option.code;
const name = element(documentRef, 'span', 'locale-picker-native');
name.textContent = option.nativeName;
// Chinese variants are distinguished by script rather than region.
const meta = element(documentRef, 'span', 'locale-picker-meta');
const codeLabel = element(documentRef, 'span', 'locale-picker-option-code');
codeLabel.setAttribute('translate', 'no');
codeLabel.textContent =
CHINESE_LABELS[option.code] || option.code.toUpperCase();
if (CHINESE_LABELS[option.code]) codeLabel.lang = 'en';
meta.append(codeLabel);
const text = element(documentRef, 'span', 'locale-picker-text');
text.append(name, meta);
item.append(text);
if (option.selected)
item.append(icon(documentRef, 'check', 'locale-picker-check'));
optionNodes.set(option.code, item);
}
const focusTarget = () => search || listbox;
const render = () => {
const visible = new Set(model.visible.map((option) => option.code));
listbox.replaceChildren(
...model.visible.map((option) => optionNodes.get(option.code)),
);
for (const [localeCode, item] of optionNodes) {
item.classList.toggle(
'is-active',
visible.has(localeCode) && model.activeOption?.code === localeCode,
);
}
empty.hidden = model.visible.length > 0;
const activeNode = model.activeOption
? optionNodes.get(model.activeOption.code)
: null;
if (activeNode) {
focusTarget().setAttribute('aria-activedescendant', activeNode.id);
activeNode.scrollIntoView?.({ block: 'nearest' });
} else {
focusTarget().removeAttribute('aria-activedescendant');
}
};
let swallowSpaceKeyup = false;
const close = ({ restoreFocus = false } = {}) => {
if (!model.open) return;
model.closeList();
popup.hidden = true;
root.classList.remove('is-open');
button.setAttribute('aria-expanded', 'false');
windowRef?.removeEventListener('keydown', onWindowKeyDown, true);
documentRef.removeEventListener('pointerdown', onDocumentPointerDown, true);
if (restoreFocus) button.focus({ preventScroll: true });
};
const choose = (localeCode) => {
close({ restoreFocus: true });
if (localeCode !== current) onSelect(localeCode);
};
const open = () => {
if (model.open) return;
model.openList();
if (search) search.value = '';
popup.hidden = false;
root.classList.add('is-open');
button.setAttribute('aria-expanded', 'true');
render();
// Window capture runs before the document-level shortcut handlers
// (cockpit, hotkeys, push-to-talk), so keys typed in the list stay here.
windowRef?.addEventListener('keydown', onWindowKeyDown, true);
documentRef.addEventListener('pointerdown', onDocumentPointerDown, true);
focusTarget().focus({ preventScroll: true });
};
function onWindowKeyDown(event) {
if (!model.open || event.isComposing) return;
const inside = root.contains(event.target);
if (!inside && event.key !== 'Escape') return;
const result = model.keydown(event);
if (!result.handled) return;
event.preventDefault();
event.stopImmediatePropagation();
if (result.close) close({ restoreFocus: true });
else if (result.choose) {
if (event.key === ' ') swallowSpaceKeyup = true;
choose(result.choose);
} else render();
}
function onDocumentPointerDown(event) {
if (!root.contains(event.target)) close();
}
const onButtonClick = () => {
if (model.open) close({ restoreFocus: true });
else open();
};
const onButtonKeyDown = (event) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
event.stopPropagation();
open();
};
// Space chose an option and moved focus here; its keyup must not click.
const onButtonKeyUp = (event) => {
if (event.key !== ' ' || !swallowSpaceKeyup) return;
swallowSpaceKeyup = false;
event.preventDefault();
};
const optionFromEvent = (event) =>
event.target?.closest?.('[role="option"]')?.dataset.locale || null;
const onListClick = (event) => {
const localeCode = optionFromEvent(event);
if (localeCode) choose(localeCode);
};
const onListPointerMove = (event) => {
const localeCode = optionFromEvent(event);
if (!localeCode || model.activeOption?.code === localeCode) return;
model.setActive(
model.visible.findIndex((option) => option.code === localeCode),
);
render();
};
const onSearchInput = () => {
model.setQuery(search.value);
render();
};
const onFocusOut = (event) => {
const next = event.relatedTarget;
if (model.open && next && !root.contains(next)) close();
};
button.addEventListener('click', onButtonClick);
button.addEventListener('keydown', onButtonKeyDown);
button.addEventListener('keyup', onButtonKeyUp);
listbox.addEventListener('click', onListClick);
listbox.addEventListener('pointermove', onListPointerMove);
search?.addEventListener('input', onSearchInput);
root.addEventListener('focusout', onFocusOut);
return {
open,
close,
isOpen: () => model.open,
destroy() {
close();
button.removeEventListener('click', onButtonClick);
button.removeEventListener('keydown', onButtonKeyDown);
button.removeEventListener('keyup', onButtonKeyUp);
listbox.removeEventListener('click', onListClick);
listbox.removeEventListener('pointermove', onListPointerMove);
search?.removeEventListener('input', onSearchInput);
root.removeEventListener('focusout', onFocusOut);
root.textContent = '';
},
};
} }
+351
View File
@@ -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);
});
+21 -13
View File
@@ -4,6 +4,8 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { StyleManager } from './applicationShell.js'; import { StyleManager } from './applicationShell.js';
import { useLocaleStore } from './stores/locale.js';
import { applicationPinia } from './vueRuntime.js';
import { import {
describeUnsavedWork, describeUnsavedWork,
msg, msg,
@@ -12,7 +14,8 @@ import {
} from '../i18n/index.js'; } from '../i18n/index.js';
/** Wire the shell's language choice with the given shell state. No picker /** Wire the shell's language choice with the given shell state. No picker
* markup here: the picker's own behaviour is covered by localePicker.test. */ * markup here: the picker's own behaviour is covered by localePicker.test
* and localePickerView.test. */
function renderSwitch({ calDirty = false, cockpit = false } = {}) { function renderSwitch({ calDirty = false, cockpit = false } = {}) {
const previous = { const previous = {
document: globalThis.document, document: globalThis.document,
@@ -137,20 +140,25 @@ test('the language already active neither asks nor reloads', () => {
} }
}); });
test('a host without Core locales removes the picker instead of mounting it', () => { test('a host without Core locales shows no picker; otherwise the shell publishes one', () => {
const previous = globalThis.document; const store = useLocaleStore(applicationPinia());
let removed = 0; store.publishPicker(null);
const root = { remove: () => removed++ }; const disabled = { services: {}, _localePickerEnabled: false };
globalThis.document = { const enabled = {
getElementById: (id) => (id === 'locale-picker' ? root : null), services: {},
_chooseLocale: (locale) => chosen.push(locale),
}; };
const shell = { services: {}, _localePickerEnabled: false }; const chosen = [];
try { try {
StyleManager.prototype._initLocaleSelector.call(shell); StyleManager.prototype._initLocaleSelector.call(disabled);
assert.equal(removed, 1); assert.equal(store.picker, null);
assert.equal(shell._localePicker, undefined); StyleManager.prototype._initLocaleSelector.call(enabled);
assert.equal(store.picker.current, 'en');
store.picker.onSelect('fr');
assert.deepEqual(chosen, ['fr']);
} finally { } finally {
for (const unregister of shell._unsavedWorkChecks) unregister(); store.publishPicker(null);
globalThis.document = previous; for (const shell of [disabled, enabled])
for (const unregister of shell._unsavedWorkChecks ?? []) unregister();
} }
}); });
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* The interface language picker as its view renders it. The shell publishes
* the active language and what a choice does once it decides to show the
* picker; a host that does not initialize Core's locale never does.
*/
export const useLocaleStore = defineStore('gev-locale', () => {
/** `{current, onSelect}`, or null while no picker is shown. */
const picker = shallowRef(null);
/** @param {?{current: string, onSelect: (locale: string) => void}} next */
function publishPicker(next) {
picker.value = next && markRaw(next);
}
return { picker, publishPicker };
});
+1 -1
View File
@@ -1,7 +1,7 @@
/* ============================================================ /* ============================================================
Bottom-right corner row: the POWER UP chip (provider-settings.css) Bottom-right corner row: the POWER UP chip (provider-settings.css)
and, in the very corner, the interface language picker and, in the very corner, the interface language picker
(src/ui/localePicker.js). Logical properties throughout, so a (src/ui/views/panels/LocalePicker.vue). Logical properties throughout, so a
right-to-left page mirrors the row and the list. right-to-left page mirrors the row and the list.
============================================================ */ ============================================================ */
+10 -2
View File
@@ -1,16 +1,24 @@
<script setup>
import { storeToRefs } from 'pinia';
import { useLocaleStore } from '../stores/locale.js';
import LocalePicker from './panels/LocalePicker.vue';
const { picker } = storeToRefs(useLocaleStore());
</script>
<template> <template>
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes <!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
both the chip and this dialog outright when /api/setup/status is not both the chip and this dialog outright when /api/setup/status is not
there to answer (prod builds, LAN visitors). Rows are rendered from there to answer (prod builds, LAN visitors). Rows are rendered from
that payload, so the key registry lives in src/keySetupCore.mjs alone. --> that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
<!-- Bottom-right HUD row: the POWER UP chip (while keys are missing) and <!-- Bottom-right HUD row: the POWER UP chip (while keys are missing) and
the language picker that src/ui/localePicker.js renders into #locale-picker. --> the language picker, once the shell publishes it. -->
<div id="corner-actions"> <div id="corner-actions">
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup"> <button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
<span class="material-symbols-outlined" aria-hidden="true">bolt</span> <span class="material-symbols-outlined" aria-hidden="true">bolt</span>
<span data-key-setup-chip-label data-i18n>POWER UP</span> <span data-key-setup-chip-label data-i18n>POWER UP</span>
</button> </button>
<div id="locale-picker"></div> <LocalePicker v-if="picker" :key="picker.current" :current="picker.current" :on-select="picker.onSelect" />
</div> </div>
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden> <aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
<div class="key-setup-scanline" aria-hidden="true"></div> <div class="key-setup-scanline" aria-hidden="true"></div>
+200
View File
@@ -0,0 +1,200 @@
<script setup>
import {
computed,
nextTick,
onBeforeUnmount,
shallowRef,
useTemplateRef,
} from 'vue';
import { t } from '../../../i18n/index.js';
import { LOCALES, LOCALE_NAMES } from '../../../i18n/locale.js';
import {
LOCALE_PICKER_ICONS,
LOCALE_SEARCH_THRESHOLD,
createLocalNameReader,
createLocalePickerModel,
localeOptionCode,
} from '../../localePicker.js';
import { vClassToggle } from '../../rail/classToggle.js';
// The interface language picker. The model in localePicker.js holds the
// list, search, active option and keyboard rules; this renders it and wires
// the listbox pattern. The shell decides what a choice does.
const props = defineProps({
current: { type: String, required: true },
onSelect: { type: Function, required: true },
locales: { type: Array, default: () => LOCALES },
nativeNames: { type: Object, default: () => LOCALE_NAMES },
searchThreshold: { type: Number, default: LOCALE_SEARCH_THRESHOLD },
});
const model = createLocalePickerModel({
locales: props.locales,
current: props.current,
nativeNames: props.nativeNames,
localName: createLocalNameReader(props.current),
searchThreshold: props.searchThreshold,
});
const id = 'locale-picker';
const ids = {
popup: `${id}-popup`,
heading: `${id}-heading`,
listbox: `${id}-listbox`,
option: (code) => `${id}-option-${code}`,
};
// The model is plain state; each change bumps this so the template rereads it.
const revision = shallowRef(0);
const view = computed(() => {
void revision.value;
return {
open: model.open,
visible: model.visible,
active: model.activeOption?.code ?? null,
};
});
// Each render keeps the active option in view, as the list opens too.
const render = () => {
revision.value += 1;
nextTick(() => {
const code = model.open ? model.activeOption?.code : null;
if (!code) return;
root.value
?.querySelector(`#${CSS.escape(ids.option(code))}`)
?.scrollIntoView?.({ block: 'nearest' });
});
};
const root = useTemplateRef('root');
const button = useTemplateRef('button');
const listbox = useTemplateRef('listbox');
const search = useTemplateRef('search');
const focusTarget = () => search.value || listbox.value;
const activeDescendant = computed(() =>
view.value.active ? ids.option(view.value.active) : undefined,
);
const buttonLabel = t('Language: {language}', {
language: props.nativeNames[props.current] || props.current,
});
let swallowSpaceKeyup = false;
function close({ restoreFocus = false } = {}) {
if (!model.open) return;
model.closeList();
render();
globalThis.removeEventListener('keydown', onWindowKeyDown, true);
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
if (restoreFocus) button.value?.focus({ preventScroll: true });
}
// The shell may ask before switching; the list is closed on screen first.
async function choose(code) {
close({ restoreFocus: true });
if (code === props.current) return;
await nextTick();
props.onSelect(code);
}
async function open() {
if (model.open) return;
model.openList();
if (search.value) search.value.value = '';
render();
// Window capture runs before the document-level shortcut handlers
// (cockpit, hotkeys, push-to-talk), so keys typed in the list stay here.
globalThis.addEventListener('keydown', onWindowKeyDown, true);
document.addEventListener('pointerdown', onDocumentPointerDown, true);
// The popup shows when Vue renders it; focus it after.
await nextTick();
if (model.open) focusTarget()?.focus({ preventScroll: true });
}
function onWindowKeyDown(event) {
if (!model.open || event.isComposing) return;
const inside = root.value?.contains(event.target);
if (!inside && event.key !== 'Escape') return;
const result = model.keydown(event);
if (!result.handled) return;
event.preventDefault();
event.stopImmediatePropagation();
if (result.close) close({ restoreFocus: true });
else if (result.choose) {
if (event.key === ' ') swallowSpaceKeyup = true;
choose(result.choose);
} else render();
}
function onDocumentPointerDown(event) {
if (!root.value?.contains(event.target)) close();
}
function onButtonClick() {
if (model.open) close({ restoreFocus: true });
else open();
}
function onButtonKeyDown(event) {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
event.stopPropagation();
open();
}
// Space chose an option and moved focus here; its keyup must not click.
function onButtonKeyUp(event) {
if (event.key !== ' ' || !swallowSpaceKeyup) return;
swallowSpaceKeyup = false;
event.preventDefault();
}
const optionFromEvent = (event) =>
event.target?.closest?.('[role="option"]')?.dataset.locale || null;
function onListClick(event) {
const code = optionFromEvent(event);
if (code) choose(code);
}
function onListPointerMove(event) {
const code = optionFromEvent(event);
if (!code || model.activeOption?.code === code) return;
model.setActive(model.visible.findIndex((option) => option.code === code));
render();
}
function onSearchInput(event) {
model.setQuery(event.target.value);
render();
}
function onFocusOut(event) {
const next = event.relatedTarget;
if (model.open && next && !root.value?.contains(next)) close();
}
onBeforeUnmount(() => close());
defineExpose({ open, close, isOpen: () => model.open });
</script>
<template>
<div id="locale-picker" ref="root" class="locale-picker" v-class-toggle="{ 'is-open': view.open }" @focusout="onFocusOut">
<button ref="button" class="locale-picker-button" type="button" aria-haspopup="listbox" :aria-expanded="String(view.open)" :aria-controls="ids.popup" :aria-label="buttonLabel" :title="t('Change the interface language')" @click="onButtonClick" @keydown="onButtonKeyDown" @keyup="onButtonKeyUp">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="locale-picker-globe"><path v-for="d in LOCALE_PICKER_ICONS.globe.stroke" :key="d" :d="d" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" /></svg>
<span class="locale-picker-code" translate="no" aria-hidden="true">{{ current.toUpperCase() }}</span>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="locale-picker-caret"><path v-for="d in LOCALE_PICKER_ICONS.caret.fill" :key="d" :d="d" fill="currentColor" /></svg>
</button>
<div :id="ids.popup" class="locale-picker-popup" :hidden="!view.open">
<div :id="ids.heading" class="locale-picker-heading">{{ t('Interface language') }}</div>
<input v-if="model.searchable" ref="search" class="locale-picker-search" type="search" autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" aria-expanded="true" :aria-controls="ids.listbox" :aria-label="t('Search languages')" :placeholder="t('Search languages')" :aria-activedescendant="activeDescendant" @input="onSearchInput" />
<ul ref="listbox" :id="ids.listbox" class="locale-picker-list ui-scroll" role="listbox" :aria-labelledby="ids.heading" tabindex="-1" :aria-activedescendant="model.searchable ? undefined : activeDescendant" @click="onListClick" @pointermove="onListPointerMove">
<li v-for="option in view.visible" :key="option.code" :id="ids.option(option.code)" class="locale-picker-option" v-class-toggle="{ 'is-active': view.active === option.code }" role="option" :aria-selected="String(option.selected)" :lang="option.code" :data-locale="option.code">
<span class="locale-picker-text"><span class="locale-picker-native">{{ option.nativeName }}</span><span class="locale-picker-meta"><span class="locale-picker-option-code" translate="no" :lang="localeOptionCode(option.code).lang">{{ localeOptionCode(option.code).text }}</span></span></span>
<svg v-if="option.selected" viewBox="0 0 24 24" aria-hidden="true" focusable="false" class="locale-picker-check"><path v-for="d in LOCALE_PICKER_ICONS.check.stroke" :key="d" :d="d" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" /></svg>
</li>
</ul>
<p class="locale-picker-empty" role="status" :hidden="view.visible.length > 0">{{ t('No matching languages') }}</p>
</div>
</div>
</template>