librelad 875a60f90f LibrePortal v0.1.0 — initial release
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>
2026-05-21 20:37:54 +01:00

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 };
})();