206 lines
8.1 KiB
JavaScript
206 lines
8.1 KiB
JavaScript
// public/js/util.js
|
|
export const fmt = new Intl.NumberFormat(undefined,{maximumFractionDigits:6});
|
|
export const currency = (v,cur='USD')=> new Intl.NumberFormat(undefined,{style:'currency',currency:cur,maximumFractionDigits:2}).format(v);
|
|
export const persist = (key, obj) => localStorage.setItem(key, JSON.stringify(obj));
|
|
export const revive = (key, fallback={}) => { try { return JSON.parse(localStorage.getItem(key)) || fallback } catch { return fallback } };
|
|
|
|
export function el(tag, attrs={}, children=[]){
|
|
const x = document.createElement(tag);
|
|
for (const [k,v] of Object.entries(attrs||{})){
|
|
if (k === 'class') x.className = v ?? '';
|
|
else if (k === 'html') x.innerHTML = v ?? '';
|
|
else if (k === 'on' && v && typeof v === 'object'){
|
|
for (const [evt, fn] of Object.entries(v)){ if (typeof fn === 'function') x.addEventListener(evt, fn, false); }
|
|
} else {
|
|
if (v !== null && v !== undefined && v !== false){
|
|
x.setAttribute(k, v === true ? '' : String(v));
|
|
}
|
|
}
|
|
}
|
|
(Array.isArray(children) ? children : [children]).forEach(ch => { if (ch != null) x.append(ch) });
|
|
return x;
|
|
}
|
|
|
|
export function labelInput(labelText, type, name, value, attrs={}){
|
|
const w = el('div');
|
|
w.append(el('label',{html:labelText}));
|
|
const input = el('input',{type, name, value: String(value ?? ''), ...attrs});
|
|
w.append(input); return w;
|
|
}
|
|
export function labelSelect(labelText, name, value, options){
|
|
const w = el('div');
|
|
w.append(el('label',{html:labelText}));
|
|
const sel = el('select',{name, 'data-ui':'lite'}); // opt into Select Lite
|
|
options.forEach(([val, text])=>{
|
|
const opt = el('option',{value:val}); opt.textContent = text;
|
|
if (String(val) === String(value)) opt.selected = true;
|
|
sel.append(opt);
|
|
});
|
|
w.append(sel); return w;
|
|
}
|
|
|
|
/* ---- Theme helpers ---- */
|
|
export function applyTheme(mode){
|
|
if(mode === 'auto'){ document.documentElement.removeAttribute('data-theme'); }
|
|
else{ document.documentElement.setAttribute('data-theme', mode); }
|
|
localStorage.setItem('theme', mode);
|
|
}
|
|
export function initTheme(toggleBtn){
|
|
const saved = localStorage.getItem('theme') || 'auto';
|
|
applyTheme(saved);
|
|
if(toggleBtn){
|
|
toggleBtn.textContent = titleForTheme(saved);
|
|
toggleBtn.addEventListener('click', ()=>{
|
|
const next = nextTheme(localStorage.getItem('theme')||'auto');
|
|
applyTheme(next);
|
|
toggleBtn.textContent = titleForTheme(next);
|
|
});
|
|
}
|
|
}
|
|
function nextTheme(mode){ return mode==='auto' ? 'light' : mode==='light' ? 'dark' : 'auto' }
|
|
function titleForTheme(mode){ return mode==='auto' ? 'Auto' : mode==='light' ? 'Light' : 'Dark' }
|
|
|
|
/* ---- Select Lite: progressively enhance <select> with a themed list ---- */
|
|
export function enhanceSelects(scope=document){
|
|
scope.querySelectorAll('select[data-ui="lite"]').forEach(sel=>{
|
|
if (sel.dataset.enhanced) return;
|
|
sel.dataset.enhanced = '1';
|
|
|
|
// wrapper
|
|
const wrap = el('div', {class:'select-lite'});
|
|
sel.parentNode.insertBefore(wrap, sel);
|
|
wrap.append(sel);
|
|
|
|
// button showing current value
|
|
const btn = el('button', {type:'button', class:'select-lite__button', 'aria-haspopup':'listbox', 'aria-expanded':'false'});
|
|
const label = ()=> sel.options[sel.selectedIndex]?.text || '';
|
|
btn.textContent = label();
|
|
wrap.append(btn);
|
|
|
|
// menu
|
|
const menu = el('div', {class:'select-lite__menu', role:'listbox', tabindex:'-1', hidden:true});
|
|
const mkOption = (opt, idx)=>{
|
|
const item = el('div', {class:'select-lite__option', role:'option', 'data-value':opt.value, 'data-idx':idx, 'aria-selected': String(opt.selected)});
|
|
item.textContent = opt.text;
|
|
return item;
|
|
};
|
|
[...sel.options].forEach((opt,i)=> menu.append(mkOption(opt,i)));
|
|
wrap.append(menu);
|
|
|
|
// open/close helpers
|
|
const open = ()=>{
|
|
menu.hidden = false; wrap.classList.add('is-open');
|
|
btn.setAttribute('aria-expanded','true');
|
|
// focus selected and ensure menu is focusable
|
|
const current = menu.querySelector(`[data-idx="${sel.selectedIndex}"]`) || menu.firstElementChild;
|
|
current?.focus();
|
|
// Ensure menu can receive focus for search
|
|
menu.focus();
|
|
// outside click to close
|
|
setTimeout(()=>{ document.addEventListener('pointerdown', onDocDown, {capture:true, once:true}); }, 0);
|
|
};
|
|
const close = ()=>{
|
|
menu.hidden = true; wrap.classList.remove('is-open');
|
|
btn.setAttribute('aria-expanded','false');
|
|
btn.focus();
|
|
};
|
|
const onDocDown = (e)=>{ if(!wrap.contains(e.target)) close(); };
|
|
|
|
// button events
|
|
btn.addEventListener('click', ()=> menu.hidden ? open() : close());
|
|
btn.addEventListener('keydown', (e)=>{
|
|
if(e.key==='ArrowDown' || e.key==='Enter' || e.key===' '){ e.preventDefault(); open(); }
|
|
});
|
|
|
|
// Add search functionality
|
|
let searchBuffer = '';
|
|
let searchTimeout = null;
|
|
|
|
const handleSearch = (key) => {
|
|
if (key.length === 1 && key.match(/[a-zA-Z0-9]/)) {
|
|
searchBuffer += key.toLowerCase();
|
|
console.log('Search buffer:', searchBuffer); // Debug log
|
|
|
|
// Clear search buffer after 500ms of no typing (faster response)
|
|
if (searchTimeout) clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
searchBuffer = '';
|
|
console.log('Search buffer cleared'); // Debug log
|
|
}, 500);
|
|
|
|
// Find and highlight matching option
|
|
const options = [...menu.querySelectorAll('.select-lite__option')];
|
|
const matchingOption = options.find(opt =>
|
|
opt.textContent.toLowerCase().startsWith(searchBuffer)
|
|
);
|
|
|
|
if (matchingOption) {
|
|
console.log('Found match:', matchingOption.textContent); // Debug log
|
|
// Remove previous highlights and search matches
|
|
options.forEach(o => {
|
|
o.classList.remove('search-match');
|
|
o.style.background = '';
|
|
o.style.color = '';
|
|
o.style.fontWeight = '';
|
|
});
|
|
|
|
// Highlight matching option with strong visual feedback
|
|
matchingOption.classList.add('search-match');
|
|
matchingOption.style.background = 'var(--accent)';
|
|
matchingOption.style.color = 'white';
|
|
matchingOption.style.fontWeight = 'bold';
|
|
|
|
// Focus and scroll to the matching option
|
|
matchingOption.focus();
|
|
matchingOption.scrollIntoView({ block: 'nearest' });
|
|
} else {
|
|
console.log('No match found for:', searchBuffer); // Debug log
|
|
}
|
|
}
|
|
};
|
|
|
|
// menu interactions
|
|
menu.addEventListener('click', (e)=>{
|
|
const opt = e.target.closest('.select-lite__option'); if(!opt) return;
|
|
commit(opt.dataset.value);
|
|
});
|
|
menu.addEventListener('keydown', (e)=>{
|
|
console.log('Menu keydown:', e.key); // Debug log
|
|
|
|
// Handle search typing
|
|
if (e.key.length === 1 && e.key.match(/[a-zA-Z0-9]/)) {
|
|
e.preventDefault();
|
|
handleSearch(e.key);
|
|
return;
|
|
}
|
|
|
|
const opts = [...menu.querySelectorAll('.select-lite__option')];
|
|
const i = opts.indexOf(document.activeElement);
|
|
if(e.key==='ArrowDown'){ e.preventDefault(); (opts[i+1]||opts[0]).focus(); }
|
|
else if(e.key==='ArrowUp'){ e.preventDefault(); (opts[i-1]||opts[opts.length-1]).focus(); }
|
|
else if(e.key==='Home'){ e.preventDefault(); opts[0].focus(); }
|
|
else if(e.key==='End'){ e.preventDefault(); opts[opts.length-1].focus(); }
|
|
else if(e.key==='Enter' || e.key===' '){
|
|
e.preventDefault();
|
|
const t = document.activeElement;
|
|
if(t?.classList.contains('select-lite__option')) {
|
|
commit(t.dataset.value);
|
|
}
|
|
}
|
|
else if(e.key==='Escape'){ e.preventDefault(); close(); }
|
|
});
|
|
|
|
function commit(value){
|
|
if (sel.value !== value){
|
|
sel.value = value;
|
|
btn.textContent = label();
|
|
// update aria-selected
|
|
menu.querySelectorAll('.select-lite__option').forEach(o=> o.setAttribute('aria-selected', String(o.dataset.value===value)));
|
|
// bubble change to app
|
|
sel.dispatchEvent(new Event('input', {bubbles:true}));
|
|
sel.dispatchEvent(new Event('change', {bubbles:true}));
|
|
}
|
|
close();
|
|
}
|
|
});
|
|
}
|