Compare commits

...

2 commits

Author SHA1 Message Date
fc85729374 Synchronize UX stack handling and search header 2025-09-18 18:55:40 -05:00
0450ec9e1c Refine overlay stacking behavior 2025-09-18 18:42:52 -05:00
3 changed files with 98 additions and 37 deletions

View file

@ -40,6 +40,28 @@
<template id="tpl-search">
<div class="ux-view fade">
<div class="box">
<header class="level mb-4">
<div class="level-left">
<div class="level-item">
<h2 class="title is-4 mb-0">Search</h2>
</div>
</div>
<div class="level-right">
<div class="level-item">
<button class="button is-text" type="button" data-action="back">Back</button>
</div>
<div class="level-item">
<div class="field is-grouped is-grouped-right is-align-items-center">
<div class="control">
<span class="is-size-7 has-text-grey mr-2">Sort</span>
</div>
<div class="control">
<div class="buttons has-addons is-small" role="group" aria-label="Sort results" data-ref="sort-buttons"></div>
</div>
</div>
</div>
</div>
</header>
<form class="mb-4" role="search" aria-label="Track search" data-ref="form">
<div class="field">
<label class="label" for="q">Search</label>

112
script.js
View file

@ -59,15 +59,68 @@
// UX Manager
const UX = (() => {
let currentBase = null; // { view, el }
const overlayStack = []; // [{ view, el, lastFocus }]
const overlayStack = []; // [{ view, el, lastFocus, isHidden, isClosing }]
function ensureViewEl(view) {
if (!view || !view.el) throw new Error('Invalid view');
view.el.classList.add('ux-view');
if (view.kind) view.el.dataset.uxKind = view.kind;
return view.el;
}
function assertKind(view, expected) {
if (!view) throw new Error('Missing view');
if (view.kind && view.kind !== expected) {
throw new Error(`Expected view kind "${expected}" but received "${view.kind}"`);
}
}
async function closeAllOverlays({ restoreFocus = false } = {}) {
while (overlayStack.length) {
const shouldRestore = restoreFocus && overlayStack.length === 1;
await closeTop({ restoreFocus: shouldRestore });
}
}
function syncOverlayVisibility() {
const topIndex = overlayStack.length - 1;
overlayStack.forEach((entry, idx) => {
const { el, view } = entry;
const shouldHide = idx !== topIndex;
if (shouldHide) {
if (!el.hasAttribute('hidden')) el.setAttribute('hidden', '');
el.setAttribute('aria-hidden', 'true');
el.setAttribute('inert', '');
el.inert = true;
if (!entry.isHidden) {
entry.isHidden = true;
if (view && typeof view.onHide === 'function') view.onHide();
}
} else {
if (el.hasAttribute('hidden')) el.removeAttribute('hidden');
el.removeAttribute('aria-hidden');
el.removeAttribute('inert');
el.inert = false;
entry.isHidden = false;
}
});
if (currentBase && currentBase.el) {
if (overlayStack.length > 0) {
currentBase.el.setAttribute('aria-hidden', 'true');
currentBase.el.setAttribute('inert', '');
currentBase.el.inert = true;
} else {
currentBase.el.removeAttribute('aria-hidden');
currentBase.el.removeAttribute('inert');
currentBase.el.inert = false;
}
}
}
async function replace(view) {
assertKind(view, 'base');
await closeAllOverlays({ restoreFocus: false });
const el = ensureViewEl(view);
const prev = currentBase;
if (prev) {
@ -80,30 +133,40 @@
}
$uxRoot.appendChild(el);
currentBase = { view, el };
syncOverlayVisibility();
await fadeIn(el);
if (typeof view.onShow === 'function') view.onShow();
}
async function openOverlay(view) {
assertKind(view, 'overlay');
const el = ensureViewEl(view);
el.classList.add('ux-view--overlay');
const lastFocus = document.activeElement;
const prevEntry = overlayStack.length ? overlayStack[overlayStack.length - 1] : null;
const entry = { view, el, lastFocus, isHidden: false, isClosing: false, prev: prevEntry };
overlayStack.push(entry);
$uxOverlays.appendChild(el);
syncOverlayVisibility();
await fadeIn(el);
overlayStack.push({ view, el, lastFocus });
$uxRoot.setAttribute('aria-hidden', 'true');
if (typeof view.onShow === 'function') view.onShow();
}
async function closeTop() {
const top = overlayStack.pop();
async function closeTop({ restoreFocus = true } = {}) {
const top = overlayStack[overlayStack.length - 1];
if (!top) return;
if (top.isClosing) return;
top.isClosing = true;
const { view, el, lastFocus } = top;
if (view && typeof view.onHide === 'function') view.onHide();
await fadeOut(el);
if (el.parentNode) el.parentNode.removeChild(el);
if (typeof view.destroy === 'function') view.destroy();
if (overlayStack.length === 0) $uxRoot.removeAttribute('aria-hidden');
if (lastFocus && typeof lastFocus.focus === 'function') lastFocus.focus();
overlayStack.pop();
syncOverlayVisibility();
if (restoreFocus && lastFocus && typeof lastFocus.focus === 'function') {
lastFocus.focus();
}
}
window.addEventListener('keydown', (e) => {
@ -113,7 +176,7 @@
}
});
return { replace, openOverlay, closeTop };
return { replace, openOverlay, closeTop, closeAllOverlays };
})();
// --- IndexedDB helpers (minimal, no external deps) ---
@ -679,35 +742,8 @@
const $form = el.querySelector('[data-ref="form"]');
const $q = el.querySelector('[data-ref="q"]');
const $results = el.querySelector('[data-ref="results"]');
// Back + sort toolbar keeps controls grouped for accessibility.
const $toolbar = document.createElement('div');
$toolbar.className = 'level mb-4 is-mobile';
const $toolbarLeft = document.createElement('div');
$toolbarLeft.className = 'level-left';
const $toolbarLeftItem = document.createElement('div');
$toolbarLeftItem.className = 'level-item';
const $backBtn = document.createElement('button');
$backBtn.type = 'button';
$backBtn.className = 'button is-small is-text';
$backBtn.textContent = 'Back to menu';
$toolbarLeftItem.appendChild($backBtn);
$toolbarLeft.appendChild($toolbarLeftItem);
const $toolbarRight = document.createElement('div');
$toolbarRight.className = 'level-right';
const $toolbarRightItem = document.createElement('div');
$toolbarRightItem.className = 'level-item';
const $sortLabel = document.createElement('span');
$sortLabel.className = 'is-size-7 has-text-grey mr-2';
$sortLabel.textContent = 'Sort';
const $sortButtons = document.createElement('div');
$sortButtons.className = 'buttons has-addons is-small';
$toolbarRightItem.appendChild($sortLabel);
$toolbarRightItem.appendChild($sortButtons);
$toolbarRight.appendChild($toolbarRightItem);
$toolbar.appendChild($toolbarLeft);
$toolbar.appendChild($toolbarRight);
$form.insertAdjacentElement('afterend', $toolbar);
const $backBtn = el.querySelector('[data-action="back"]');
const $sortButtons = el.querySelector('[data-ref="sort-buttons"]');
const $status = document.createElement('p');
$status.className = 'has-text-grey';
@ -753,6 +789,8 @@
{ key: 'alpha', label: 'AZ' },
];
if (!$backBtn) throw new Error('Missing back button');
if (!$sortButtons) throw new Error('Missing sort container');
sortOptions.forEach((opt) => {
const btn = document.createElement('button');
btn.type = 'button';

View file

@ -11,6 +11,7 @@
.ux-overlays { position: absolute; inset: 0; pointer-events: none; }
.ux-view { width: 100%; }
.ux-view--overlay { position: absolute; inset: 0; z-index: 10; pointer-events: auto; }
.ux-view[hidden] { display: none !important; }
.is-sr-only {
position: absolute;