A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
319 lines
12 KiB
JavaScript
319 lines
12 KiB
JavaScript
/* Custom <select> enhancer.
|
|
*
|
|
* Native <select> popups are drawn by the OS and largely ignore CSS,
|
|
* which is why dropdowns rendered like "Local / Git Repository" or
|
|
* "TLS / SSL / None" appear with stock white chrome on Linux/Firefox
|
|
* even with our themed select.form-control rule. This module enhances
|
|
* every <select class="form-control"> by:
|
|
*
|
|
* 1. Keeping the native <select> in the DOM so form submission and
|
|
* any existing code reading .value / dispatching change events
|
|
* keeps working — it's just visually replaced.
|
|
* 2. Building a sibling <button> + popup <div> that we fully theme.
|
|
* 3. Wiring keyboard nav (ArrowUp/Down, Enter, Escape, Home/End)
|
|
* and ARIA roles for screen readers.
|
|
* 4. Watching the DOM (MutationObserver) so dynamically rendered
|
|
* forms (apps-manager, config-renderer, etc.) auto-enhance.
|
|
*
|
|
* Opt-out: put data-no-enhance on the <select> to keep the native one.
|
|
*/
|
|
|
|
(() => {
|
|
const ENHANCED = Symbol('customSelectEnhanced');
|
|
|
|
class CustomSelect {
|
|
constructor(selectEl) {
|
|
if (selectEl[ENHANCED]) return;
|
|
selectEl[ENHANCED] = true;
|
|
this.select = selectEl;
|
|
this.build();
|
|
this.bind();
|
|
this.sync();
|
|
}
|
|
|
|
build() {
|
|
this.wrapper = document.createElement('div');
|
|
this.wrapper.className = 'custom-select';
|
|
if (this.select.disabled) this.wrapper.classList.add('is-disabled');
|
|
|
|
this.button = document.createElement('button');
|
|
this.button.type = 'button';
|
|
this.button.className = 'custom-select-button';
|
|
this.button.setAttribute('aria-haspopup', 'listbox');
|
|
this.button.setAttribute('aria-expanded', 'false');
|
|
if (this.select.disabled) this.button.disabled = true;
|
|
|
|
this.label = document.createElement('span');
|
|
this.label.className = 'custom-select-label';
|
|
|
|
this.arrow = document.createElement('span');
|
|
this.arrow.className = 'custom-select-arrow';
|
|
this.arrow.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
|
|
|
|
this.button.append(this.label, this.arrow);
|
|
|
|
this.popup = document.createElement('div');
|
|
this.popup.className = 'custom-select-popup';
|
|
this.popup.setAttribute('role', 'listbox');
|
|
this.popup.hidden = true;
|
|
|
|
// Move the native select inside the wrapper so it stays in the form.
|
|
this.select.parentNode.insertBefore(this.wrapper, this.select);
|
|
this.wrapper.append(this.select, this.button, this.popup);
|
|
this.select.classList.add('custom-select-native');
|
|
// Forward width-related layout from the native select to the wrapper.
|
|
if (this.select.style.maxWidth) {
|
|
this.wrapper.style.maxWidth = this.select.style.maxWidth;
|
|
}
|
|
if (this.select.style.width && this.select.style.width !== '100%') {
|
|
this.wrapper.style.width = this.select.style.width;
|
|
}
|
|
}
|
|
|
|
sync() {
|
|
this.renderOptions();
|
|
this.renderLabel();
|
|
}
|
|
|
|
renderOptions() {
|
|
this.popup.innerHTML = '';
|
|
const opts = [...this.select.options];
|
|
opts.forEach((opt, idx) => {
|
|
const li = document.createElement('div');
|
|
li.className = 'custom-select-option';
|
|
li.setAttribute('role', 'option');
|
|
li.dataset.value = opt.value;
|
|
li.dataset.index = String(idx);
|
|
li.textContent = opt.textContent;
|
|
if (opt.disabled) li.classList.add('is-disabled');
|
|
if (opt.selected) {
|
|
li.classList.add('is-selected');
|
|
li.setAttribute('aria-selected', 'true');
|
|
}
|
|
this.popup.appendChild(li);
|
|
});
|
|
}
|
|
|
|
renderLabel() {
|
|
const sel = this.select.options[this.select.selectedIndex];
|
|
this.label.textContent = sel ? sel.textContent : '';
|
|
}
|
|
|
|
open() {
|
|
if (this.select.disabled) return;
|
|
// Close any other open popup first.
|
|
document.querySelectorAll('.custom-select.is-open').forEach(w => {
|
|
if (w !== this.wrapper) w.dispatchEvent(new CustomEvent('custom-select:close'));
|
|
});
|
|
this.popup.hidden = false;
|
|
this.button.setAttribute('aria-expanded', 'true');
|
|
this.wrapper.classList.add('is-open');
|
|
this.positionPopup();
|
|
// Initial focus highlight on the currently-selected option.
|
|
const sel = this.popup.querySelector('.is-selected');
|
|
if (sel) {
|
|
this.setFocused(sel);
|
|
sel.scrollIntoView({ block: 'nearest' });
|
|
}
|
|
document.addEventListener('click', this._outsideClick, true);
|
|
document.addEventListener('keydown', this._onKey);
|
|
window.addEventListener('resize', this._reposition);
|
|
window.addEventListener('scroll', this._reposition, true);
|
|
}
|
|
|
|
close() {
|
|
if (this.popup.hidden) return;
|
|
this.popup.hidden = true;
|
|
this.button.setAttribute('aria-expanded', 'false');
|
|
this.wrapper.classList.remove('is-open');
|
|
this.clearFocused();
|
|
document.removeEventListener('click', this._outsideClick, true);
|
|
document.removeEventListener('keydown', this._onKey);
|
|
window.removeEventListener('resize', this._reposition);
|
|
window.removeEventListener('scroll', this._reposition, true);
|
|
}
|
|
|
|
positionPopup() {
|
|
// Popup is position: fixed so it can escape any ancestor with
|
|
// overflow: hidden (modals, .container, etc.). We compute the
|
|
// coordinates here from the button's viewport rect.
|
|
const rect = this.button.getBoundingClientRect();
|
|
const spaceBelow = window.innerHeight - rect.bottom;
|
|
const popupMax = 280;
|
|
const flipUp = spaceBelow < 200 && rect.top > popupMax;
|
|
this.wrapper.classList.toggle('flip-up', flipUp);
|
|
|
|
this.popup.style.left = `${rect.left}px`;
|
|
this.popup.style.width = `${rect.width}px`;
|
|
if (flipUp) {
|
|
this.popup.style.top = 'auto';
|
|
this.popup.style.bottom = `${window.innerHeight - rect.top + 6}px`;
|
|
} else {
|
|
this.popup.style.top = `${rect.bottom + 6}px`;
|
|
this.popup.style.bottom = 'auto';
|
|
}
|
|
}
|
|
|
|
setFocused(el) {
|
|
this.clearFocused();
|
|
if (!el) return;
|
|
el.classList.add('is-focused');
|
|
}
|
|
|
|
clearFocused() {
|
|
this.popup.querySelectorAll('.is-focused').forEach(el => el.classList.remove('is-focused'));
|
|
}
|
|
|
|
pick(value) {
|
|
// Don't dispatch if value unchanged.
|
|
if (this.select.value !== value) {
|
|
this.select.value = value;
|
|
// Native change event so existing onchange handlers run.
|
|
this.select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
this.select.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
this.sync();
|
|
this.close();
|
|
this.button.focus();
|
|
}
|
|
|
|
bind() {
|
|
this._onKey = (e) => {
|
|
const opts = [...this.popup.querySelectorAll('.custom-select-option:not(.is-disabled)')];
|
|
if (!opts.length) return;
|
|
const focused = this.popup.querySelector('.is-focused');
|
|
const i = focused ? opts.indexOf(focused) : opts.findIndex(o => o.classList.contains('is-selected'));
|
|
switch (e.key) {
|
|
case 'Escape':
|
|
e.preventDefault();
|
|
this.close();
|
|
this.button.focus();
|
|
return;
|
|
case 'Enter':
|
|
case ' ':
|
|
e.preventDefault();
|
|
if (focused) this.pick(focused.dataset.value);
|
|
return;
|
|
case 'ArrowDown': {
|
|
e.preventDefault();
|
|
const next = opts[Math.min(i + 1, opts.length - 1)] ?? opts[0];
|
|
this.setFocused(next);
|
|
next.scrollIntoView({ block: 'nearest' });
|
|
return;
|
|
}
|
|
case 'ArrowUp': {
|
|
e.preventDefault();
|
|
const prev = opts[Math.max(i - 1, 0)] ?? opts[opts.length - 1];
|
|
this.setFocused(prev);
|
|
prev.scrollIntoView({ block: 'nearest' });
|
|
return;
|
|
}
|
|
case 'Home':
|
|
e.preventDefault();
|
|
this.setFocused(opts[0]);
|
|
opts[0].scrollIntoView({ block: 'nearest' });
|
|
return;
|
|
case 'End':
|
|
e.preventDefault();
|
|
this.setFocused(opts[opts.length - 1]);
|
|
opts[opts.length - 1].scrollIntoView({ block: 'nearest' });
|
|
return;
|
|
}
|
|
};
|
|
|
|
this._outsideClick = (e) => {
|
|
if (!this.wrapper.contains(e.target)) this.close();
|
|
};
|
|
|
|
this._reposition = () => this.positionPopup();
|
|
|
|
this.button.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (this.wrapper.classList.contains('is-open')) this.close();
|
|
else this.open();
|
|
});
|
|
|
|
this.button.addEventListener('keydown', (e) => {
|
|
if (this.wrapper.classList.contains('is-open')) return;
|
|
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
this.open();
|
|
}
|
|
});
|
|
|
|
this.popup.addEventListener('click', (e) => {
|
|
const opt = e.target.closest('.custom-select-option');
|
|
if (!opt || opt.classList.contains('is-disabled')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this.pick(opt.dataset.value);
|
|
});
|
|
|
|
this.popup.addEventListener('mouseover', (e) => {
|
|
const opt = e.target.closest('.custom-select-option');
|
|
if (opt && !opt.classList.contains('is-disabled')) this.setFocused(opt);
|
|
});
|
|
|
|
this.wrapper.addEventListener('custom-select:close', () => this.close());
|
|
|
|
// External code may set select.value programmatically — listen for
|
|
// the change event so the visual stays in sync.
|
|
this.select.addEventListener('change', () => this.sync());
|
|
|
|
// Watch for options being replaced (e.g. config-options.js repaints
|
|
// the VPN-type select when provider changes).
|
|
this.optionsObserver = new MutationObserver(() => this.sync());
|
|
this.optionsObserver.observe(this.select, { childList: true, attributes: true, attributeFilter: ['disabled'] });
|
|
}
|
|
}
|
|
|
|
// Classes that participate in the themed dropdown. Add more here
|
|
// (or use data-no-enhance to opt a specific <select> out) as new
|
|
// dropdown surfaces appear in the app.
|
|
const ENHANCE_CLASSES = ['form-control', 'theme-selector'];
|
|
const ENHANCE_SELECTOR = ENHANCE_CLASSES.map(c => `select.${c}`).join(',');
|
|
|
|
function shouldEnhance(el) {
|
|
if (!(el instanceof HTMLSelectElement)) return false;
|
|
if (el[ENHANCED]) return false;
|
|
if (el.multiple) return false; // multi-selects need different UX
|
|
if (el.hasAttribute('data-no-enhance')) return false;
|
|
return ENHANCE_CLASSES.some(c => el.classList.contains(c));
|
|
}
|
|
|
|
function enhanceAll(root = document) {
|
|
root.querySelectorAll(ENHANCE_SELECTOR).forEach(s => {
|
|
if (shouldEnhance(s)) new CustomSelect(s);
|
|
});
|
|
}
|
|
|
|
function start() {
|
|
enhanceAll();
|
|
// Catch dynamically rendered forms (apps-manager, config-renderer, etc.).
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const m of mutations) {
|
|
for (const node of m.addedNodes) {
|
|
if (node.nodeType !== 1) continue;
|
|
if (shouldEnhance(node)) {
|
|
new CustomSelect(node);
|
|
} else {
|
|
enhanceAll(node);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
|
|
// This script is loaded from <head>, so document.body may not exist
|
|
// yet at execution time. Wait for DOMContentLoaded before touching it.
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', start);
|
|
} else {
|
|
start();
|
|
}
|
|
|
|
// Public hook in case a screen wants to force re-enhance (rare).
|
|
window.CustomSelect = { enhance: enhanceAll, _Class: CustomSelect };
|
|
})();
|