mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge remote-tracking branch 'origin/i18n-spanish' into i18n-spanish
This commit is contained in:
commit
05fdaaa97c
22 files changed
+1381
-140
No files matched your search
@@ -3209,8 +3209,8 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives
|
|||||||
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
|
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
|
||||||
`navigator.languages` → English. `?lang=` is not stored: it applies while it
|
`navigator.languages` → English. `?lang=` is not stored: it applies while it
|
||||||
stays in the address, and the copied share link drops it, so a recipient keeps
|
stays in the address, and the copied share link drops it, so a recipient keeps
|
||||||
their own language. The language switch in the Visual Presets dock stores the
|
their own language. The language picker in the bottom-right corner (a globe
|
||||||
choice, removes `?lang=` (keeps it only where storage is blocked) and reloads,
|
button beside POWER UP, `src/ui/localePicker.js`) stores the choice, removes `?lang=` (keeps it only where storage is blocked) and reloads,
|
||||||
so nothing re-renders live; it asks first when the reload would lose work
|
so nothing re-renders live; it asks first when the reload would lose work
|
||||||
(unsaved CCTV calibration, the open cockpit, Draw marks on the board,
|
(unsaved CCTV calibration, the open cockpit, Draw marks on the board,
|
||||||
imported map files, an active voice session — each registered through
|
imported map files, an active voice session — each registered through
|
||||||
|
|||||||
+2
-2
@@ -33,7 +33,7 @@ label.textContent = t(FEED_STATE_LABELS[state]);
|
|||||||
<input placeholder="Search any location..." data-i18n-placeholder />
|
<input placeholder="Search any location..." data-i18n-placeholder />
|
||||||
```
|
```
|
||||||
|
|
||||||
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language switch asks before reloading.
|
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ npm run i18n -- --prune # drop entries whose English no longer exists
|
|||||||
|
|
||||||
Changing the English changes the key, so the old translation goes unused and the new text shows as missing. Add the translation to `src/i18n/locales/ru.json` (or ask a translator), prune, and run `npm test`, which runs the same check and fails until every language is complete.
|
Changing the English changes the key, so the old translation goes unused and the new text shows as missing. Add the translation to `src/i18n/locales/ru.json` (or ask a translator), prune, and run `npm test`, which runs the same check and fails until every language is complete.
|
||||||
|
|
||||||
To see a language, open the app with `?lang=ru`, or use the language switch in the Visual Presets dock.
|
To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner).
|
||||||
|
|
||||||
An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`.
|
An empty translation is reported, and so is one that is exactly the English ("Bloom" → "Bloom"), including any plural form left as the English: it is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keye
|
|||||||
|
|
||||||
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
|
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
|
||||||
|
|
||||||
The page picks its language from `?lang=`, then the choice stored by the language switch in the Visual Presets dock, then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page.
|
The page picks its language from `?lang=`, then the choice stored by the language picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page.
|
||||||
|
|
||||||
## The check
|
## The check
|
||||||
|
|
||||||
|
|||||||
@@ -635,6 +635,7 @@
|
|||||||
"src/styles/surveillance.js",
|
"src/styles/surveillance.js",
|
||||||
"src/styles/thermal.js",
|
"src/styles/thermal.js",
|
||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
|
"src/ui/localePicker.js",
|
||||||
"src/ui/cyberSonarControls.js",
|
"src/ui/cyberSonarControls.js",
|
||||||
"src/ui/locationNavigation.js",
|
"src/ui/locationNavigation.js",
|
||||||
"src/ui/cockpitCoordinator.js",
|
"src/ui/cockpitCoordinator.js",
|
||||||
@@ -2074,6 +2075,7 @@
|
|||||||
"src/styles/surveillance.js",
|
"src/styles/surveillance.js",
|
||||||
"src/styles/thermal.js",
|
"src/styles/thermal.js",
|
||||||
"src/ui/applicationShell.js",
|
"src/ui/applicationShell.js",
|
||||||
|
"src/ui/localePicker.js",
|
||||||
"src/ui/cyberSonarControls.js",
|
"src/ui/cyberSonarControls.js",
|
||||||
"src/ui/locationNavigation.js",
|
"src/ui/locationNavigation.js",
|
||||||
"src/ui/cockpitCoordinator.js",
|
"src/ui/cockpitCoordinator.js",
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,8 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
|
|||||||
export const DEFAULT_LOCALE = 'en';
|
export const DEFAULT_LOCALE = 'en';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shipped locales, in selector order. Adding one: locales/<code>.json, an
|
* Shipped locales. The picker (src/ui/localePicker.js) lists the active one
|
||||||
|
* first and the rest by LOCALE_NAMES. Adding one: locales/<code>.json, an
|
||||||
* import in index.js, and an entry here (see docs/TRANSLATORS.md).
|
* import in index.js, and an entry here (see docs/TRANSLATORS.md).
|
||||||
*/
|
*/
|
||||||
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
|
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
"Globe actions": "Acciones del globo",
|
"Globe actions": "Acciones del globo",
|
||||||
"Reset camera and return to full globe view": "Restablecer la cámara y volver a la vista del globo completo",
|
"Reset camera and return to full globe view": "Restablecer la cámara y volver a la vista del globo completo",
|
||||||
"loading frames": "cargando fotogramas",
|
"loading frames": "cargando fotogramas",
|
||||||
"Language": "Idioma",
|
"Language: {language}": "Idioma: {language}",
|
||||||
|
"Change the interface language": "Cambiar el idioma de la interfaz",
|
||||||
|
"Interface language": "Idioma de la interfaz",
|
||||||
|
"Search languages": "Buscar idioma",
|
||||||
|
"No matching languages": "No hay idiomas que coincidan",
|
||||||
"Loading Google 3D Tiles...": "Cargando Google 3D Tiles...",
|
"Loading Google 3D Tiles...": "Cargando Google 3D Tiles...",
|
||||||
"Loading the keyless globe...": "Cargando el globo sin clave...",
|
"Loading the keyless globe...": "Cargando el globo sin clave...",
|
||||||
"Initializing systems...": "Inicializando sistemas...",
|
"Initializing systems...": "Inicializando sistemas...",
|
||||||
|
|||||||
@@ -688,7 +688,11 @@
|
|||||||
"Keep location tray open": "Держать панель места открытой",
|
"Keep location tray open": "Держать панель места открытой",
|
||||||
"Search any location": "Поиск любого места",
|
"Search any location": "Поиск любого места",
|
||||||
"Navigation, voice, and visual preset controls": "Управление навигацией, голосом и визуальными пресетами",
|
"Navigation, voice, and visual preset controls": "Управление навигацией, голосом и визуальными пресетами",
|
||||||
"Language": "Язык",
|
"Language: {language}": "Язык: {language}",
|
||||||
|
"Change the interface language": "Сменить язык интерфейса",
|
||||||
|
"Interface language": "Язык интерфейса",
|
||||||
|
"Search languages": "Поиск языка",
|
||||||
|
"No matching languages": "Подходящих языков нет",
|
||||||
"Search location by name or coordinates": "Поиск места по названию или координатам",
|
"Search location by name or coordinates": "Поиск места по названию или координатам",
|
||||||
"Search any location...": "Поиск любого места...",
|
"Search any location...": "Поиск любого места...",
|
||||||
"Normal": "Обычный",
|
"Normal": "Обычный",
|
||||||
|
|||||||
+11
-6
@@ -345,17 +345,22 @@ function buildRow(documentRef, key) {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Move the existing setup action with the theme, retaining its listeners. */
|
/**
|
||||||
export function bindKeySetupPlacement(documentRef, chip, root) {
|
* Move the existing setup action with the theme, retaining its listeners:
|
||||||
|
* Cyber puts it in the top toolbar, every other theme keeps it first in its
|
||||||
|
* markup home (the bottom-right corner row, beside the language picker).
|
||||||
|
*/
|
||||||
|
export function bindKeySetupPlacement(documentRef, chip) {
|
||||||
const theme = documentRef?.documentElement;
|
const theme = documentRef?.documentElement;
|
||||||
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
||||||
const Observer = documentRef?.defaultView?.MutationObserver;
|
const Observer = documentRef?.defaultView?.MutationObserver;
|
||||||
if (!theme || !toolbar || !Observer) return () => {};
|
const home = chip?.parentNode;
|
||||||
|
if (!theme || !toolbar || !Observer || !home) return () => {};
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
if (theme.dataset.uiTheme === 'cyber') {
|
if (theme.dataset.uiTheme === 'cyber') {
|
||||||
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
||||||
} else if (chip.parentNode !== root.parentNode) {
|
} else if (chip.parentNode !== home) {
|
||||||
root.before(chip);
|
home.prepend(chip);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const observer = new Observer(sync);
|
const observer = new Observer(sync);
|
||||||
@@ -415,7 +420,7 @@ export async function initKeySetup({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
||||||
disposePlacement = bindKeySetupPlacement(documentRef, chip, root);
|
disposePlacement = bindKeySetupPlacement(documentRef, chip);
|
||||||
const applyButton = root.querySelector('[data-key-setup-apply]');
|
const applyButton = root.querySelector('[data-key-setup-apply]');
|
||||||
const closeButton = root.querySelector('[data-key-setup-close]');
|
const closeButton = root.querySelector('[data-key-setup-close]');
|
||||||
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ import {
|
|||||||
} from './keySetup.js';
|
} from './keySetup.js';
|
||||||
|
|
||||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
||||||
const home = {};
|
const home = { prepend: (node) => { node.parentNode = home; } };
|
||||||
const chip = { parentNode: home, hidden: true };
|
const chip = { parentNode: home, hidden: true };
|
||||||
const root = { parentNode: home, before: (node) => { node.parentNode = home; } };
|
|
||||||
let moves = 0;
|
let moves = 0;
|
||||||
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
|
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
|
||||||
const theme = { dataset: { uiTheme: 'tactical' } };
|
const theme = { dataset: { uiTheme: 'tactical' } };
|
||||||
@@ -28,7 +27,7 @@ test('setup placement moves the same button only in Cyber and disconnects on tea
|
|||||||
documentElement: theme,
|
documentElement: theme,
|
||||||
getElementById: () => toolbar,
|
getElementById: () => toolbar,
|
||||||
defaultView: { MutationObserver: Observer },
|
defaultView: { MutationObserver: Observer },
|
||||||
}, chip, root);
|
}, chip);
|
||||||
assert.equal(chip.parentNode, home);
|
assert.equal(chip.parentNode, home);
|
||||||
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
||||||
theme.dataset.uiTheme = variant;
|
theme.dataset.uiTheme = variant;
|
||||||
|
|||||||
+28
-27
@@ -45,7 +45,7 @@ import {
|
|||||||
registerUnsavedWork,
|
registerUnsavedWork,
|
||||||
t,
|
t,
|
||||||
} from '../i18n/index.js';
|
} from '../i18n/index.js';
|
||||||
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
|
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central UI orchestrator for the God's Eye View application.
|
* Central UI orchestrator for the God's Eye View application.
|
||||||
@@ -715,17 +715,13 @@ export class StyleManager extends ShellFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the command-dock locale switch: one button per shipped locale,
|
* Mounts the interface language picker in the bottom-right corner
|
||||||
* labelled with its code and named in its own language. A click stores the
|
* (src/ui/localePicker.js). Choosing another language stores it and
|
||||||
* choice and reloads, so nothing re-renders live; it asks first when the
|
* reloads, so nothing re-renders live; it asks first when the reload would
|
||||||
* reload would lose work (see _confirmLocaleReload).
|
* lose work (see _chooseLocale).
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
_initLocaleSelector() {
|
_initLocaleSelector() {
|
||||||
const container = document.querySelector(
|
|
||||||
'#control-panel .dock-locale-switch',
|
|
||||||
);
|
|
||||||
if (!container) return;
|
|
||||||
// Work this shell can see that a reload drops. Other modules register
|
// Work this shell can see that a reload drops. Other modules register
|
||||||
// their own (registerUnsavedWork in src/i18n/index.js).
|
// their own (registerUnsavedWork in src/i18n/index.js).
|
||||||
this._unsavedWorkChecks = [
|
this._unsavedWorkChecks = [
|
||||||
@@ -740,24 +736,27 @@ export class StyleManager extends ShellFacade {
|
|||||||
this.cockpitView?.active ? msg('the open cockpit') : null,
|
this.cockpitView?.active ? msg('the open cockpit') : null,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
const active = getLocale();
|
const root = globalThis.document?.getElementById?.('locale-picker');
|
||||||
for (const locale of LOCALES) {
|
if (!root) return;
|
||||||
const button = document.createElement('button');
|
this._localePicker = mountLocalePicker({
|
||||||
button.type = 'button';
|
root,
|
||||||
button.className = 'dock-locale-btn';
|
current: getLocale(),
|
||||||
button.dataset.locale = locale;
|
onSelect: (locale) => this._chooseLocale(locale),
|
||||||
button.textContent = locale.toUpperCase();
|
});
|
||||||
button.setAttribute('aria-label', LOCALE_NAMES[locale]);
|
}
|
||||||
button.lang = locale;
|
|
||||||
const isActive = locale === active;
|
/**
|
||||||
button.classList.toggle('active', isActive);
|
* Switch to `locale`: nothing for the active language, otherwise store it
|
||||||
button.setAttribute('aria-pressed', String(isActive));
|
* and reload once _confirmLocaleReload agrees.
|
||||||
button.addEventListener('click', () => {
|
* @param {string} locale
|
||||||
if (!this._confirmLocaleReload()) return;
|
* @returns {'unchanged'|'declined'|'switched'}
|
||||||
persistLocaleAndReload(button.dataset.locale);
|
*/
|
||||||
});
|
_chooseLocale(locale) {
|
||||||
container.appendChild(button);
|
return applyLocaleChoice(locale, {
|
||||||
}
|
current: getLocale(),
|
||||||
|
confirmSwitch: () => this._confirmLocaleReload(),
|
||||||
|
switchLocale: (next) => persistLocaleAndReload(next),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1759,6 +1758,8 @@ 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();
|
||||||
|
this._localePicker = null;
|
||||||
this._recording.destroy();
|
this._recording.destroy();
|
||||||
this._panelChrome.destroy();
|
this._panelChrome.destroy();
|
||||||
this._feedback.destroy();
|
this._feedback.destroy();
|
||||||
|
|||||||
@@ -0,0 +1,631 @@
|
|||||||
|
/**
|
||||||
|
* Interface language picker: a HUD button in the bottom-right corner that
|
||||||
|
* opens a listbox of the shipped languages.
|
||||||
|
*
|
||||||
|
* The ordering, search filter, keyboard model and choice rule are pure
|
||||||
|
* functions below, testable without a DOM (localePicker.test.mjs).
|
||||||
|
* mountLocalePicker() binds them to the #locale-picker markup; the shell only
|
||||||
|
* mounts it and decides what choosing another language does (confirm, reload).
|
||||||
|
*
|
||||||
|
* The list follows the ARIA listbox pattern: the button opens it, focus moves
|
||||||
|
* into the list (or into the search field, which is a combobox, once there are
|
||||||
|
* more than LOCALE_SEARCH_THRESHOLD languages), and aria-activedescendant
|
||||||
|
* marks the active option.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { t } from '../i18n/index.js';
|
||||||
|
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
|
||||||
|
|
||||||
|
/** Above this many languages the list gets a search field. */
|
||||||
|
export const LOCALE_SEARCH_THRESHOLD = 8;
|
||||||
|
|
||||||
|
/** Type-ahead keystrokes closer together than this extend one prefix. */
|
||||||
|
export const TYPEAHEAD_RESET_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text folded for search: no diacritics, lower case. 'Español' → 'espanol',
|
||||||
|
* 'Ёжик' → 'ежик'.
|
||||||
|
* @param {unknown} text
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function foldSearchText(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/\p{M}+/gu, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reader of language names in the interface language, via Intl.DisplayNames.
|
||||||
|
* Returns '' for anything it cannot name (no Intl support, unknown code).
|
||||||
|
* @param {string} locale Interface language.
|
||||||
|
* @param {typeof Intl.DisplayNames} [DisplayNames]
|
||||||
|
* @returns {(code: string) => string}
|
||||||
|
*/
|
||||||
|
export function createLocalNameReader(
|
||||||
|
locale,
|
||||||
|
DisplayNames = globalThis.Intl?.DisplayNames,
|
||||||
|
) {
|
||||||
|
let names = null;
|
||||||
|
try {
|
||||||
|
names = new DisplayNames([locale], { type: 'language', fallback: 'none' });
|
||||||
|
} catch {
|
||||||
|
return () => '';
|
||||||
|
}
|
||||||
|
return (code) => {
|
||||||
|
try {
|
||||||
|
const name = names.of(code);
|
||||||
|
return typeof name === 'string' && name !== code ? name : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rows of the list: the current language first, then the rest in
|
||||||
|
* alphabetical order of their own-language names (collated for the
|
||||||
|
* interface language). `localName` is the name in the interface language,
|
||||||
|
* left empty when it only repeats `nativeName`.
|
||||||
|
* @param {object} input
|
||||||
|
* @param {readonly string[]} input.locales
|
||||||
|
* @param {string} input.current
|
||||||
|
* @param {Record<string, string>} input.nativeNames
|
||||||
|
* @param {(code: string) => string} [input.localName]
|
||||||
|
* @returns {Array<{code: string, nativeName: string, localName: string, selected: boolean}>}
|
||||||
|
*/
|
||||||
|
export function buildLocaleOptions({
|
||||||
|
locales,
|
||||||
|
current,
|
||||||
|
nativeNames,
|
||||||
|
localName = () => '',
|
||||||
|
}) {
|
||||||
|
const rows = locales.map((code) => {
|
||||||
|
const nativeName = nativeNames[code] || code;
|
||||||
|
const translated = localName(code) || '';
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
nativeName,
|
||||||
|
localName:
|
||||||
|
foldSearchText(translated) === foldSearchText(nativeName)
|
||||||
|
? ''
|
||||||
|
: translated,
|
||||||
|
selected: code === current,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
let collate;
|
||||||
|
try {
|
||||||
|
collate = new Intl.Collator(current).compare;
|
||||||
|
} catch {
|
||||||
|
collate = (a, b) => a.localeCompare(b);
|
||||||
|
}
|
||||||
|
const others = rows
|
||||||
|
.filter((row) => !row.selected)
|
||||||
|
.sort((a, b) => collate(a.nativeName, b.nativeName));
|
||||||
|
return [...rows.filter((row) => row.selected), ...others];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows matching a search: by own-language name, interface-language name or
|
||||||
|
* code, ignoring case and diacritics. An empty query keeps every row.
|
||||||
|
* @template {{code: string, nativeName: string, localName: string}} Row
|
||||||
|
* @param {Row[]} options
|
||||||
|
* @param {string} query
|
||||||
|
* @returns {Row[]}
|
||||||
|
*/
|
||||||
|
export function filterLocaleOptions(options, query) {
|
||||||
|
const needle = foldSearchText(query);
|
||||||
|
if (!needle) return options.slice();
|
||||||
|
return options.filter((option) =>
|
||||||
|
[option.nativeName, option.localName, option.code].some((value) =>
|
||||||
|
foldSearchText(value).includes(needle),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The option a navigation key moves to; null for any other key. The list
|
||||||
|
* does not wrap, as in the ARIA listbox pattern.
|
||||||
|
* @param {string} key
|
||||||
|
* @param {number} index Active option, -1 for none.
|
||||||
|
* @param {number} count
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
export function moveActiveIndex(key, index, count) {
|
||||||
|
if (count <= 0) return null;
|
||||||
|
switch (key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
return index < 0 ? 0 : Math.min(index + 1, count - 1);
|
||||||
|
case 'ArrowUp':
|
||||||
|
return index < 0 ? count - 1 : Math.max(index - 1, 0);
|
||||||
|
case 'Home':
|
||||||
|
return 0;
|
||||||
|
case 'End':
|
||||||
|
return count - 1;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type-ahead: the next option after `fromIndex` (wrapping) whose own-language
|
||||||
|
* or interface-language name starts with `buffer`. A buffer of one repeated
|
||||||
|
* letter cycles through the options starting with that letter.
|
||||||
|
* @param {Array<{nativeName: string, localName: string}>} options
|
||||||
|
* @param {string} buffer
|
||||||
|
* @param {number} fromIndex
|
||||||
|
* @returns {number} -1 when nothing matches.
|
||||||
|
*/
|
||||||
|
export function typeaheadIndex(options, buffer, fromIndex) {
|
||||||
|
const folded = foldSearchText(buffer);
|
||||||
|
if (!folded || !options.length) return -1;
|
||||||
|
const repeated = [...folded].every((char) => char === folded[0]);
|
||||||
|
const prefix = repeated ? folded[0] : folded;
|
||||||
|
// A longer prefix keeps refining the current option; a single letter moves on.
|
||||||
|
const start = repeated ? fromIndex + 1 : Math.max(fromIndex, 0);
|
||||||
|
for (let step = 0; step < options.length; step++) {
|
||||||
|
const index = (start + step) % options.length;
|
||||||
|
const option = options[index];
|
||||||
|
if (
|
||||||
|
foldSearchText(option.nativeName).startsWith(prefix) ||
|
||||||
|
foldSearchText(option.localName).startsWith(prefix)
|
||||||
|
)
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What choosing a language does. The current one changes nothing; another one
|
||||||
|
* switches after `confirmSwitch()` agrees (it asks when a reload loses work).
|
||||||
|
* @param {string} locale
|
||||||
|
* @param {{current: string, confirmSwitch: () => boolean, switchLocale: (locale: string) => void}} actions
|
||||||
|
* @returns {'unchanged'|'declined'|'switched'}
|
||||||
|
*/
|
||||||
|
export function applyLocaleChoice(
|
||||||
|
locale,
|
||||||
|
{ current, confirmSwitch, switchLocale },
|
||||||
|
) {
|
||||||
|
if (locale === current) return 'unchanged';
|
||||||
|
if (!confirmSwitch()) return 'declined';
|
||||||
|
switchLocale(locale);
|
||||||
|
return 'switched';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The picker's state without a DOM: open/closed, query, visible rows, active
|
||||||
|
* row and type-ahead. `keydown` answers what a key did so the DOM layer only
|
||||||
|
* has to render and focus.
|
||||||
|
* @param {object} input
|
||||||
|
* @param {readonly string[]} [input.locales]
|
||||||
|
* @param {string} input.current
|
||||||
|
* @param {Record<string, string>} [input.nativeNames]
|
||||||
|
* @param {(code: string) => string} [input.localName]
|
||||||
|
* @param {number} [input.searchThreshold]
|
||||||
|
* @param {() => number} [input.now]
|
||||||
|
*/
|
||||||
|
export function createLocalePickerModel({
|
||||||
|
locales = LOCALES,
|
||||||
|
current,
|
||||||
|
nativeNames = LOCALE_NAMES,
|
||||||
|
localName = () => '',
|
||||||
|
searchThreshold = LOCALE_SEARCH_THRESHOLD,
|
||||||
|
now = () => Date.now(),
|
||||||
|
}) {
|
||||||
|
const options = buildLocaleOptions({
|
||||||
|
locales,
|
||||||
|
current,
|
||||||
|
nativeNames,
|
||||||
|
localName,
|
||||||
|
});
|
||||||
|
const searchable = locales.length > searchThreshold;
|
||||||
|
let open = false;
|
||||||
|
let query = '';
|
||||||
|
let visible = options;
|
||||||
|
let active = -1;
|
||||||
|
let typed = '';
|
||||||
|
let typedAt = -Infinity;
|
||||||
|
|
||||||
|
const activateSelected = () => {
|
||||||
|
const selected = visible.findIndex((option) => option.selected);
|
||||||
|
active = visible.length ? Math.max(selected, 0) : -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
options,
|
||||||
|
searchable,
|
||||||
|
get open() {
|
||||||
|
return open;
|
||||||
|
},
|
||||||
|
get query() {
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
get visible() {
|
||||||
|
return visible;
|
||||||
|
},
|
||||||
|
get activeIndex() {
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
get activeOption() {
|
||||||
|
return visible[active] || null;
|
||||||
|
},
|
||||||
|
openList() {
|
||||||
|
open = true;
|
||||||
|
query = '';
|
||||||
|
visible = options;
|
||||||
|
typed = '';
|
||||||
|
activateSelected();
|
||||||
|
},
|
||||||
|
closeList() {
|
||||||
|
open = false;
|
||||||
|
typed = '';
|
||||||
|
},
|
||||||
|
setQuery(text) {
|
||||||
|
query = String(text ?? '');
|
||||||
|
visible = filterLocaleOptions(options, query);
|
||||||
|
// The current language stays active while it matches; else the first.
|
||||||
|
activateSelected();
|
||||||
|
},
|
||||||
|
setActive(index) {
|
||||||
|
if (index >= 0 && index < visible.length) active = index;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @param {{key: string, altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean}} event
|
||||||
|
* @returns {{handled: boolean, close?: boolean, choose?: string}}
|
||||||
|
*/
|
||||||
|
keydown({ key, altKey = false, ctrlKey = false, metaKey = false }) {
|
||||||
|
if (!open) return { handled: false };
|
||||||
|
if (key === 'Escape') return { handled: true, close: true };
|
||||||
|
if (key === 'Enter' || (key === ' ' && !searchable)) {
|
||||||
|
const option = visible[active];
|
||||||
|
return option
|
||||||
|
? { handled: true, choose: option.code }
|
||||||
|
: { handled: true };
|
||||||
|
}
|
||||||
|
// In the search field Home/End move the caret, as in the APG combobox.
|
||||||
|
if (searchable && (key === 'Home' || key === 'End'))
|
||||||
|
return { handled: false };
|
||||||
|
const moved = moveActiveIndex(key, active, visible.length);
|
||||||
|
if (moved !== null) {
|
||||||
|
active = moved;
|
||||||
|
return { handled: true };
|
||||||
|
}
|
||||||
|
const printable =
|
||||||
|
key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey;
|
||||||
|
// With a search field, letters are typed into it.
|
||||||
|
if (!printable || searchable) return { handled: false };
|
||||||
|
const time = now();
|
||||||
|
typed = time - typedAt > TYPEAHEAD_RESET_MS ? key : typed + key;
|
||||||
|
typedAt = time;
|
||||||
|
const match = typeaheadIndex(visible, typed, active);
|
||||||
|
if (match >= 0) active = match;
|
||||||
|
return { handled: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DOM ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
/** Thin-line globe, check mark and caret, drawn in currentColor. */
|
||||||
|
const ICONS = Object.freeze({
|
||||||
|
globe: {
|
||||||
|
stroke: [
|
||||||
|
'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18Z',
|
||||||
|
'M12 3c-2.6 2.5-3.9 5.5-3.9 9s1.3 6.5 3.9 9',
|
||||||
|
'M12 3c2.6 2.5 3.9 5.5 3.9 9s-1.3 6.5-3.9 9',
|
||||||
|
'M3 12h18',
|
||||||
|
'M4.3 7.5h15.4',
|
||||||
|
'M4.3 16.5h15.4',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
check: { stroke: ['M5 12.5l4.3 4.3L19 7'] },
|
||||||
|
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<string, string>} [input.nativeNames]
|
||||||
|
* @param {number} [input.searchThreshold]
|
||||||
|
* @returns {{open: Function, close: Function, isOpen: () => boolean, destroy: Function}}
|
||||||
|
*/
|
||||||
|
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');
|
||||||
|
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;
|
||||||
|
// Every row reads the same: the language named in itself, then its code.
|
||||||
|
// The interface-language name only helps the search find it.
|
||||||
|
const meta = element(documentRef, 'span', 'locale-picker-meta');
|
||||||
|
const codeLabel = element(documentRef, 'span', 'locale-picker-option-code');
|
||||||
|
codeLabel.setAttribute('translate', 'no');
|
||||||
|
codeLabel.textContent = option.code.toUpperCase();
|
||||||
|
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,352 @@
|
|||||||
|
// The interface language picker without a DOM: row order, search, the
|
||||||
|
// keyboard model and what choosing a language does.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
LOCALE_SEARCH_THRESHOLD,
|
||||||
|
TYPEAHEAD_RESET_MS,
|
||||||
|
applyLocaleChoice,
|
||||||
|
buildLocaleOptions,
|
||||||
|
createLocalNameReader,
|
||||||
|
createLocalePickerModel,
|
||||||
|
filterLocaleOptions,
|
||||||
|
foldSearchText,
|
||||||
|
moveActiveIndex,
|
||||||
|
typeaheadIndex,
|
||||||
|
} from './localePicker.js';
|
||||||
|
import { LOCALES, LOCALE_NAMES } from '../i18n/locale.js';
|
||||||
|
|
||||||
|
const NAMES = {
|
||||||
|
en: 'English',
|
||||||
|
es: 'Español',
|
||||||
|
ru: 'Русский',
|
||||||
|
de: 'Deutsch',
|
||||||
|
fr: 'Français',
|
||||||
|
pt: 'Português',
|
||||||
|
tr: 'Türkçe',
|
||||||
|
uk: 'Українська',
|
||||||
|
ja: '日本語',
|
||||||
|
};
|
||||||
|
const MANY = Object.keys(NAMES);
|
||||||
|
|
||||||
|
const codes = (rows) => rows.map((row) => row.code);
|
||||||
|
|
||||||
|
test('the current language comes first, the rest by their own names', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
codes(
|
||||||
|
buildLocaleOptions({
|
||||||
|
locales: LOCALES,
|
||||||
|
current: 'ru',
|
||||||
|
nativeNames: LOCALE_NAMES,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
['ru', 'en', 'es'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
codes(
|
||||||
|
buildLocaleOptions({
|
||||||
|
locales: LOCALES,
|
||||||
|
current: 'es',
|
||||||
|
nativeNames: LOCALE_NAMES,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
['es', 'en', 'ru'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
codes(
|
||||||
|
buildLocaleOptions({
|
||||||
|
locales: LOCALES,
|
||||||
|
current: 'en',
|
||||||
|
nativeNames: LOCALE_NAMES,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
['en', 'es', 'ru'],
|
||||||
|
);
|
||||||
|
const rows = buildLocaleOptions({
|
||||||
|
locales: MANY,
|
||||||
|
current: 'en',
|
||||||
|
nativeNames: NAMES,
|
||||||
|
});
|
||||||
|
assert.deepEqual(codes(rows), [
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'es',
|
||||||
|
'fr',
|
||||||
|
'pt',
|
||||||
|
'tr',
|
||||||
|
'ru',
|
||||||
|
'uk',
|
||||||
|
'ja',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
rows.map((row) => row.selected),
|
||||||
|
[true, false, false, false, false, false, false, false, false],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each row names the language in the interface language too, unless that only repeats it', () => {
|
||||||
|
const local = createLocalNameReader('ru');
|
||||||
|
const rows = buildLocaleOptions({
|
||||||
|
locales: LOCALES,
|
||||||
|
current: 'ru',
|
||||||
|
nativeNames: LOCALE_NAMES,
|
||||||
|
localName: local,
|
||||||
|
});
|
||||||
|
const byCode = Object.fromEntries(rows.map((row) => [row.code, row]));
|
||||||
|
assert.equal(byCode.en.localName, 'английский');
|
||||||
|
assert.equal(byCode.es.localName, 'испанский');
|
||||||
|
assert.equal(byCode.ru.localName, '', 'русский only repeats Русский');
|
||||||
|
const english = buildLocaleOptions({
|
||||||
|
locales: LOCALES,
|
||||||
|
current: 'en',
|
||||||
|
nativeNames: LOCALE_NAMES,
|
||||||
|
localName: createLocalNameReader('en'),
|
||||||
|
});
|
||||||
|
assert.equal(english.find((row) => row.code === 'es').localName, 'Spanish');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without Intl.DisplayNames the interface-language name is simply left out', () => {
|
||||||
|
const read = createLocalNameReader('ru', null);
|
||||||
|
assert.equal(read('en'), '');
|
||||||
|
class Broken {
|
||||||
|
of() {
|
||||||
|
throw new RangeError('bad code');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(createLocalNameReader('ru', Broken)('en'), '');
|
||||||
|
class Echo {
|
||||||
|
of(code) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
createLocalNameReader('ru', Echo)('xx'),
|
||||||
|
'',
|
||||||
|
'a code echoed back is no name',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search folds case and diacritics, and matches either name or the code', () => {
|
||||||
|
assert.equal(foldSearchText(' Español '), 'espanol');
|
||||||
|
assert.equal(foldSearchText('Français'), 'francais');
|
||||||
|
assert.equal(foldSearchText('Ёлка'), 'елка');
|
||||||
|
const rows = buildLocaleOptions({
|
||||||
|
locales: MANY,
|
||||||
|
current: 'ru',
|
||||||
|
nativeNames: NAMES,
|
||||||
|
localName: createLocalNameReader('ru'),
|
||||||
|
});
|
||||||
|
assert.deepEqual(codes(filterLocaleOptions(rows, 'espan')), ['es']);
|
||||||
|
assert.deepEqual(codes(filterLocaleOptions(rows, 'ESPAÑ')), ['es']);
|
||||||
|
assert.deepEqual(codes(filterLocaleOptions(rows, 'francais')), ['fr']);
|
||||||
|
assert.deepEqual(codes(filterLocaleOptions(rows, 'turkce')), ['tr']);
|
||||||
|
assert.deepEqual(
|
||||||
|
codes(filterLocaleOptions(rows, 'испан')),
|
||||||
|
['es'],
|
||||||
|
'by the interface-language name',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
codes(filterLocaleOptions(rows, 'УКРАИН')),
|
||||||
|
['uk'],
|
||||||
|
'й folds to и',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
codes(filterLocaleOptions(rows, 'JA')),
|
||||||
|
['ja'],
|
||||||
|
'by the code',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
codes(filterLocaleOptions(rows, ' ')),
|
||||||
|
codes(rows),
|
||||||
|
'empty keeps all',
|
||||||
|
);
|
||||||
|
assert.deepEqual(filterLocaleOptions(rows, 'klingon'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the search field appears only above the threshold', () => {
|
||||||
|
assert.equal(LOCALE_SEARCH_THRESHOLD, 8);
|
||||||
|
assert.equal(createLocalePickerModel({ current: 'en' }).searchable, false);
|
||||||
|
const eight = MANY.slice(0, 8);
|
||||||
|
assert.equal(
|
||||||
|
createLocalePickerModel({
|
||||||
|
locales: eight,
|
||||||
|
nativeNames: NAMES,
|
||||||
|
current: 'en',
|
||||||
|
}).searchable,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
createLocalePickerModel({
|
||||||
|
locales: MANY,
|
||||||
|
nativeNames: NAMES,
|
||||||
|
current: 'en',
|
||||||
|
}).searchable,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('choosing the current language changes nothing; another asks, then switches', () => {
|
||||||
|
const calls = [];
|
||||||
|
const actions = (answer) => ({
|
||||||
|
current: 'ru',
|
||||||
|
confirmSwitch: () => {
|
||||||
|
calls.push('confirm');
|
||||||
|
return answer;
|
||||||
|
},
|
||||||
|
switchLocale: (locale) => calls.push(`switch:${locale}`),
|
||||||
|
});
|
||||||
|
assert.equal(applyLocaleChoice('ru', actions(true)), 'unchanged');
|
||||||
|
assert.deepEqual(calls, [], 'no prompt, no reload');
|
||||||
|
assert.equal(applyLocaleChoice('es', actions(false)), 'declined');
|
||||||
|
assert.deepEqual(calls, ['confirm']);
|
||||||
|
assert.equal(applyLocaleChoice('es', actions(true)), 'switched');
|
||||||
|
assert.deepEqual(calls, ['confirm', 'confirm', 'switch:es']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigation keys move without wrapping', () => {
|
||||||
|
assert.equal(moveActiveIndex('ArrowDown', 0, 3), 1);
|
||||||
|
assert.equal(moveActiveIndex('ArrowDown', 2, 3), 2);
|
||||||
|
assert.equal(moveActiveIndex('ArrowUp', 0, 3), 0);
|
||||||
|
assert.equal(moveActiveIndex('ArrowUp', 2, 3), 1);
|
||||||
|
assert.equal(moveActiveIndex('ArrowDown', -1, 3), 0);
|
||||||
|
assert.equal(moveActiveIndex('ArrowUp', -1, 3), 2);
|
||||||
|
assert.equal(moveActiveIndex('Home', 2, 3), 0);
|
||||||
|
assert.equal(moveActiveIndex('End', 0, 3), 2);
|
||||||
|
assert.equal(moveActiveIndex('Tab', 0, 3), null);
|
||||||
|
assert.equal(moveActiveIndex('ArrowDown', 0, 0), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('type-ahead cycles by first letter and refines with more letters', () => {
|
||||||
|
const rows = buildLocaleOptions({
|
||||||
|
locales: MANY,
|
||||||
|
current: 'en',
|
||||||
|
nativeNames: NAMES,
|
||||||
|
});
|
||||||
|
// en, de, es, fr, pt, tr, ru, uk, ja
|
||||||
|
assert.equal(
|
||||||
|
typeaheadIndex(rows, 'e', 0),
|
||||||
|
2,
|
||||||
|
'from English, e moves on to Español',
|
||||||
|
);
|
||||||
|
assert.equal(typeaheadIndex(rows, 'e', 2), 0, 'and wraps back to English');
|
||||||
|
assert.equal(
|
||||||
|
typeaheadIndex(rows, 'ee', 2),
|
||||||
|
0,
|
||||||
|
'a repeated letter keeps cycling',
|
||||||
|
);
|
||||||
|
assert.equal(typeaheadIndex(rows, 'es', 0), 2);
|
||||||
|
assert.equal(
|
||||||
|
typeaheadIndex(rows, 'en', 0),
|
||||||
|
0,
|
||||||
|
'a longer prefix may stay put',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
typeaheadIndex(rows, 'р', 0),
|
||||||
|
6,
|
||||||
|
'Русский by its Cyrillic letter',
|
||||||
|
);
|
||||||
|
assert.equal(typeaheadIndex(rows, 'q', 0), -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard model: open on the current language, move, choose, close', () => {
|
||||||
|
const model = createLocalePickerModel({
|
||||||
|
current: 'ru',
|
||||||
|
localName: createLocalNameReader('ru'),
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
model.keydown({ key: 'ArrowDown' }),
|
||||||
|
{ handled: false },
|
||||||
|
'closed: ignored',
|
||||||
|
);
|
||||||
|
model.openList();
|
||||||
|
assert.equal(model.activeOption.code, 'ru');
|
||||||
|
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
|
||||||
|
assert.equal(model.activeOption.code, 'en');
|
||||||
|
model.keydown({ key: 'End' });
|
||||||
|
assert.equal(model.activeOption.code, 'es');
|
||||||
|
model.keydown({ key: 'Home' });
|
||||||
|
assert.equal(model.activeOption.code, 'ru');
|
||||||
|
model.keydown({ key: 'ArrowUp' });
|
||||||
|
assert.equal(model.activeOption.code, 'ru', 'no wrap at the top');
|
||||||
|
assert.deepEqual(model.keydown({ key: 'Enter' }), {
|
||||||
|
handled: true,
|
||||||
|
choose: 'ru',
|
||||||
|
});
|
||||||
|
model.keydown({ key: 'ArrowDown' });
|
||||||
|
assert.deepEqual(model.keydown({ key: ' ' }), {
|
||||||
|
handled: true,
|
||||||
|
choose: 'en',
|
||||||
|
});
|
||||||
|
assert.deepEqual(model.keydown({ key: 'Escape' }), {
|
||||||
|
handled: true,
|
||||||
|
close: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
model.keydown({ key: 'Tab' }),
|
||||||
|
{ handled: false },
|
||||||
|
'Tab leaves the list',
|
||||||
|
);
|
||||||
|
model.closeList();
|
||||||
|
assert.equal(model.open, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard model: type-ahead resets after a pause and ignores shortcuts', () => {
|
||||||
|
let clock = 0;
|
||||||
|
const model = createLocalePickerModel({ current: 'en', now: () => clock });
|
||||||
|
model.openList();
|
||||||
|
assert.deepEqual(model.keydown({ key: 'e' }), { handled: true });
|
||||||
|
assert.equal(model.activeOption.code, 'es');
|
||||||
|
clock += 100;
|
||||||
|
model.keydown({ key: 's' });
|
||||||
|
assert.equal(model.activeOption.code, 'es', '"es" keeps Español');
|
||||||
|
clock += TYPEAHEAD_RESET_MS + 1;
|
||||||
|
model.keydown({ key: 'Р' });
|
||||||
|
assert.equal(model.activeOption.code, 'ru');
|
||||||
|
assert.deepEqual(
|
||||||
|
model.keydown({ key: 'z' }),
|
||||||
|
{ handled: true },
|
||||||
|
'an unmatched letter is still the list’s, not a global hotkey',
|
||||||
|
);
|
||||||
|
assert.equal(model.activeOption.code, 'ru');
|
||||||
|
assert.deepEqual(model.keydown({ key: 'c', metaKey: true }), {
|
||||||
|
handled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyboard model with search: letters, Space, Home and End belong to the field', () => {
|
||||||
|
const model = createLocalePickerModel({
|
||||||
|
locales: MANY,
|
||||||
|
nativeNames: NAMES,
|
||||||
|
current: 'en',
|
||||||
|
localName: createLocalNameReader('en'),
|
||||||
|
});
|
||||||
|
model.openList();
|
||||||
|
assert.deepEqual(model.keydown({ key: 'f' }), { handled: false });
|
||||||
|
assert.deepEqual(model.keydown({ key: ' ' }), { handled: false });
|
||||||
|
assert.deepEqual(model.keydown({ key: 'Home' }), { handled: false });
|
||||||
|
model.setQuery('SPAN');
|
||||||
|
assert.deepEqual(codes(model.visible), ['es']);
|
||||||
|
assert.equal(model.activeOption.code, 'es');
|
||||||
|
assert.deepEqual(model.keydown({ key: 'Enter' }), {
|
||||||
|
handled: true,
|
||||||
|
choose: 'es',
|
||||||
|
});
|
||||||
|
model.setQuery('e');
|
||||||
|
assert.equal(
|
||||||
|
model.activeOption.code,
|
||||||
|
'en',
|
||||||
|
'the current language stays active while it matches',
|
||||||
|
);
|
||||||
|
model.keydown({ key: 'ArrowDown' });
|
||||||
|
assert.notEqual(model.activeOption.code, 'en');
|
||||||
|
model.setQuery('nothing like it');
|
||||||
|
assert.equal(model.activeOption, null);
|
||||||
|
assert.deepEqual(
|
||||||
|
model.keydown({ key: 'Enter' }),
|
||||||
|
{ handled: true },
|
||||||
|
'nothing to choose',
|
||||||
|
);
|
||||||
|
model.openList();
|
||||||
|
assert.equal(model.query, '', 'reopening clears the search');
|
||||||
|
assert.equal(model.visible.length, MANY.length);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// K-27: the dock language switch reloads the page, so it asks first while the
|
// K-27: the language picker reloads the page, so it asks first while the
|
||||||
// reload would lose work (unsaved CCTV calibration, the open cockpit, anything
|
// reload would lose work (unsaved CCTV calibration, the open cockpit, anything
|
||||||
// a module registers).
|
// a module registers). Choosing the language already active does nothing.
|
||||||
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';
|
||||||
@@ -11,20 +11,9 @@ import {
|
|||||||
setLocale,
|
setLocale,
|
||||||
} from '../i18n/index.js';
|
} from '../i18n/index.js';
|
||||||
|
|
||||||
function fakeButton() {
|
/** Wire the shell's language choice with the given shell state. No picker
|
||||||
const listeners = {};
|
* markup here: the picker's own behaviour is covered by localePicker.test. */
|
||||||
return {
|
|
||||||
dataset: {},
|
|
||||||
classList: { toggle() {} },
|
|
||||||
setAttribute() {},
|
|
||||||
addEventListener: (type, listener) => (listeners[type] = listener),
|
|
||||||
click: () => listeners.click(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render the switch into a fake dock with the given shell state. */
|
|
||||||
function renderSwitch({ calDirty = false, cockpit = false } = {}) {
|
function renderSwitch({ calDirty = false, cockpit = false } = {}) {
|
||||||
const buttons = [];
|
|
||||||
const previous = {
|
const previous = {
|
||||||
document: globalThis.document,
|
document: globalThis.document,
|
||||||
location: globalThis.location,
|
location: globalThis.location,
|
||||||
@@ -34,10 +23,7 @@ function renderSwitch({ calDirty = false, cockpit = false } = {}) {
|
|||||||
const reloads = [];
|
const reloads = [];
|
||||||
const prompts = [];
|
const prompts = [];
|
||||||
let answer = true;
|
let answer = true;
|
||||||
globalThis.document = {
|
globalThis.document = { getElementById: () => null };
|
||||||
querySelector: () => ({ appendChild: (button) => buttons.push(button) }),
|
|
||||||
createElement: fakeButton,
|
|
||||||
};
|
|
||||||
globalThis.location = {
|
globalThis.location = {
|
||||||
href: 'https://gev.example/#v=2',
|
href: 'https://gev.example/#v=2',
|
||||||
assign() {},
|
assign() {},
|
||||||
@@ -56,12 +42,11 @@ function renderSwitch({ calDirty = false, cockpit = false } = {}) {
|
|||||||
},
|
},
|
||||||
cockpitView: { active: cockpit },
|
cockpitView: { active: cockpit },
|
||||||
_confirmLocaleReload: StyleManager.prototype._confirmLocaleReload,
|
_confirmLocaleReload: StyleManager.prototype._confirmLocaleReload,
|
||||||
|
_chooseLocale: StyleManager.prototype._chooseLocale,
|
||||||
};
|
};
|
||||||
StyleManager.prototype._initLocaleSelector.call(shell);
|
StyleManager.prototype._initLocaleSelector.call(shell);
|
||||||
const button = (locale) =>
|
|
||||||
buttons.find((candidate) => candidate.dataset.locale === locale);
|
|
||||||
return {
|
return {
|
||||||
button,
|
choose: (locale) => shell._chooseLocale(locale),
|
||||||
reloads,
|
reloads,
|
||||||
prompts,
|
prompts,
|
||||||
answer: (value) => (answer = value),
|
answer: (value) => (answer = value),
|
||||||
@@ -83,7 +68,7 @@ test.after(() => {
|
|||||||
test('nothing to lose: the switch reloads without asking', () => {
|
test('nothing to lose: the switch reloads without asking', () => {
|
||||||
const view = renderSwitch();
|
const view = renderSwitch();
|
||||||
try {
|
try {
|
||||||
view.button('es').click();
|
view.choose('es');
|
||||||
assert.deepEqual(view.prompts, []);
|
assert.deepEqual(view.prompts, []);
|
||||||
assert.equal(view.reloads.length, 1);
|
assert.equal(view.reloads.length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -95,13 +80,13 @@ test('unsaved calibration or the open cockpit: the switch asks first', () => {
|
|||||||
const view = renderSwitch({ calDirty: true, cockpit: true });
|
const view = renderSwitch({ calDirty: true, cockpit: true });
|
||||||
try {
|
try {
|
||||||
view.answer(false);
|
view.answer(false);
|
||||||
view.button('ru').click();
|
view.choose('ru');
|
||||||
assert.equal(view.prompts.length, 1);
|
assert.equal(view.prompts.length, 1);
|
||||||
assert.match(view.prompts[0], /unsaved CCTV calibration/);
|
assert.match(view.prompts[0], /unsaved CCTV calibration/);
|
||||||
assert.match(view.prompts[0], /the open cockpit/);
|
assert.match(view.prompts[0], /the open cockpit/);
|
||||||
assert.equal(view.reloads.length, 0, 'declined: no reload');
|
assert.equal(view.reloads.length, 0, 'declined: no reload');
|
||||||
view.answer(true);
|
view.answer(true);
|
||||||
view.button('ru').click();
|
view.choose('ru');
|
||||||
assert.equal(view.reloads.length, 1, 'confirmed: reload');
|
assert.equal(view.reloads.length, 1, 'confirmed: reload');
|
||||||
} finally {
|
} finally {
|
||||||
view.restore();
|
view.restore();
|
||||||
@@ -116,7 +101,7 @@ test('modules register their own unsaved work', () => {
|
|||||||
const view = renderSwitch();
|
const view = renderSwitch();
|
||||||
try {
|
try {
|
||||||
view.answer(false);
|
view.answer(false);
|
||||||
view.button('es').click();
|
view.choose('es');
|
||||||
assert.match(view.prompts[0], /drawings on the map/);
|
assert.match(view.prompts[0], /drawings on the map/);
|
||||||
assert.equal(view.reloads.length, 0);
|
assert.equal(view.reloads.length, 0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -132,7 +117,7 @@ test('the prompt reads in the active language', () => {
|
|||||||
const view = renderSwitch({ calDirty: true });
|
const view = renderSwitch({ calDirty: true });
|
||||||
try {
|
try {
|
||||||
view.answer(false);
|
view.answer(false);
|
||||||
view.button('en').click();
|
view.choose('en');
|
||||||
assert.equal(view.prompts.length, 1);
|
assert.equal(view.prompts.length, 1);
|
||||||
assert.doesNotMatch(view.prompts[0], /Switching language/);
|
assert.doesNotMatch(view.prompts[0], /Switching language/);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,3 +125,14 @@ test('the prompt reads in the active language', () => {
|
|||||||
setLocale('en');
|
setLocale('en');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the language already active neither asks nor reloads', () => {
|
||||||
|
const view = renderSwitch({ calDirty: true });
|
||||||
|
try {
|
||||||
|
assert.equal(view.choose('en'), 'unchanged');
|
||||||
|
assert.deepEqual(view.prompts, []);
|
||||||
|
assert.equal(view.reloads.length, 0);
|
||||||
|
} finally {
|
||||||
|
view.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -701,54 +701,3 @@ html:not([lang='en']) #gev-voice-control .gev-voice-help-detail {
|
|||||||
rgba(232, 234, 237, 0.24)
|
rgba(232, 234, 237, 0.24)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Language switch under the Visual Presets title: small mono chips, the
|
|
||||||
active language in accent like the other dock toggles. */
|
|
||||||
#command-dock #control-panel .dock-locale-switch {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.2rem;
|
|
||||||
margin-top: 0.22rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#command-dock .dock-locale-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-width: 1.35rem;
|
|
||||||
height: 0.95rem;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0 0.22rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 0.18rem;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.46rem;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.06rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
color var(--transition-fast),
|
|
||||||
border-color var(--transition-fast),
|
|
||||||
background var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
#command-dock .dock-locale-btn:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
border-color: var(--glass-border-hover);
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
#command-dock .dock-locale-btn:focus-visible {
|
|
||||||
outline: 1px solid var(--text-primary);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#command-dock .dock-locale-btn.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: rgba(0, 212, 255, 0.55);
|
|
||||||
background: var(--accent-dim);
|
|
||||||
}
|
|
||||||
@@ -174,6 +174,8 @@
|
|||||||
.context-mode-button,
|
.context-mode-button,
|
||||||
.map-stack-chip,
|
.map-stack-chip,
|
||||||
.key-setup-chip,
|
.key-setup-chip,
|
||||||
|
.locale-picker-button,
|
||||||
|
.locale-picker-popup,
|
||||||
.first-run-card
|
.first-run-card
|
||||||
) {
|
) {
|
||||||
border-color: var(--glass-border);
|
border-color: var(--glass-border);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
body.ui-embed #clean-view-exit,
|
body.ui-embed #clean-view-exit,
|
||||||
body.ui-embed #intel-hud,
|
body.ui-embed #intel-hud,
|
||||||
body.ui-embed #key-setup-chip,
|
body.ui-embed #key-setup-chip,
|
||||||
|
body.ui-embed #corner-actions,
|
||||||
body.ui-embed #key-setup,
|
body.ui-embed #key-setup,
|
||||||
body.ui-embed #first-run-launcher,
|
body.ui-embed #first-run-launcher,
|
||||||
body.ui-embed #command-dock,
|
body.ui-embed #command-dock,
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/* ============================================================
|
||||||
|
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
|
||||||
|
right-to-left page mirrors the row and the list.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
#corner-actions {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline-end: 0.9rem;
|
||||||
|
inset-block-end: 0.9rem;
|
||||||
|
/* Above the dock (145), the cockpit HUD and panel stacks (146–147);
|
||||||
|
below the first-run launcher (175) and Provider Settings (190). */
|
||||||
|
z-index: 150;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.4rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#corner-actions > * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same HUD frame as #key-setup-chip, so the two read as one group. */
|
||||||
|
.locale-picker-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
color: rgba(225, 244, 248, 0.92);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
letter-spacing: 0.09rem;
|
||||||
|
line-height: 1;
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
rgba(10, 27, 37, 0.92),
|
||||||
|
rgba(7, 13, 22, 0.9)
|
||||||
|
);
|
||||||
|
border: 1px solid rgba(54, 220, 255, 0.38);
|
||||||
|
border-radius: 0.42rem;
|
||||||
|
box-shadow:
|
||||||
|
0 0.4rem 1.6rem rgba(0, 0, 0, 0.5),
|
||||||
|
0 0 1.2rem rgba(0, 212, 255, 0.07);
|
||||||
|
backdrop-filter: blur(18px) saturate(1.2);
|
||||||
|
-webkit-backdrop-filter: blur(18px) saturate(1.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
background var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-button:hover,
|
||||||
|
.locale-picker-button:focus-visible,
|
||||||
|
.locale-picker.is-open .locale-picker-button {
|
||||||
|
background: linear-gradient(
|
||||||
|
145deg,
|
||||||
|
rgba(14, 38, 51, 0.95),
|
||||||
|
rgba(9, 18, 28, 0.93)
|
||||||
|
);
|
||||||
|
border-color: rgba(54, 220, 255, 0.7);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-button:hover,
|
||||||
|
.locale-picker-button:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-globe {
|
||||||
|
flex: none;
|
||||||
|
width: 0.95rem;
|
||||||
|
height: 0.95rem;
|
||||||
|
color: #55dff5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-caret {
|
||||||
|
flex: none;
|
||||||
|
width: 0.62rem;
|
||||||
|
height: 0.62rem;
|
||||||
|
margin-inline-start: -0.1rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker.is-open .locale-picker-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Opens upward, aligned to the corner edge. */
|
||||||
|
.locale-picker-popup {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: calc(100% + 0.4rem);
|
||||||
|
inset-inline-end: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 14rem;
|
||||||
|
max-width: calc(100vw - 1.8rem);
|
||||||
|
max-height: min(26rem, calc(100vh - 4.5rem));
|
||||||
|
max-height: min(26rem, calc(100dvh - 4.5rem));
|
||||||
|
padding: 0.45rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background:
|
||||||
|
linear-gradient(145deg, rgba(10, 27, 37, 0.97), rgba(7, 13, 22, 0.95)),
|
||||||
|
rgba(7, 17, 25, 0.96);
|
||||||
|
border: 1px solid rgba(54, 220, 255, 0.42);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
box-shadow:
|
||||||
|
0 1rem 3rem rgba(0, 0, 0, 0.65),
|
||||||
|
0 0 2rem rgba(0, 212, 255, 0.08),
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.025);
|
||||||
|
backdrop-filter: blur(24px) saturate(1.25);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-popup[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-heading {
|
||||||
|
padding: 0.2rem 0.4rem 0.45rem;
|
||||||
|
color: rgba(85, 223, 245, 0.85);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
letter-spacing: 0.14rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-search {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(54, 220, 255, 0.28);
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-search:focus {
|
||||||
|
border-color: rgba(54, 220, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
outline: none;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-option.is-active {
|
||||||
|
background: rgba(54, 220, 255, 0.09);
|
||||||
|
border-color: rgba(54, 220, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus lives on the list (aria-activedescendant): show it. */
|
||||||
|
.locale-picker-list:focus-visible .locale-picker-option.is-active,
|
||||||
|
.locale-picker-search:focus
|
||||||
|
~ .locale-picker-list
|
||||||
|
.locale-picker-option.is-active {
|
||||||
|
border-color: rgba(54, 220, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-native {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-option[aria-selected='true'] .locale-picker-native {
|
||||||
|
color: #55dff5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: rgba(199, 225, 231, 0.6);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.55rem;
|
||||||
|
letter-spacing: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-check {
|
||||||
|
flex: none;
|
||||||
|
width: 0.95rem;
|
||||||
|
height: 0.95rem;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
color: #55dff5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
color: rgba(199, 225, 231, 0.62);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-empty[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The surfaces that hide the rest of the chrome hide the corner too. The
|
||||||
|
cockpit keeps the language picker (provider-settings.css hides the chip). */
|
||||||
|
body.ui-clean-view #corner-actions,
|
||||||
|
body.recording-mode #corner-actions,
|
||||||
|
body.scene-playback-mode #corner-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The cockpit's position readout shares this corner: it steps up to clear
|
||||||
|
the picker row (0.9rem inset + one ~2rem button + a gap). */
|
||||||
|
body.cockpit-mode .cockpit-position-readout {
|
||||||
|
bottom: max(clamp(22px, 4vh, 42px), 3.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below this width the full chip label runs into the centred dock: the chip
|
||||||
|
keeps its bolt, and its count stays in its title and aria-label. */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
#corner-actions #key-setup-chip [data-key-setup-chip-label] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
/* Phone: the corner beside the dock, clear of the attribution above it.
|
||||||
|
There is room for one button across, so the chip stacks on the globe. */
|
||||||
|
#corner-actions {
|
||||||
|
inset-inline-end: 10px;
|
||||||
|
inset-block-end: max(16px, env(safe-area-inset-bottom));
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#corner-actions #key-setup-chip {
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phone: the globe alone; the label stays in aria-label. */
|
||||||
|
.locale-picker-code,
|
||||||
|
.locale-picker-caret {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-button {
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The list becomes a bottom sheet across the screen. */
|
||||||
|
.locale-picker-popup {
|
||||||
|
position: fixed;
|
||||||
|
inset-inline: 16px;
|
||||||
|
inset-block-end: 16px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
max-height: calc(100dvh - 32px);
|
||||||
|
padding: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locale-picker-option {
|
||||||
|
padding-block: 0.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,9 @@
|
|||||||
mono kickers. The chip retires once every key is configured.
|
mono kickers. The chip retires once every key is configured.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
/* The chip sits in the bottom-right corner row (#corner-actions,
|
||||||
|
locale-picker.css), which owns the position. */
|
||||||
#key-setup-chip {
|
#key-setup-chip {
|
||||||
position: fixed;
|
|
||||||
right: 0.9rem;
|
|
||||||
bottom: 0.9rem;
|
|
||||||
z-index: 60;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -389,13 +387,6 @@ body.scene-playback-mode #key-setup {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
|
||||||
/* Clear the dock. Desktop-first app; this is a courtesy, not a layout. */
|
|
||||||
#key-setup-chip {
|
|
||||||
bottom: 5.8rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Provider Settings additions: externally-managed badge + per-row remove. */
|
/* Provider Settings additions: externally-managed badge + per-row remove. */
|
||||||
.key-setup-external {
|
.key-setup-external {
|
||||||
padding: 0.05rem 0.3rem;
|
padding: 0.05rem 0.3rem;
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
<span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
|
<span class="panel-title" data-i18n-runtime><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
|
||||||
<span class="panel-divider"></span>
|
<span class="panel-divider"></span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Language selector: buttons are rendered by applicationShell _initLocaleSelector(), one per shipped locale. -->
|
|
||||||
<div class="dock-locale-switch" role="group" data-i18n-aria-label aria-label="Language"></div>
|
|
||||||
<div id="control-panel-popover" class="dock-popover-content">
|
<div id="control-panel-popover" class="dock-popover-content">
|
||||||
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
|
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open" data-i18n-aria-label data-i18n-title><img src="/pin.svg" alt="" /></button>
|
||||||
<div id="style-buttons" class="button-grid">
|
<div id="style-buttons" class="button-grid">
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
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. -->
|
||||||
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
|
<!-- Bottom-right corner, one HUD row: the POWER UP chip (while keys are
|
||||||
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
|
missing) and, in the very corner, the interface language picker that
|
||||||
<span data-key-setup-chip-label data-i18n>POWER UP</span>
|
src/ui/localePicker.js renders into #locale-picker. -->
|
||||||
</button>
|
<div id="corner-actions">
|
||||||
|
<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 data-key-setup-chip-label data-i18n>POWER UP</span>
|
||||||
|
</button>
|
||||||
|
<div id="locale-picker"></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>
|
||||||
<header class="key-setup-header">
|
<header class="key-setup-header">
|
||||||
|
|||||||
@@ -689,7 +689,6 @@ test('history time keeps the hour whatever the length of the localized date (K-6
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('air-quality readout renders its band legend, Canada coverage and worst-first list', async () => {
|
test('air-quality readout renders its band legend, Canada coverage and worst-first list', async () => {
|
||||||
const observedMs = Date.now() - 10 * 60_000;
|
const observedMs = Date.now() - 10 * 60_000;
|
||||||
const reading = (stationId, value, band, name) => ({
|
const reading = (stationId, value, band, name) => ({
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
@import './src/ui/styles/voice-card.css';
|
@import './src/ui/styles/voice-card.css';
|
||||||
@import './src/ui/styles/first-run.css';
|
@import './src/ui/styles/first-run.css';
|
||||||
@import './src/ui/styles/provider-settings.css';
|
@import './src/ui/styles/provider-settings.css';
|
||||||
|
@import './src/ui/styles/locale-picker.css';
|
||||||
|
|
||||||
@import './src/ui/styles/weather.css';
|
@import './src/ui/styles/weather.css';
|
||||||
@import './src/ui/styles/panel-surfaces.css';
|
@import './src/ui/styles/panel-surfaces.css';
|
||||||
|
|||||||
Reference in new issue
Block a user