mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 23:50:12 +01:00
feat(ui): add debounce helper and skeleton polish
This commit is contained in:
parent
bf40be41fb
commit
b7bfc4ca09
9 changed files with 449 additions and 719 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue