This commit is contained in:
whilb 2025-08-16 17:54:12 -07:00
commit 97f9a95415
21 changed files with 2963 additions and 0 deletions

163
public/js/app.js Normal file
View file

@ -0,0 +1,163 @@
import {el, initTheme, enhanceSelects} from './util.js';
const CALCS = [
{ id:'interest', name:'Interest (Simple & Compound)', about:'Simple/compound interest', path:'../calculators/interest.js' },
{ id:'raid', name:'RAID', about:'Usable capacity', path:'../calculators/raid.js' },
{ id:'bandwidth', name:'Bandwidth', about:'Bits↔bytes unit conv.', path:'../calculators/bandwidth.js' },
{ id:'nmea', name:'NMEA', about:'0183 XOR checksum', path:'../calculators/nmea.js' },
{ id:'currency', name:'Currency Converter', about:'Convert between currencies', path:'../calculators/currency.js' }
];
const navEl = document.getElementById('nav');
const viewEl = document.getElementById('view');
const themeBtn= document.getElementById('themeToggle');
initTheme(themeBtn);
const moduleCache = new Map();
const viewCache = new Map();
function metaById(id){ return CALCS.find(c=>c.id===id) || CALCS[0] }
/* ---------- History API routing (path + query) ---------- */
function normalize(id, params){
const qs = params && [...params].length ? `?${params.toString()}` : '';
return `/${id}${qs}`;
}
function parseRoute(){
const path = location.pathname.replace(/^\/+/, '');
const id = path || 'interest';
const params = new URLSearchParams(location.search);
return { id, params };
}
function setRoute(id, params, {replace=true}={}){
const target = normalize(id, params || new URLSearchParams());
const current = `${location.pathname}${location.search}`;
if (target === current) return; // no-op if unchanged
if (replace) history.replaceState(null, '', target);
else history.pushState(null, '', target);
mount(); // re-render
}
function formToParams(card){
const p = new URLSearchParams();
card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{
let v = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value;
if (v !== '') p.set(el.name, v);
});
return p;
}
function paramsToForm(card, params){
let touched = false;
card.querySelectorAll('input[name], select[name], textarea[name]').forEach(el=>{
const k = el.name;
if (!params.has(k)) return;
const v = params.get(k);
if (el.type === 'checkbox'){
const val = (v==='1' || v==='true');
if(el.checked !== val){ el.checked = val; touched = true; }
}else{
if(el.value !== v){ el.value = v; touched = true; }
}
});
if (touched) card.dispatchEvent(new Event('input', {bubbles:true}));
}
/* ---------- Nav: anchors + minimal history ---------- */
function renderNav(active){
navEl.innerHTML = '';
const ul = el('ul',{class:'navlist'});
for(const c of CALCS){
const a = el('a', {
href: `/${c.id}`,
'data-calc': c.id,
'aria-current': c.id===active ? 'page' : null
}, c.name);
ul.append(el('li',{}, a));
}
navEl.append(ul);
}
// delegate clicks so we can decide push vs replace
navEl.addEventListener('click', (e)=>{
const a = e.target.closest('a[data-calc]');
if(!a) return;
// allow new tab/window/defaults
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
const id = a.dataset.calc;
// shift-click = commit to history (push); otherwise replace (minimal history)
setRoute(id, null, {replace: !e.shiftKey});
});
function loadModule(spec){
if(!moduleCache.has(spec)) moduleCache.set(spec, import(spec));
return moduleCache.get(spec);
}
async function ensureMounted(id){
if(viewCache.has(id)) return viewCache.get(id);
const meta = metaById(id);
const card = el('section',{class:'card'});
viewCache.set(id, card);
// placeholder heading
card.append(el('h2',{}, meta.name), el('div',{class:'muted'}, meta.about||''));
try{
const mod = await loadModule(meta.path);
const calc = mod.default;
card.innerHTML = '';
card.append(el('h2',{}, calc.name || meta.name));
if(calc.about) card.append(el('div',{class:'muted'}, calc.about));
calc.render(card);
enhanceSelects(card);
}catch(e){
console.error(e);
card.innerHTML = `<div class="muted">Failed to load calculator. Check console.</div>`;
}
return card;
}
function attachUrlSync(card, id){
// Debounced replaceState on input changes (never pushes to history)
let t;
card.addEventListener('input', ()=>{
clearTimeout(t);
t = setTimeout(()=>{
const newParams = formToParams(card);
const {id:curId, params:curParams} = parseRoute();
const sameId = (curId === id);
const sameQs = sameId && newParams.toString() === curParams.toString();
if(!sameQs) setRoute(id, newParams, {replace:true});
}, 150);
});
}
async function show(id, params){
renderNav(id);
for(const [cid, card] of viewCache.entries()){
card.style.display = (cid===id) ? '' : 'none';
}
if(!viewCache.has(id)){
const card = await ensureMounted(id);
viewEl.append(card);
attachUrlSync(card, id);
}
paramsToForm(viewCache.get(id), params);
}
async function mount(){
const {id, params} = parseRoute();
// normalize root to /interest once
if(!location.pathname || location.pathname === '/' ){
setRoute('interest', params, {replace:true});
return;
}
await show(id, params);
// idle prefetch
const idle = window.requestIdleCallback || ((fn)=>setTimeout(fn,300));
idle(()=> CALCS.forEach(c=> loadModule(c.path).catch(()=>{})));
}
addEventListener('popstate', mount); // back/forward
mount();

206
public/js/util.js Normal file
View file

@ -0,0 +1,206 @@
// 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();
}
});
}