mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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 = '';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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,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.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user