Files
AnyPS5/tools/input-mapper/dropdown.js
T

168 lines
7.3 KiB
JavaScript

const dialog = document.getElementById('picker-dialog');
const title = document.getElementById('picker-title');
const search = document.getElementById('picker-search');
const list = document.getElementById('picker-options');
const empty = document.getElementById('picker-empty');
const close = document.getElementById('picker-close');
let active;
let visible = [];
let focused = 0;
function renderOptions() {
const query = search.value.trim().toLocaleLowerCase();
visible = active.choices.filter(item => `${item.label} ${item.value}`.toLocaleLowerCase().includes(query));
list.replaceChildren();
const exactMatch = query ? visible.findIndex(item => item.label.toLocaleLowerCase() === query || item.value.toLocaleLowerCase() === query) : -1;
focused = Math.max(0, exactMatch >= 0 ? exactMatch : visible.findIndex(item => item.value === active.select.value));
visible.forEach((item, index) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'picker-option';
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', String(item.value === active.select.value));
option.tabIndex = index === focused ? 0 : -1;
option.textContent = item.label;
if (item.lang) option.lang = item.lang;
option.dir = item.direction || active.direction;
option.addEventListener('click', () => choose(item));
option.addEventListener('focus', () => { focused = index; });
list.append(option);
});
empty.hidden = visible.length !== 0;
if (dialog.open) list.children[focused]?.scrollIntoView({block: 'nearest'});
}
function choose(item) {
const current = active;
current.setValue(item.value);
dialog.close();
current.select.dispatchEvent(new Event('change', {bubbles: true}));
}
function focusOption(index) {
if (!visible.length) return;
focused = (index + visible.length) % visible.length;
[...list.children].forEach((option, position) => { option.tabIndex = position === focused ? 0 : -1; });
list.children[focused].focus({preventScroll: true});
list.children[focused].scrollIntoView({block: 'nearest'});
}
function positionPicker() {
if (!dialog.open || !active) return;
if (matchMedia('(max-width: 600px)').matches) {
for (const name of ['top', 'left', 'width']) dialog.style.removeProperty(name);
return;
}
const rect = active.trigger.getBoundingClientRect();
const width = Math.min(Math.max(rect.width, 320), innerWidth - 32);
dialog.style.width = `${width}px`;
const height = dialog.getBoundingClientRect().height;
const preferred = rect.bottom + 10;
const top = preferred + height <= innerHeight - 16 ? preferred : Math.max(16, rect.top - height - 10);
const left = active.direction === 'rtl' ? rect.right - width : rect.left;
dialog.style.top = `${Math.min(top, Math.max(16, innerHeight - height - 16))}px`;
dialog.style.left = `${Math.max(16, Math.min(left, innerWidth - width - 16))}px`;
}
function openPicker(control) {
active = control;
title.textContent = control.label;
list.setAttribute('aria-label', control.label);
search.value = '';
search.placeholder = control.text.search;
search.setAttribute('aria-label', control.text.search);
close.textContent = control.text.close;
empty.textContent = control.text.empty;
dialog.style.setProperty('--picker-height', `${Math.min(480, 136 + Math.min(control.choices.length, 8) * 46)}px`);
renderOptions();
control.trigger.setAttribute('aria-expanded', 'true');
dialog.showModal();
positionPicker();
list.children[focused]?.scrollIntoView({block: 'nearest'});
search.focus();
}
search.addEventListener('input', renderOptions);
dialog.addEventListener('keydown', event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const inSearch = event.target === search;
focusOption(inSearch ? focused : focused + (event.key === 'ArrowDown' ? 1 : -1));
} else if (event.target !== search && (event.key === 'Home' || event.key === 'End')) {
event.preventDefault();
focusOption(event.key === 'Home' ? 0 : visible.length - 1);
} else if (event.target === search && event.key === 'Enter' && visible.length) {
event.preventDefault();
choose(visible[focused]);
}
});
close.addEventListener('click', () => dialog.close());
dialog.addEventListener('click', event => {
if (event.target !== dialog) return;
const rect = dialog.getBoundingClientRect();
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) dialog.close();
});
dialog.addEventListener('close', () => {
const current = active;
active = undefined;
current?.trigger.setAttribute('aria-expanded', 'false');
if (current?.trigger.isConnected) current.trigger.focus({preventScroll: true});
});
window.addEventListener('resize', positionPicker);
export function createDropdown({label, choices, value, placeholder, text, trigger, direction = 'ltr'}) {
const wrapper = document.createElement('div');
wrapper.className = 'dropdown';
const select = document.createElement('select');
select.hidden = true;
const providedTrigger = Boolean(trigger);
const control = trigger || document.createElement('button');
control.type = 'button';
if (!providedTrigger) {
control.className = 'dropdown-trigger';
control.setAttribute('role', 'combobox');
}
control.setAttribute('aria-label', label);
control.setAttribute('aria-haspopup', 'dialog');
control.setAttribute('aria-controls', 'picker-dialog');
control.setAttribute('aria-expanded', 'false');
const selected = document.createElement('span');
selected.className = 'dropdown-value';
if (!providedTrigger) control.append(selected);
const api = {
element: wrapper, select, trigger: control, label, direction, text, choices: [],
setValue(next) {
select.value = next;
const choice = api.choices.find(item => item.value === select.value);
if (!providedTrigger) selected.textContent = choice?.label || placeholder;
if (!providedTrigger) control.title = choice?.label || placeholder;
},
setChoices(next) {
const previous = select.value;
api.choices = next.map(item => typeof item === 'string' ? {value: item, label: item} : item);
select.replaceChildren();
for (const item of api.choices) {
const option = document.createElement('option');
option.value = item.value;
option.textContent = item.label;
select.append(option);
}
api.setValue(previous);
},
focus() { control.focus(); }
};
api.setChoices(choices);
api.setValue(value);
select.addEventListener('change', () => api.setValue(select.value));
control.addEventListener('click', () => openPicker(api));
control.addEventListener('keydown', event => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
openPicker(api);
}
});
wrapper.append(select);
if (!providedTrigger) wrapper.append(control);
return api;
}