feat(ui): add debounce helper and skeleton polish

This commit is contained in:
matt 2025-10-07 11:35:43 -07:00
parent bf40be41fb
commit b7bfc4ca09
9 changed files with 449 additions and 719 deletions

View file

@ -136,19 +136,141 @@
}
addFocusVisible();
// Skeleton utility: swap placeholders before HTMX swaps or on explicit triggers
function showSkeletons(container){
(container || document).querySelectorAll('[data-skeleton]')
.forEach(function(el){ el.classList.add('is-loading'); });
// Skeleton utility: defer placeholders until the request lasts long enough to be noticeable
var SKELETON_DELAY_DEFAULT = 400;
var skeletonTimers = new WeakMap();
function gatherSkeletons(root){
if (!root){ return []; }
var list = [];
var scope = (root.nodeType === 9) ? root.documentElement : root;
if (scope && scope.matches && scope.hasAttribute('data-skeleton')){
list.push(scope);
}
if (scope && scope.querySelectorAll){
scope.querySelectorAll('[data-skeleton]').forEach(function(el){
if (list.indexOf(el) === -1){ list.push(el); }
});
}
return list;
}
function hideSkeletons(container){
(container || document).querySelectorAll('[data-skeleton]')
.forEach(function(el){ el.classList.remove('is-loading'); });
function scheduleSkeleton(el){
var delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10);
var delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr);
clearSkeleton(el, false);
var timer = setTimeout(function(){
el.classList.add('is-loading');
el.setAttribute('aria-busy', 'true');
skeletonTimers.set(el, null);
}, delay);
skeletonTimers.set(el, timer);
}
function clearSkeleton(el, removeBusy){
var timer = skeletonTimers.get(el);
if (typeof timer === 'number'){
clearTimeout(timer);
}
skeletonTimers.delete(el);
el.classList.remove('is-loading');
if (removeBusy !== false){ el.removeAttribute('aria-busy'); }
}
function showSkeletons(context){
gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); });
}
function hideSkeletons(context){
gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el); });
}
window.skeletons = { show: showSkeletons, hide: hideSkeletons };
document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); });
document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); });
document.addEventListener('htmx:beforeRequest', function(e){
var detail = e && e.detail ? e.detail : {};
var target = detail.target || detail.elt || e.target;
showSkeletons(target);
});
document.addEventListener('htmx:afterSwap', function(e){
var detail = e && e.detail ? e.detail : {};
var target = detail.target || detail.elt || e.target;
hideSkeletons(target);
});
document.addEventListener('htmx:afterRequest', function(e){
var detail = e && e.detail ? e.detail : {};
var target = detail.target || detail.elt || e.target;
hideSkeletons(target);
});
// Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce)
var hxDebounceGroups = new Map();
function dispatchHtmx(el, evtName){
if (!el) return;
if (window.htmx && typeof window.htmx.trigger === 'function'){
window.htmx.trigger(el, evtName);
} else {
try { el.dispatchEvent(new Event(evtName, { bubbles: true })); } catch(_){ }
}
}
function bindHtmxDebounce(el){
if (!el || el.__hxDebounceBound) return;
el.__hxDebounceBound = true;
var delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10);
var delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw);
var eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input';
var events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean);
if (!events.length){ events = ['input']; }
var trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput';
var group = el.getAttribute('data-hx-debounce-group') || '';
var flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase();
var flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true');
function clearTimer(){
if (el.__hxDebounceTimer){
clearTimeout(el.__hxDebounceTimer);
el.__hxDebounceTimer = null;
}
}
function schedule(){
clearTimer();
if (group){
var prev = hxDebounceGroups.get(group);
if (prev && prev !== el && prev.__hxDebounceTimer){
clearTimeout(prev.__hxDebounceTimer);
prev.__hxDebounceTimer = null;
}
hxDebounceGroups.set(group, el);
}
el.__hxDebounceTimer = setTimeout(function(){
el.__hxDebounceTimer = null;
dispatchHtmx(el, trigger);
}, delay);
}
events.forEach(function(evt){
el.addEventListener(evt, schedule, { passive: true });
});
if (flushOnBlur){
el.addEventListener('blur', function(){
if (el.__hxDebounceTimer){
clearTimer();
dispatchHtmx(el, trigger);
}
});
}
el.addEventListener('htmx:beforeRequest', clearTimer);
}
function initHtmxDebounce(root){
var scope = root || document;
if (scope === document){ scope = document.body || document; }
if (!scope) return;
var seen = new Set();
function collect(candidate){
if (!candidate || seen.has(candidate)) return;
seen.add(candidate);
bindHtmxDebounce(candidate);
}
if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){
collect(scope);
}
if (scope.querySelectorAll){
scope.querySelectorAll('[data-hx-debounce]').forEach(collect);
}
}
window.initHtmxDebounce = initHtmxDebounce;
// Example: persist "show skipped" toggle if present
document.addEventListener('change', function(e){
@ -172,7 +294,8 @@
hydrateProgress(document);
syncShowSkipped(document);
initCardFilters(document);
initVirtualization(document);
initVirtualization(document);
initHtmxDebounce(document);
});
// Hydrate progress bars with width based on data-pct
@ -200,7 +323,8 @@
hydrateProgress(e.target);
syncShowSkipped(e.target);
initCardFilters(e.target);
initVirtualization(e.target);
initVirtualization(e.target);
initHtmxDebounce(e.target);
});
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)

View file

@ -246,6 +246,23 @@ small, .muted{ color: var(--muted); }
display: none;
}
[data-skeleton].is-loading::after{ display:block; }
[data-skeleton].is-loading::before{
content: attr(data-skeleton-label);
position:absolute;
top:50%;
left:50%;
transform:translate(-50%, -50%);
color: var(--muted);
font-size:.85rem;
text-align:center;
line-height:1.4;
max-width:min(92%, 360px);
padding:.3rem .5rem;
pointer-events:none;
z-index:1;
filter: drop-shadow(0 2px 4px rgba(15,23,42,.45));
}
[data-skeleton][data-skeleton-label=""]::before{ content:''; }
@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
/* Banner */
@ -268,6 +285,9 @@ small, .muted{ color: var(--muted); }
justify-content: start; /* pack as many as possible per row */
/* Prevent scroll chaining bounce that can cause flicker near bottom */
overscroll-behavior: contain;
content-visibility: auto;
contain: layout paint;
contain-intrinsic-size: 640px 420px;
}
@media (max-width: 420px){
.card-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
@ -293,6 +313,9 @@ small, .muted{ color: var(--muted); }
.card-tile .name{ font-weight:600; margin-top:.25rem; font-size:.92rem; }
.card-tile .reason{ color:var(--muted); font-size:.85rem; margin-top:.15rem; }
.group-grid{ content-visibility: auto; contain: layout paint; contain-intrinsic-size: 540px 360px; }
.alt-list{ list-style:none; padding:0; margin:0; display:grid; gap:.25rem; content-visibility: auto; contain: layout paint; contain-intrinsic-size: 320px 220px; }
/* Shared ownership badge for card tiles and stacked images */
.owned-badge{
position:absolute;

View file

@ -3,7 +3,7 @@
{ 'name': display_name, 'name_lower': lower, 'owned': bool, 'tags': list[str] }
]
#}
<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;">
<div class="alts" style="margin-top:.35rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0f1115;" data-skeleton data-skeleton-label="Pulling alternatives…" data-skeleton-delay="450">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.25rem; gap:.5rem; flex-wrap:wrap;">
<strong>Alternatives</strong>
{% set toggle_q = '0' if require_owned else '1' %}
@ -18,7 +18,7 @@
{% if not items or items|length == 0 %}
<div class="muted">No alternatives found{{ ' (owned only)' if require_owned else '' }}.</div>
{% else %}
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
<ul class="alt-list">
{% for it in items %}
{% set badge = '✔' if it.owned else '✖' %}
{% set title = 'Owned' if it.owned else 'Not owned' %}

View file

@ -20,7 +20,9 @@
<span>Commander</span>
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
hx-get="/build/new/candidates" hx-trigger="input changed delay:150ms" hx-target="#newdeck-candidates" hx-sync="this:replace" />
hx-get="/build/new/candidates" hx-trigger="debouncedinput change" hx-target="#newdeck-candidates" hx-sync="this:replace"
data-hx-debounce="220" data-hx-debounce-events="input"
data-hx-debounce-flush="blur" />
</label>
<small class="muted" style="display:block; margin-top:.25rem;">Start typing to see matches, then select one to load themes.</small>
<div id="newdeck-candidates" class="muted" style="font-size:12px; min-height:1.1em;"></div>

View file

@ -2,7 +2,7 @@
{% set labels = ['Choose Commander','Tags & Bracket','Ideal Counts','Review','Build'] %}
{% set index = step_index if step_index is defined else i if i is defined else 1 %}
{% set total = step_total if step_total is defined else n if n is defined else 5 %}
<nav class="stage-nav" aria-label="Build stages">
<nav class="stage-nav" aria-label="Build stages" data-skeleton data-skeleton-label="Refreshing stages…" data-skeleton-delay="400">
<ol>
{% for idx in range(1, total+1) %}
{% set name = labels[idx-1] if (labels|length)>=idx else ('Step ' ~ idx) %}

View file

@ -9,7 +9,7 @@
<a href="{{ return_url }}" class="btn" style="margin-left:auto;">← Back to Commanders</a>
{% endif %}
</div>
<div id="wizard">
<div id="wizard" data-skeleton data-skeleton-label="Preparing build surfaces…" data-skeleton-delay="420" aria-live="polite">
<!-- Wizard content will load here after the modal submit starts the build. -->
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
</div>

View file

@ -4,7 +4,8 @@
<div id="theme-picker" class="theme-picker" hx-get="/themes/fragment/list?limit=20&offset=0" hx-trigger="load" hx-target="#theme-results" hx-swap="innerHTML" role="region" aria-label="Theme picker">
<div class="theme-picker-controls">
<input type="text" id="theme-search" placeholder="Search themes or synergies" aria-label="Search"
hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="keyup changed delay:250ms" name="q" />
hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="debouncedinput change" name="q"
data-hx-debounce="260" data-hx-debounce-events="input,keyup" data-hx-debounce-flush="blur" data-hx-debounce-group="theme-search" />
<select id="theme-archetype" name="archetype" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change">
<option value="">All Archetypes</option>
{% if archetypes %}{% for a in archetypes %}<option value="{{ a }}">{{ a }}</option>{% endfor %}{% endif %}