mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
i18n: language picker in the bottom-right corner
Replaces the EN/ES/RU buttons in the Visual Presets dock with a picker that scales to many languages: a globe button with the current code next to the POWER UP chip, opening a list of languages named in their own language with their codes, the current one first and checked. A search field appears past 8 languages. Keyboard: arrows, Home/End, Enter/Space, Esc, type-ahead; keys stay in the list while it is open. Choosing a language still asks before dropping unsaved work, then reloads. Phones get the globe alone and a full-width sheet; the layout uses logical properties for right-to-left languages. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7fd1dd867f
commit
6031c95a81
21 files changed
+1381
-139
No files matched your search
@@ -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 = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user