From 6cb01ff9754bd26b9e4fee2ef4a8c7c5ca9f7553 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 18:03:25 -0700 Subject: [PATCH] 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. --- scripts/package-boundaries.json | 5 + src/ui/applicationShell.js | 22 +- src/ui/localePicker.js | 317 +---------------------- src/ui/localePickerView.test.mjs | 351 ++++++++++++++++++++++++++ src/ui/localeSwitchReload.test.mjs | 34 ++- src/ui/stores/locale.js | 19 ++ src/ui/styles/locale-picker.css | 2 +- src/ui/views/ProviderSettingsView.vue | 12 +- src/ui/views/panels/LocalePicker.vue | 200 +++++++++++++++ 9 files changed, 627 insertions(+), 335 deletions(-) create mode 100644 src/ui/localePickerView.test.mjs create mode 100644 src/ui/stores/locale.js create mode 100644 src/ui/views/panels/LocalePicker.vue diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index edfa93735..9fbb1856a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -27,6 +27,8 @@ "src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/SdrCard.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/CockpitBriefing.vue", "src/ui/views/panels/CockpitContext.vue", @@ -72,6 +74,7 @@ "src/ui/stores/radio.js", "src/ui/stores/context.js", "src/ui/stores/cockpit.js", + "src/ui/stores/locale.js", "src/ui/stores/location.js", "src/ui/stores/streetLevel.js", "src/ui/rail/RailChips.vue", @@ -998,6 +1001,7 @@ "src/ui/stores/radio.js", "src/ui/stores/context.js", "src/ui/stores/cockpit.js", + "src/ui/stores/locale.js", "src/ui/stores/location.js", "src/ui/stores/display.js" ], @@ -2975,6 +2979,7 @@ "src/ui/stores/radio.js", "src/ui/stores/context.js", "src/ui/stores/cockpit.js", + "src/ui/stores/locale.js", "src/ui/stores/location.js", "src/ui/stores/display.js" ], diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index ce958b81f..3bf2c9322 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -51,11 +51,12 @@ import { registerUnsavedWork, t, } from '../i18n/index.js'; -import { applyLocaleChoice, mountLocalePicker } from './localePicker.js'; +import { applyLocaleChoice } from './localePicker.js'; import { PanelNavigation } from './panelNavigation.js'; import { applicationPinia, createUiRuntime } from './vueRuntime.js'; import { useContextStore } from './stores/context.js'; import { useDisplayStore } from './stores/display.js'; +import { useLocaleStore } from './stores/locale.js'; import { useLocationStore } from './stores/location.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 - * page; _confirmLocaleReload asks first when that would lose work. + * Publishes the language picker, which ProviderSettingsView renders. A + * choice reloads the page; _confirmLocaleReload asks first when that would + * lose work. * @returns {void} */ _initLocaleSelector() { @@ -741,14 +743,9 @@ export class StyleManager extends ShellFacade { this.cockpitView?.active ? msg('the open cockpit') : null, ), ]; - const root = globalThis.document?.getElementById?.('locale-picker'); - if (!root) return; - if (this._localePickerEnabled === false) { - root.remove(); - return; - } - this._localePicker = mountLocalePicker({ - root, + // A host that does not initialize Core's locale shows no picker. + if (this._localePickerEnabled === false) return; + useLocaleStore(applicationPinia()).publishPicker({ current: getLocale(), onSelect: (locale) => this._chooseLocale(locale), }); @@ -1772,8 +1769,7 @@ export class StyleManager extends ShellFacade { this._lifetime.destroy(); for (const unregister of this._unsavedWorkChecks?.splice(0) ?? []) unregister(); - this._localePicker?.destroy(); - this._localePicker = null; + useLocaleStore(applicationPinia()).publishPicker(null); this._recording.destroy(); this._mobileShell?.destroy(); this._panelNavigation?.destroy(); diff --git a/src/ui/localePicker.js b/src/ui/localePicker.js index f21559e1d..b10b9917e 100644 --- a/src/ui/localePicker.js +++ b/src/ui/localePicker.js @@ -2,7 +2,7 @@ * Interface language picker: a HUD button in the bottom-right corner that * 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). * * The list follows the ARIA listbox pattern: the button opens it, focus moves @@ -309,12 +309,10 @@ export function createLocalePickerModel({ }; } -/* ── DOM ───────────────────────────────────────────────────────────────── */ - -const SVG_NS = 'http://www.w3.org/2000/svg'; +/* ── Presentation ──────────────────────────────────────────────────────── */ /** Thin-line globe, check mark and caret, drawn in currentColor. */ -const ICONS = Object.freeze({ +export const LOCALE_PICKER_ICONS = Object.freeze({ globe: { stroke: [ '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'] }, }); -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. - * @param {object} input - * @param {HTMLElement} input.root Empty #locale-picker container. - * @param {string} input.current Active interface language. - * @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} [input.nativeNames] - * @param {number} [input.searchThreshold] - * @returns {{open: Function, close: Function, isOpen: () => boolean, destroy: Function}} + * The code shown beside a language's name. Chinese variants are named by + * script rather than region, in English. + * @param {string} code + * @returns {{text: string, lang: string|undefined}} */ -export function mountLocalePicker({ - root, - current, - onSelect, - 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 = ''; - }, - }; +export function localeOptionCode(code) { + return CHINESE_LABELS[code] + ? { text: CHINESE_LABELS[code], lang: 'en' } + : { text: code.toUpperCase(), lang: undefined }; } diff --git a/src/ui/localePickerView.test.mjs b/src/ui/localePickerView.test.mjs new file mode 100644 index 000000000..a047178da --- /dev/null +++ b/src/ui/localePickerView.test.mjs @@ -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); +}); diff --git a/src/ui/localeSwitchReload.test.mjs b/src/ui/localeSwitchReload.test.mjs index e035f070c..d5c766dc1 100644 --- a/src/ui/localeSwitchReload.test.mjs +++ b/src/ui/localeSwitchReload.test.mjs @@ -4,6 +4,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { StyleManager } from './applicationShell.js'; +import { useLocaleStore } from './stores/locale.js'; +import { applicationPinia } from './vueRuntime.js'; import { describeUnsavedWork, msg, @@ -12,7 +14,8 @@ import { } from '../i18n/index.js'; /** 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 } = {}) { const previous = { 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', () => { - const previous = globalThis.document; - let removed = 0; - const root = { remove: () => removed++ }; - globalThis.document = { - getElementById: (id) => (id === 'locale-picker' ? root : null), +test('a host without Core locales shows no picker; otherwise the shell publishes one', () => { + const store = useLocaleStore(applicationPinia()); + store.publishPicker(null); + const disabled = { services: {}, _localePickerEnabled: false }; + const enabled = { + services: {}, + _chooseLocale: (locale) => chosen.push(locale), }; - const shell = { services: {}, _localePickerEnabled: false }; + const chosen = []; try { - StyleManager.prototype._initLocaleSelector.call(shell); - assert.equal(removed, 1); - assert.equal(shell._localePicker, undefined); + StyleManager.prototype._initLocaleSelector.call(disabled); + assert.equal(store.picker, null); + StyleManager.prototype._initLocaleSelector.call(enabled); + assert.equal(store.picker.current, 'en'); + store.picker.onSelect('fr'); + assert.deepEqual(chosen, ['fr']); } finally { - for (const unregister of shell._unsavedWorkChecks) unregister(); - globalThis.document = previous; + store.publishPicker(null); + for (const shell of [disabled, enabled]) + for (const unregister of shell._unsavedWorkChecks ?? []) unregister(); } }); diff --git a/src/ui/stores/locale.js b/src/ui/stores/locale.js new file mode 100644 index 000000000..619e908bc --- /dev/null +++ b/src/ui/stores/locale.js @@ -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 }; +}); diff --git a/src/ui/styles/locale-picker.css b/src/ui/styles/locale-picker.css index 6f5d994b3..418997c9d 100644 --- a/src/ui/styles/locale-picker.css +++ b/src/ui/styles/locale-picker.css @@ -1,7 +1,7 @@ /* ============================================================ Bottom-right corner row: the POWER UP chip (provider-settings.css) 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. ============================================================ */ diff --git a/src/ui/views/ProviderSettingsView.vue b/src/ui/views/ProviderSettingsView.vue index 8f2024fe5..1b05cc6e3 100644 --- a/src/ui/views/ProviderSettingsView.vue +++ b/src/ui/views/ProviderSettingsView.vue @@ -1,16 +1,24 @@ + +