sliding modals, view transitions, accessibility, etc, etc
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import BackToTop from "./BackToTop.astro"
|
||||
---
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-2 display-sm-none">
|
||||
<div class="col-md-2">
|
||||
<div class="h5 d-none">Inventory management placeholder</div>
|
||||
<div class="offcanvas offcanvas-start" data-bs-backdrop="static" tabindex="-1" id="filterBar" aria-labelledby="filterBarLabel">
|
||||
<div class="offcanvas-header">
|
||||
@@ -15,16 +15,256 @@ import BackToTop from "./BackToTop.astro"
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-10 mt-0">
|
||||
<div id="activeFilters" class="mb-2 d-flex align-items-center justify-content-end small"></div>
|
||||
<div id="cardGrid" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div>
|
||||
<div id="notfound"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true" transition:name="">
|
||||
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||
<div class="modal-content">
|
||||
Loading...
|
||||
<div class="d-flex flex-row align-items-center mb-2">
|
||||
<div id="sortBy" class="mb-2 d-flex align-items-center justify-content-start small d-none">
|
||||
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
|
||||
<ul class="dropdown-menu dropdown-menu-dark">
|
||||
<li><a class="dropdown-item" href="#">Price: High to Low</a></li>
|
||||
<li><a class="dropdown-item" href="#">Price: Low to High</a></li>
|
||||
<li><a class="dropdown-item" href="#">Set: Newest to Oldest</a></li>
|
||||
<li><a class="dropdown-item" href="#">Set: Oldest to Newest</a></li>
|
||||
<li><a class="dropdown-item" href="#">Card Number: Ascending</a></li>
|
||||
<li><a class="dropdown-item" href="#">Card Number: Descending</a></li>
|
||||
</ul>
|
||||
<div id="totalResults"></div>
|
||||
</div>
|
||||
<div id="activeFilters"></div>
|
||||
</div>
|
||||
<div id="cardGrid" aria-live="polite" class="row g-xxl-3 g-2 row-cols-2 row-cols-md-3 row-cols-xl-4 row-cols-xxxl-5"></div>
|
||||
<div id="notfound" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<BackToTop>
|
||||
|
||||
<div class="modal card-modal" id="cardModal" tabindex="-1" aria-labelledby="cardModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-fullscreen-md-down modal-xl">
|
||||
<div class="modal-content p-2">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal nav buttons, rendered outside modal-content so they survive htmx swaps -->
|
||||
<button id="modalPrevBtn" class="modal-nav-btn modal-nav-prev d-none" aria-label="Previous card">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="modalNextBtn" class="modal-nav-btn modal-nav-next d-none" aria-label="Next card">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<BackToTop />
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────────────
|
||||
const cardIndex = [];
|
||||
let currentCardId = null;
|
||||
let isNavigating = false;
|
||||
|
||||
// ── Register cards as HTMX loads them ────────────────────────────────────
|
||||
const cardGrid = document.getElementById('cardGrid');
|
||||
const gridObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType !== 1) continue;
|
||||
const triggers = node.querySelectorAll
|
||||
? node.querySelectorAll('[data-card-id]')
|
||||
: [];
|
||||
for (const el of triggers) {
|
||||
const id = Number(el.getAttribute('data-card-id'));
|
||||
if (id && !cardIndex.includes(id)) cardIndex.push(id);
|
||||
}
|
||||
if (node.dataset?.cardId) {
|
||||
const id = Number(node.dataset.cardId);
|
||||
if (id && !cardIndex.includes(id)) cardIndex.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
gridObserver.observe(cardGrid, { childList: true, subtree: true });
|
||||
|
||||
// ── Navigation helpers ────────────────────────────────────────────────────
|
||||
function getAdjacentIds() {
|
||||
const idx = cardIndex.indexOf(currentCardId);
|
||||
return {
|
||||
prev: idx > 0 ? cardIndex[idx - 1] : null,
|
||||
next: idx < cardIndex.length - 1 ? cardIndex[idx + 1] : null,
|
||||
idx,
|
||||
total: cardIndex.length,
|
||||
};
|
||||
}
|
||||
|
||||
function updateNavButtons(modal) {
|
||||
const prevBtn = document.getElementById('modalPrevBtn');
|
||||
const nextBtn = document.getElementById('modalNextBtn');
|
||||
if (!modal || !modal.classList.contains('show')) {
|
||||
prevBtn.classList.add('d-none');
|
||||
nextBtn.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
const { prev, next } = getAdjacentIds();
|
||||
prevBtn.classList.toggle('d-none', prev === null);
|
||||
nextBtn.classList.toggle('d-none', next === null);
|
||||
}
|
||||
|
||||
// ── Trigger infinite scroll sentinel ─────────────────────────────────────
|
||||
function tryTriggerSentinel() {
|
||||
const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]');
|
||||
if (!sentinel) return;
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.trigger(sentinel, 'revealed');
|
||||
} else {
|
||||
sentinel.scrollIntoView({ behavior: 'instant', block: 'end' });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCard(cardId, direction = null) {
|
||||
if (!cardId || isNavigating) return;
|
||||
isNavigating = true;
|
||||
|
||||
currentCardId = cardId;
|
||||
|
||||
const modal = document.getElementById('cardModal');
|
||||
const url = `/partials/card-modal?cardId=${cardId}`;
|
||||
|
||||
const { idx, total } = getAdjacentIds();
|
||||
if (idx >= total - 3) {
|
||||
tryTriggerSentinel();
|
||||
}
|
||||
|
||||
const doSwap = async () => {
|
||||
const response = await fetch(url);
|
||||
const html = await response.text();
|
||||
modal.innerHTML = html;
|
||||
if (typeof htmx !== 'undefined') htmx.process(modal);
|
||||
updateNavButtons(modal);
|
||||
};
|
||||
|
||||
if (document.startViewTransition && direction) {
|
||||
modal.dataset.navDirection = direction;
|
||||
await document.startViewTransition(doSwap).finished;
|
||||
delete modal.dataset.navDirection;
|
||||
} else {
|
||||
await doSwap();
|
||||
}
|
||||
|
||||
isNavigating = false;
|
||||
|
||||
const { idx: newIdx, total: newTotal } = getAdjacentIds();
|
||||
if (newIdx >= newTotal - 3) {
|
||||
tryTriggerSentinel();
|
||||
}
|
||||
}
|
||||
|
||||
function navigatePrev() {
|
||||
const { prev } = getAdjacentIds();
|
||||
if (prev) loadCard(prev, 'prev');
|
||||
}
|
||||
|
||||
function navigateNext() {
|
||||
const { next } = getAdjacentIds();
|
||||
if (next) loadCard(next, 'next');
|
||||
}
|
||||
|
||||
// ── Nav button clicks ─────────────────────────────────────────────────────
|
||||
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
|
||||
document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
|
||||
|
||||
// ── Keyboard ──────────────────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const modal = document.getElementById('cardModal');
|
||||
if (!modal.classList.contains('show')) return;
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); navigatePrev(); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); }
|
||||
});
|
||||
|
||||
// ── Touch / swipe ─────────────────────────────────────────────────────────
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
|
||||
document.getElementById('cardModal').addEventListener('touchstart', (e) => {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}, { passive: true });
|
||||
|
||||
document.getElementById('cardModal').addEventListener('touchend', (e) => {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY;
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > Math.abs(dx)) return;
|
||||
if (dx < 0) navigateNext();
|
||||
else navigatePrev();
|
||||
}, { passive: true });
|
||||
|
||||
// ── Hook into HTMX card-modal opens ──────────────────────────────────────
|
||||
document.body.addEventListener('htmx:beforeRequest', async (e) => {
|
||||
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
|
||||
|
||||
const cardEl = e.detail.elt.closest('[data-card-id]');
|
||||
if (cardEl) currentCardId = Number(cardEl.getAttribute('data-card-id'));
|
||||
|
||||
if (!document.startViewTransition) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const url = e.detail.requestConfig.path;
|
||||
const target = document.getElementById('cardModal');
|
||||
const sourceImg = cardEl?.querySelector('img');
|
||||
|
||||
// ── Fetch first, THEN transition ──────────────────────────────────────
|
||||
const response = await fetch(url, { headers: { 'HX-Request': 'true' } });
|
||||
if (!response.ok) throw new Error(`Failed to load modal: ${response.status}`);
|
||||
const html = await response.text();
|
||||
|
||||
try {
|
||||
if (sourceImg) {
|
||||
sourceImg.style.viewTransitionName = 'card-hero';
|
||||
sourceImg.style.opacity = '0'; // hide original immediately after capture
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(async () => {
|
||||
target.innerHTML = html;
|
||||
if (typeof htmx !== 'undefined') htmx.process(target);
|
||||
|
||||
const destImg = target.querySelector('img.card-image');
|
||||
if (destImg) {
|
||||
destImg.style.viewTransitionName = 'card-hero';
|
||||
if (!destImg.complete) {
|
||||
await new Promise(resolve => {
|
||||
destImg.addEventListener('load', resolve, { once: true });
|
||||
destImg.addEventListener('error', resolve, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await transition.finished;
|
||||
updateNavButtons(target);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[card-modal] transition failed:', err);
|
||||
e.detail.elt.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
} finally {
|
||||
if (sourceImg) {
|
||||
sourceImg.style.viewTransitionName = '';
|
||||
sourceImg.style.opacity = ''; // restore after transition
|
||||
}
|
||||
const destImg = target.querySelector('img.card-image');
|
||||
if (destImg) destImg.style.viewTransitionName = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Show/hide nav buttons with Bootstrap modal events ────────────────────
|
||||
const cardModal = document.getElementById('cardModal');
|
||||
cardModal.addEventListener('shown.bs.modal', () => {
|
||||
updateNavButtons(cardModal);
|
||||
});
|
||||
cardModal.addEventListener('hidden.bs.modal', () => {
|
||||
currentCardId = null;
|
||||
updateNavButtons(null);
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user