diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3eb78c290..47ced056b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3139,8 +3139,8 @@ Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → `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 - their own language. The language switch in the Visual Presets dock stores the - choice, removes `?lang=` (keeps it only where storage is blocked) and reloads, + their own language. The language picker in the bottom-right corner (a globe + 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 (unsaved CCTV calibration, the open cockpit, Draw marks on the board, imported map files, an active voice session — each registered through diff --git a/docs/I18N.md b/docs/I18N.md index 550c50d75..767172b64 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -33,7 +33,7 @@ label.textContent = t(FEED_STATE_LABELS[state]); ``` -`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 @@ -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. -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`. diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 9eec06cc1..782446ecd 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -22,7 +22,7 @@ Every other language is one flat JSON file, `src/i18n/locales/.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. -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 08760e422..8c8dd13ba 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -624,6 +624,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", "src/ui/cockpitCoordinator.js", @@ -2063,6 +2064,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/ui/applicationShell.js", + "src/ui/localePicker.js", "src/ui/cyberSonarControls.js", "src/ui/locationNavigation.js", "src/ui/cockpitCoordinator.js", diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 50cf061ce..f943d44ff 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -8,7 +8,8 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; export const DEFAULT_LOCALE = 'en'; /** - * Shipped locales, in selector order. Adding one: locales/.json, an + * Shipped locales. The picker (src/ui/localePicker.js) lists the active one + * first and the rest by LOCALE_NAMES. Adding one: locales/.json, an * import in index.js, and an entry here (see docs/TRANSLATORS.md). */ export const LOCALES = Object.freeze(['en', 'es', 'ru']); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d9d0315e2..a06512624 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -22,7 +22,11 @@ "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", "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 the keyless globe...": "Cargando el globo sin clave...", "Initializing systems...": "Inicializando sistemas...", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ab61f56cc..3e8e1f152 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -688,7 +688,11 @@ "Keep location tray open": "Держать панель места открытой", "Search any location": "Поиск любого места", "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 any location...": "Поиск любого места...", "Normal": "Обычный", diff --git a/src/keySetup.js b/src/keySetup.js index dd43492d6..177937126 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -345,17 +345,22 @@ function buildRow(documentRef, key) { 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 toolbar = documentRef?.getElementById?.('top-center-actions'); const Observer = documentRef?.defaultView?.MutationObserver; - if (!theme || !toolbar || !Observer) return () => {}; + const home = chip?.parentNode; + if (!theme || !toolbar || !Observer || !home) return () => {}; const sync = () => { if (theme.dataset.uiTheme === 'cyber') { if (chip.parentNode !== toolbar) toolbar.append(chip); - } else if (chip.parentNode !== root.parentNode) { - root.before(chip); + } else if (chip.parentNode !== home) { + home.prepend(chip); } }; const observer = new Observer(sync); @@ -415,7 +420,7 @@ export async function initKeySetup({ } 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 closeButton = root.querySelector('[data-key-setup-close]'); const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip; diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index 4cdba1765..b3662bedf 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -9,9 +9,8 @@ import { } from './keySetup.js'; 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 root = { parentNode: home, before: (node) => { node.parentNode = home; } }; let moves = 0; const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } }; 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, getElementById: () => toolbar, defaultView: { MutationObserver: Observer }, - }, chip, root); + }, chip); assert.equal(chip.parentNode, home); for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) { theme.dataset.uiTheme = variant; diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4af8a9d95..56fbc7f43 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -45,7 +45,7 @@ import { registerUnsavedWork, t, } 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. @@ -715,17 +715,13 @@ export class StyleManager extends ShellFacade { } /** - * Renders the command-dock locale switch: one button per shipped locale, - * labelled with its code and named in its own language. A click stores the - * choice and reloads, so nothing re-renders live; it asks first when the - * reload would lose work (see _confirmLocaleReload). + * Mounts the interface language picker in the bottom-right corner + * (src/ui/localePicker.js). Choosing another language stores it and + * reloads, so nothing re-renders live; it asks first when the reload would + * lose work (see _chooseLocale). * @returns {void} */ _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 // their own (registerUnsavedWork in src/i18n/index.js). this._unsavedWorkChecks = [ @@ -740,24 +736,27 @@ export class StyleManager extends ShellFacade { this.cockpitView?.active ? msg('the open cockpit') : null, ), ]; - const active = getLocale(); - for (const locale of LOCALES) { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'dock-locale-btn'; - button.dataset.locale = locale; - button.textContent = locale.toUpperCase(); - button.setAttribute('aria-label', LOCALE_NAMES[locale]); - button.lang = locale; - const isActive = locale === active; - button.classList.toggle('active', isActive); - button.setAttribute('aria-pressed', String(isActive)); - button.addEventListener('click', () => { - if (!this._confirmLocaleReload()) return; - persistLocaleAndReload(button.dataset.locale); - }); - container.appendChild(button); - } + const root = globalThis.document?.getElementById?.('locale-picker'); + if (!root) return; + this._localePicker = mountLocalePicker({ + root, + current: getLocale(), + onSelect: (locale) => this._chooseLocale(locale), + }); + } + + /** + * Switch to `locale`: nothing for the active language, otherwise store it + * and reload once _confirmLocaleReload agrees. + * @param {string} locale + * @returns {'unchanged'|'declined'|'switched'} + */ + _chooseLocale(locale) { + return applyLocaleChoice(locale, { + current: getLocale(), + confirmSwitch: () => this._confirmLocaleReload(), + switchLocale: (next) => persistLocaleAndReload(next), + }); } /** @@ -1759,6 +1758,8 @@ export class StyleManager extends ShellFacade { this._lifetime.destroy(); for (const unregister of this._unsavedWorkChecks?.splice(0) ?? []) unregister(); + this._localePicker?.destroy(); + this._localePicker = null; this._recording.destroy(); this._panelChrome.destroy(); this._feedback.destroy(); diff --git a/src/ui/localePicker.js b/src/ui/localePicker.js new file mode 100644 index 000000000..7de5103aa --- /dev/null +++ b/src/ui/localePicker.js @@ -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} 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} [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} [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 = ''; + }, + }; +} diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs new file mode 100644 index 000000000..7c7003eec --- /dev/null +++ b/src/ui/localePicker.test.mjs @@ -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); +}); diff --git a/src/ui/localeSwitchReload.test.mjs b/src/ui/localeSwitchReload.test.mjs index 04121dd11..0cd2da5cc 100644 --- a/src/ui/localeSwitchReload.test.mjs +++ b/src/ui/localeSwitchReload.test.mjs @@ -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 -// a module registers). +// a module registers). Choosing the language already active does nothing. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { StyleManager } from './applicationShell.js'; @@ -11,20 +11,9 @@ import { setLocale, } from '../i18n/index.js'; -function fakeButton() { - const listeners = {}; - 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. */ +/** 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. */ function renderSwitch({ calDirty = false, cockpit = false } = {}) { - const buttons = []; const previous = { document: globalThis.document, location: globalThis.location, @@ -34,10 +23,7 @@ function renderSwitch({ calDirty = false, cockpit = false } = {}) { const reloads = []; const prompts = []; let answer = true; - globalThis.document = { - querySelector: () => ({ appendChild: (button) => buttons.push(button) }), - createElement: fakeButton, - }; + globalThis.document = { getElementById: () => null }; globalThis.location = { href: 'https://gev.example/#v=2', assign() {}, @@ -56,12 +42,11 @@ function renderSwitch({ calDirty = false, cockpit = false } = {}) { }, cockpitView: { active: cockpit }, _confirmLocaleReload: StyleManager.prototype._confirmLocaleReload, + _chooseLocale: StyleManager.prototype._chooseLocale, }; StyleManager.prototype._initLocaleSelector.call(shell); - const button = (locale) => - buttons.find((candidate) => candidate.dataset.locale === locale); return { - button, + choose: (locale) => shell._chooseLocale(locale), reloads, prompts, answer: (value) => (answer = value), @@ -83,7 +68,7 @@ test.after(() => { test('nothing to lose: the switch reloads without asking', () => { const view = renderSwitch(); try { - view.button('es').click(); + view.choose('es'); assert.deepEqual(view.prompts, []); assert.equal(view.reloads.length, 1); } finally { @@ -95,13 +80,13 @@ test('unsaved calibration or the open cockpit: the switch asks first', () => { const view = renderSwitch({ calDirty: true, cockpit: true }); try { view.answer(false); - view.button('ru').click(); + view.choose('ru'); assert.equal(view.prompts.length, 1); assert.match(view.prompts[0], /unsaved CCTV calibration/); assert.match(view.prompts[0], /the open cockpit/); assert.equal(view.reloads.length, 0, 'declined: no reload'); view.answer(true); - view.button('ru').click(); + view.choose('ru'); assert.equal(view.reloads.length, 1, 'confirmed: reload'); } finally { view.restore(); @@ -116,7 +101,7 @@ test('modules register their own unsaved work', () => { const view = renderSwitch(); try { view.answer(false); - view.button('es').click(); + view.choose('es'); assert.match(view.prompts[0], /drawings on the map/); assert.equal(view.reloads.length, 0); } finally { @@ -132,7 +117,7 @@ test('the prompt reads in the active language', () => { const view = renderSwitch({ calDirty: true }); try { view.answer(false); - view.button('en').click(); + view.choose('en'); assert.equal(view.prompts.length, 1); assert.doesNotMatch(view.prompts[0], /Switching language/); } finally { @@ -140,3 +125,14 @@ test('the prompt reads in the active language', () => { 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(); + } +}); diff --git a/src/ui/styles/command-dock-sliding.css b/src/ui/styles/command-dock-sliding.css index 993fe7df5..ee64b9a8c 100644 --- a/src/ui/styles/command-dock-sliding.css +++ b/src/ui/styles/command-dock-sliding.css @@ -701,54 +701,3 @@ html:not([lang='en']) #gev-voice-control .gev-voice-help-detail { 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); -} diff --git a/src/ui/styles/cyber.css b/src/ui/styles/cyber.css index c8f5b139f..7d52adf0d 100644 --- a/src/ui/styles/cyber.css +++ b/src/ui/styles/cyber.css @@ -174,6 +174,8 @@ .context-mode-button, .map-stack-chip, .key-setup-chip, + .locale-picker-button, + .locale-picker-popup, .first-run-card ) { border-color: var(--glass-border); diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css index 3567f6a50..5528a76da 100644 --- a/src/ui/styles/embed.css +++ b/src/ui/styles/embed.css @@ -3,6 +3,7 @@ body.ui-embed #clean-view-exit, body.ui-embed #intel-hud, body.ui-embed #key-setup-chip, +body.ui-embed #corner-actions, body.ui-embed #key-setup, body.ui-embed #first-run-launcher, body.ui-embed #command-dock, diff --git a/src/ui/styles/locale-picker.css b/src/ui/styles/locale-picker.css new file mode 100644 index 000000000..6f5d994b3 --- /dev/null +++ b/src/ui/styles/locale-picker.css @@ -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; + } +} diff --git a/src/ui/styles/provider-settings.css b/src/ui/styles/provider-settings.css index 7563a886d..da29da9ae 100644 --- a/src/ui/styles/provider-settings.css +++ b/src/ui/styles/provider-settings.css @@ -4,11 +4,9 @@ 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 { - position: fixed; - right: 0.9rem; - bottom: 0.9rem; - z-index: 60; display: flex; align-items: center; gap: 0.4rem; @@ -389,13 +387,6 @@ body.scene-playback-mode #key-setup { 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. */ .key-setup-external { padding: 0.05rem 0.3rem; diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html index 4c46ed6a6..e6e2735ea 100644 --- a/src/ui/templates/command-dock.html +++ b/src/ui/templates/command-dock.html @@ -10,8 +10,6 @@ VISUAL PRESETS - -
diff --git a/src/ui/templates/provider-settings.html b/src/ui/templates/provider-settings.html index 7422a4513..fbab6594a 100644 --- a/src/ui/templates/provider-settings.html +++ b/src/ui/templates/provider-settings.html @@ -2,10 +2,16 @@ both the chip and this dialog outright when /api/setup/status is not there to answer (prod builds, LAN visitors). Rows are rendered from that payload, so the key registry lives in src/keySetupCore.mjs alone. --> - + +
+ +
+