Files
pinkudex/mockups/pagination-options-mockup.html
2026-05-26 22:46:00 +02:00

510 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pinkudex — Pagination behavior options (functional)</title>
<style>
:root {
--bg-0: #0b0d10;
--bg-1: #14171c;
--bg-2: #1c2027;
--fg: #e6e8ec;
--fg-dim: #a4abb6;
--fg-muted: #6b7380;
--cyan: #22d3ee;
--mint: #34d399;
--amber: #fbbf24;
--coral: #f87171;
--violet: #a78bfa;
--glass: rgba(255,255,255,0.04);
--glass-border: rgba(255,255,255,0.10);
--glass-border-strong: rgba(255,255,255,0.18);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-0);
color: var(--fg);
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI";
min-height: 100vh;
}
.page { max-width: 1100px; margin: 0 auto; padding: 24px; }
header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; gap: 24px; }
h1 { font-weight: 500; font-size: 20px; margin: 0 0 4px; }
.sub { color: var(--fg-muted); font-size: 13px; }
.modes {
display: flex; gap: 4px; padding: 4px;
background: var(--bg-1); border: 1px solid var(--glass-border);
border-radius: 10px;
}
.modes button {
background: transparent; border: 0; color: var(--fg-dim);
font-family: inherit; font-size: 12px; font-weight: 500;
padding: 8px 14px; border-radius: 7px; cursor: pointer;
transition: background 120ms, color 120ms;
}
.modes button:hover { color: var(--fg); }
.modes button.active {
background: rgba(34,211,238,0.15);
color: var(--cyan);
box-shadow: inset 0 0 0 1px rgba(34,211,238,0.4);
}
/* Hud panel */
.hud {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
background: var(--bg-1); border: 1px solid var(--glass-border);
border-radius: 12px; padding: 14px 16px; margin-bottom: 14px;
font-family: ui-monospace, "SF Mono", monospace; font-size: 12px;
}
.hud .item { display: flex; flex-direction: column; gap: 4px; }
.hud .label { color: var(--fg-muted); font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; }
.hud .value { color: var(--fg); font-weight: 500; }
.hud .value .accent { color: var(--cyan); }
.description {
background: var(--bg-1); border: 1px solid var(--glass-border);
border-radius: 12px; padding: 14px 16px; margin-bottom: 14px;
font-size: 13px; color: var(--fg-dim); line-height: 1.55;
}
.description b { color: var(--fg); }
/* Event log */
.log {
background: rgba(0,0,0,0.35); border: 1px solid var(--glass-border);
border-radius: 10px; padding: 10px 12px; margin-bottom: 14px;
font-family: ui-monospace, "SF Mono", monospace; font-size: 11.5px;
height: 110px; overflow-y: auto;
}
.log .line { padding: 1px 0; color: var(--fg-dim); }
.log .line .t { color: var(--fg-muted); margin-right: 8px; }
.log .line .k { font-weight: 600; margin-right: 8px; }
.log .line .k.url { color: var(--coral); }
.log .line .k.scroll { color: var(--mint); }
.log .line .k.fetch { color: var(--amber); }
.log .line .k.click { color: var(--cyan); }
.log .line .k.remount { color: var(--violet); }
.log .line .k.note { color: var(--fg-muted); }
/* Grid viewport — emulates a virtualized scroll area */
.viewport {
background: var(--bg-1); border: 1px solid var(--glass-border);
border-radius: 14px; height: 540px; overflow-y: auto;
scroll-behavior: smooth;
position: relative;
}
.viewport-inner { padding: 16px; }
.grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.card {
aspect-ratio: 0.7; border-radius: 8px;
background: linear-gradient(135deg, var(--bg-2), #232831);
border: 1px solid var(--glass-border);
display: flex; flex-direction: column; justify-content: flex-end;
padding: 6px 8px;
font-family: ui-monospace, monospace; font-size: 10px;
color: var(--fg-muted);
position: relative;
overflow: hidden;
animation: fadeIn 200ms ease-out both;
}
.card::before {
content: ""; position: absolute; inset: 0;
background: radial-gradient(circle at 30% 30%, rgba(34,211,238,0.06), transparent 60%);
}
.card.flash { animation: flashCard 600ms ease-out; }
@keyframes flashCard {
0% { box-shadow: inset 0 0 0 2px var(--cyan); }
100% { box-shadow: inset 0 0 0 0 transparent; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.page-marker {
grid-column: 1 / -1;
margin: 18px 0 8px;
padding: 6px 10px;
border-radius: 6px;
background: rgba(34,211,238,0.06);
border-left: 3px solid var(--cyan);
font-family: ui-monospace, monospace; font-size: 11px;
color: var(--cyan); font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em;
}
.page-marker.current { background: rgba(34,211,238,0.14); }
/* Pagination bar */
.bar {
display: flex; justify-content: center; align-items: center; gap: 10px;
margin-top: 16px; padding: 12px;
background: var(--bg-1); border: 1px solid var(--glass-border);
border-radius: 12px;
}
.bar button {
font-family: inherit; font-size: 13px; padding: 7px 14px;
border-radius: 8px; border: 1px solid var(--glass-border-strong);
background: var(--glass); color: var(--fg); cursor: pointer;
display: inline-flex; align-items: center; gap: 6px;
transition: background 120ms;
}
.bar button:hover:not(:disabled) { background: rgba(255,255,255,0.07); }
.bar button:disabled { opacity: 0.35; cursor: not-allowed; }
.bar .label {
font-family: ui-monospace, monospace; font-size: 13px;
color: var(--fg-dim); padding: 0 14px; min-width: 200px; text-align: center;
}
.bar .label .num { color: var(--cyan); font-weight: 600; }
.bar .jump {
margin-left: 8px; display: inline-flex; align-items: center; gap: 6px;
}
.bar .jump input {
width: 56px; padding: 6px 8px; border-radius: 6px;
border: 1px solid var(--glass-border-strong);
background: var(--bg-0); color: var(--fg);
font-family: ui-monospace, monospace; font-size: 12px; text-align: center;
outline: none;
}
.bar .jump input:focus { border-color: var(--cyan); }
.load-more {
width: 100%; margin-top: 10px;
padding: 10px; border-radius: 8px;
background: rgba(52,211,153,0.08); color: var(--mint);
border: 1px dashed rgba(52,211,153,0.4); cursor: pointer;
font-family: inherit; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600;
transition: background 120ms;
}
.load-more:hover { background: rgba(52,211,153,0.14); }
.load-more:disabled { opacity: 0.3; cursor: not-allowed; }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--bg-2); border: 1px solid var(--glass-border-strong);
border-radius: 10px; padding: 8px 14px;
font-family: ui-monospace, monospace; font-size: 12px; color: var(--fg);
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
opacity: 0; pointer-events: none;
transition: opacity 200ms, transform 200ms;
z-index: 100;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-4px); }
.toast .tag {
display: inline-block; padding: 1px 6px; border-radius: 4px;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
margin-right: 8px; font-weight: 700;
}
.toast.url .tag { background: rgba(248,113,113,0.2); color: var(--coral); }
.toast.scroll .tag { background: rgba(52,211,153,0.2); color: var(--mint); }
.toast.fetch .tag { background: rgba(251,191,36,0.2); color: var(--amber); }
.remount-flash {
position: absolute; inset: 0; pointer-events: none;
background: rgba(248,113,113,0.08);
opacity: 0; transition: opacity 120ms;
}
.remount-flash.on { opacity: 1; }
</style>
</head>
<body>
<div class="page">
<header>
<div>
<h1>Pagination behavior — functional comparison</h1>
<div class="sub">Click <b>Prev / Next / Jump</b> with each mode active and watch the HUD + event log.</div>
</div>
<div class="modes" role="tablist">
<button id="mode-1" class="active">1 · Always URL</button>
<button id="mode-2">2 · Scroll + prefetch</button>
<button id="mode-3">3 · Two affordances</button>
</div>
</header>
<div class="hud">
<div class="item">
<span class="label">URL</span>
<span class="value" id="hud-url">/?page=1</span>
</div>
<div class="item">
<span class="label">Logical page</span>
<span class="value"><span class="accent" id="hud-page">1</span> / <span id="hud-total">12</span></span>
</div>
<div class="item">
<span class="label">Loaded buffer</span>
<span class="value">page <span id="hud-buf-lo">1</span> <span id="hud-buf-hi">1</span></span>
</div>
<div class="item">
<span class="label">DOM cards</span>
<span class="value" id="hud-card-count">25</span>
</div>
</div>
<div class="description" id="mode-desc"></div>
<div class="log" id="log"></div>
<div class="viewport" id="viewport">
<div class="remount-flash" id="remount-flash"></div>
<div class="viewport-inner" id="viewport-inner"></div>
</div>
<div class="bar">
<button id="prev-btn">← Prev</button>
<span class="label">Page <span class="num" id="bar-page">1</span> of <span id="bar-total">12</span></span>
<button id="next-btn">Next →</button>
<form class="jump" id="jump-form" onsubmit="return false;">
<span style="color: var(--fg-muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;">Jump</span>
<input id="jump-input" type="text" inputmode="numeric" placeholder="1-12">
<button type="submit"></button>
</form>
</div>
<button class="load-more" id="load-more" style="display: none;">↓ Load more (extend buffer)</button>
</div>
<div class="toast" id="toast"></div>
<script>
// ---------- model ----------
const TOTAL_PAGES = 12;
const PAGE_SIZE = 25; // cards per page (5×5)
const FETCH_LATENCY_MS = 280;
let mode = 1; // 1 | 2 | 3
let currentPage = 1; // logical page
let initialPage = 1; // page that's anchored as the "SSR" first page
let bufLo = 1; // first loaded page
let bufHi = 1; // last loaded page
let inflight = false;
// ---------- helpers ----------
const $ = (id) => document.getElementById(id);
const viewportEl = $("viewport");
const innerEl = $("viewport-inner");
const logEl = $("log");
function fmtTime() {
const d = new Date();
return `${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")}:${String(d.getSeconds()).padStart(2,"0")}.${String(d.getMilliseconds()).padStart(3,"0")}`;
}
function log(kind, msg) {
const line = document.createElement("div");
line.className = "line";
line.innerHTML = `<span class="t">${fmtTime()}</span><span class="k ${kind}">${kind.toUpperCase()}</span>${msg}`;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
while (logEl.children.length > 50) logEl.removeChild(logEl.firstChild);
}
function toast(kind, msg) {
const el = $("toast");
el.className = `toast ${kind}`;
el.innerHTML = `<span class="tag">${kind}</span>${msg}`;
requestAnimationFrame(() => el.classList.add("show"));
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove("show"), 1400);
}
function buildCard(pageNum, idx) {
const el = document.createElement("div");
el.className = "card";
el.dataset.page = String(pageNum);
el.innerHTML = `<div>p${pageNum}·#${idx+1}</div>`;
return el;
}
function renderBuffer({ animate = false } = {}) {
innerEl.innerHTML = "";
const grid = document.createElement("div");
grid.className = "grid";
for (let p = bufLo; p <= bufHi; p++) {
const marker = document.createElement("div");
marker.className = "page-marker" + (p === currentPage ? " current" : "");
marker.textContent = `Page ${p}` + (p === currentPage ? " · current" : "");
marker.dataset.pageMarker = String(p);
grid.appendChild(marker);
for (let i = 0; i < PAGE_SIZE; i++) {
const c = buildCard(p, i);
if (!animate) c.style.animation = "none";
grid.appendChild(c);
}
}
innerEl.appendChild(grid);
updateHud();
}
function updateHud() {
$("hud-url").textContent = currentPage === 1 ? "/" : `/?page=${currentPage}`;
$("hud-page").textContent = String(currentPage);
$("hud-total").textContent = String(TOTAL_PAGES);
$("hud-buf-lo").textContent = String(bufLo);
$("hud-buf-hi").textContent = String(bufHi);
$("hud-card-count").textContent = String((bufHi - bufLo + 1) * PAGE_SIZE);
$("bar-page").textContent = String(currentPage);
$("bar-total").textContent = String(TOTAL_PAGES);
$("prev-btn").disabled = currentPage <= 1;
$("next-btn").disabled = currentPage >= TOTAL_PAGES;
const lm = $("load-more");
lm.disabled = bufHi >= TOTAL_PAGES;
lm.textContent = bufHi >= TOTAL_PAGES
? "(end of library)"
: `↓ Load page ${bufHi + 1} (extend buffer)`;
}
function flashRemount() {
const f = $("remount-flash");
f.classList.add("on");
setTimeout(() => f.classList.remove("on"), 200);
}
function flashPage(p) {
[...innerEl.querySelectorAll(`.card[data-page="${p}"]`)].forEach((el) => {
el.classList.remove("flash");
void el.offsetWidth;
el.classList.add("flash");
});
}
function scrollToPageMarker(p) {
const m = innerEl.querySelector(`[data-page-marker="${p}"]`);
if (!m) return false;
const target = m.offsetTop - 12;
viewportEl.scrollTo({ top: target, behavior: "smooth" });
return true;
}
// ---------- the three modes ----------
// Mode 1: always URL nav. Buffer is reset to {target}.
function mode1Nav(target) {
log("click", `Prev/Next clicked, target=${target}`);
log("url", `router.push(?page=${target})`);
log("remount", `LibraryGrid remounts (key includes effectivePage)`);
flashRemount();
initialPage = target;
bufLo = target; bufHi = target;
currentPage = target;
renderBuffer({ animate: true });
viewportEl.scrollTop = 0;
toast("url", `Navigated to page ${target} (remount)`);
}
// Mode 2: scroll if in buffer; else fetch+append; backward beyond initial → URL.
async function mode2Nav(target) {
log("click", `Prev/Next clicked, target=${target}`);
if (target < initialPage) {
log("note", `target < initialPage (${target} < ${initialPage}) — fall back to URL`);
log("url", `router.push(?page=${target})`);
log("remount", `LibraryGrid remounts`);
flashRemount();
initialPage = target;
bufLo = target; bufHi = target;
currentPage = target;
renderBuffer({ animate: true });
viewportEl.scrollTop = 0;
toast("url", `Backward across buffer → URL nav (page ${target})`);
return;
}
if (target > bufHi) {
log("note", `target > bufHi (${target} > ${bufHi}) — prefetching`);
if (inflight) return;
inflight = true;
for (let p = bufHi + 1; p <= target; p++) {
log("fetch", `fetch page ${p}`);
await new Promise((r) => setTimeout(r, FETCH_LATENCY_MS));
bufHi = p;
renderBuffer({ animate: false });
log("note", `appended page ${p}, buffer now ${bufLo}${bufHi}`);
}
inflight = false;
}
currentPage = target;
log("scroll", `scrollToIndex(rowOf(page ${target}))`);
renderBuffer({ animate: false });
requestAnimationFrame(() => scrollToPageMarker(target));
flashPage(target);
toast("scroll", `Scrolled to page ${target} (in-buffer)`);
}
// Mode 3: Prev/Next is always URL. Load-more button does the buffer growth.
function mode3Nav(target) {
// Same as mode 1 for Prev/Next.
mode1Nav(target);
}
async function mode3LoadMore() {
if (bufHi >= TOTAL_PAGES) return;
log("click", "Load-more clicked");
log("fetch", `fetch page ${bufHi + 1}`);
inflight = true;
await new Promise((r) => setTimeout(r, FETCH_LATENCY_MS));
bufHi += 1;
inflight = false;
renderBuffer({ animate: false });
log("note", `appended page ${bufHi}, buffer now ${bufLo}${bufHi}`);
toast("fetch", `Buffer extended to page ${bufHi}`);
}
function navTo(target) {
target = Math.max(1, Math.min(TOTAL_PAGES, target));
if (target === currentPage && target >= bufLo && target <= bufHi) return;
if (mode === 1) mode1Nav(target);
else if (mode === 2) mode2Nav(target);
else mode3Nav(target);
}
// ---------- mode switching ----------
const MODE_DESC = {
1: `<b>Mode 1 — Always URL.</b> Every Prev/Next click pushes a new URL. The grid
remounts (red flash). Buffer always reduces to a single page. Predictable but you
lose the smooth in-buffer jump.`,
2: `<b>Mode 2 — Scroll + prefetch.</b> If the target is already in the buffer it
scrolls (green flash). Past <code>bufHi</code> it fetches missing pages then scrolls.
Backward past <code>initialPage</code> still falls back to URL nav (residual seam).
Try Next a few times then Prev.`,
3: `<b>Mode 3 — Two affordances.</b> Prev/Next behaves like Mode 1 (always URL).
A separate <span style="color: var(--mint)">Load more</span> button below extends
the buffer in place. Each affordance has exactly one behavior.`,
};
function setMode(n) {
mode = n;
document.querySelectorAll(".modes button").forEach((b) => b.classList.remove("active"));
$(`mode-${n}`).classList.add("active");
$("mode-desc").innerHTML = MODE_DESC[n];
$("load-more").style.display = n === 3 ? "block" : "none";
// Reset state on mode change so each demo starts clean.
currentPage = 1; initialPage = 1; bufLo = 1; bufHi = 1;
renderBuffer({ animate: true });
viewportEl.scrollTop = 0;
log("note", `Mode switched to ${n}`);
}
// ---------- wire up ----------
$("mode-1").addEventListener("click", () => setMode(1));
$("mode-2").addEventListener("click", () => setMode(2));
$("mode-3").addEventListener("click", () => setMode(3));
$("prev-btn").addEventListener("click", () => navTo(currentPage - 1));
$("next-btn").addEventListener("click", () => navTo(currentPage + 1));
$("jump-form").addEventListener("submit", (e) => {
e.preventDefault();
const v = Number($("jump-input").value);
if (Number.isFinite(v) && v >= 1 && v <= TOTAL_PAGES) navTo(v);
$("jump-input").value = "";
});
$("load-more").addEventListener("click", mode3LoadMore);
setMode(1);
</script>
</body>
</html>