← back to 4square Admin
feat: full keyboard navigation
c8d229719505b4033c61f66064490b5b3c3489f3 · 2026-05-14 09:34:20 -0700 · Steve
shortcuts (when grid focused, not in search box):
- '/' focus search
- j/k or ↑/↓ move by one row (row = current --cols value)
- h/l or ←/→ move by one column
- Space or 'x' toggle selection on focused card
- Enter open detail modal
- 'a' select all, 'n' select none
- 'p' publish selected, 'u' unpublish, 'd' delete (all gated by selection size and read-only flag)
- 3-9 set density columns directly
- '?' show shortcut cheat sheet via toast
focus ring uses --pub green color, scrolls into view smoothly, cursor index resets to 0 on grid rerender, click syncs cursor for continuity
Files touched
Diff
commit c8d229719505b4033c61f66064490b5b3c3489f3
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 14 09:34:20 2026 -0700
feat: full keyboard navigation
shortcuts (when grid focused, not in search box):
- '/' focus search
- j/k or ↑/↓ move by one row (row = current --cols value)
- h/l or ←/→ move by one column
- Space or 'x' toggle selection on focused card
- Enter open detail modal
- 'a' select all, 'n' select none
- 'p' publish selected, 'u' unpublish, 'd' delete (all gated by selection size and read-only flag)
- 3-9 set density columns directly
- '?' show shortcut cheat sheet via toast
focus ring uses --pub green color, scrolls into view smoothly, cursor index resets to 0 on grid rerender, click syncs cursor for continuity
---
index.html | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 64 insertions(+), 8 deletions(-)
diff --git a/index.html b/index.html
index 2294658..0d89ad2 100644
--- a/index.html
+++ b/index.html
@@ -82,6 +82,8 @@
}
.card:hover { border-color:#555 }
.card.selected { border-color:var(--gold); box-shadow:0 0 0 2px rgba(210,177,92,.22) }
+ .card.focused { box-shadow:0 0 0 2px rgba(125,216,125,.45) }
+ .card.focused.selected { box-shadow:0 0 0 2px var(--gold) }
.card.readonly { cursor:default }
.card img { width:100%; height:74%; object-fit:cover; display:block; background:#222; pointer-events:none }
.card .meta { padding:7px 9px; font-size:10.5px }
@@ -413,19 +415,20 @@ function cardHtml(r, opts={}) {
function attachCardHandlers() {
document.querySelectorAll('.card').forEach(el => {
const id = parseInt(el.dataset.id, 10);
- // Click → toggle (or open modal if shift-clicked or readonly card)
el.addEventListener('click', (e) => {
- if (e.shiftKey || el.classList.contains('readonly')) {
- openModal(id);
- return;
- }
+ // sync cursor on click for keyboard nav continuity
+ cursor = CURRENT.rows.findIndex(r => r.id === id);
+ if (cursor < 0) cursor = 0;
+ if (e.shiftKey || el.classList.contains('readonly')) { openModal(id); return; }
if (selected.has(id)) selected.delete(id); else selected.add(id);
el.classList.toggle('selected');
updateSelUI();
});
- // Double-click → always open modal (works on selectable cards too)
el.addEventListener('dblclick', (e) => { e.preventDefault(); openModal(id); });
});
+ // reset focus to first card when grid rerenders
+ if (cursor >= CURRENT.rows.length) cursor = 0;
+ paintCursor();
updateSelUI();
}
@@ -505,8 +508,61 @@ async function modalAction(id, action) {
await loadProducts();
}
-// Esc closes modal
-window.addEventListener('keydown', e => { if (e.key === 'Escape' && $('#modal-bg').classList.contains('open')) closeModal(); });
+// ── keyboard navigation ──────────────────────────────────────────────
+let cursor = 0; // index into CURRENT.rows
+
+function moveCursor(delta) {
+ if (!CURRENT.rows.length) return;
+ cursor = Math.max(0, Math.min(CURRENT.rows.length - 1, cursor + delta));
+ paintCursor();
+}
+function paintCursor() {
+ document.querySelectorAll('.card.focused').forEach(el => el.classList.remove('focused'));
+ if (!CURRENT.rows[cursor]) return;
+ const id = CURRENT.rows[cursor].id;
+ const el = document.querySelector(`.card[data-id="${id}"]`);
+ if (el) {
+ el.classList.add('focused');
+ el.scrollIntoView({ block:'nearest', behavior:'smooth' });
+ }
+}
+function toggleCursor() {
+ const r = CURRENT.rows[cursor]; if (!r || CURRENT.readonly) return;
+ if (selected.has(r.id)) selected.delete(r.id); else selected.add(r.id);
+ paintSelected(); updateSelUI();
+}
+
+window.addEventListener('keydown', e => {
+ // modal-open: Esc closes; everything else swallowed
+ if ($('#modal-bg').classList.contains('open')) {
+ if (e.key === 'Escape') closeModal();
+ return;
+ }
+ // search box focused: only handle Esc (handled by input's own listener) and don't intercept other keys
+ if (document.activeElement === $('#q')) return;
+
+ switch (e.key) {
+ case '/': e.preventDefault(); $('#q').focus(); $('#q').select(); break;
+ case 'j': case 'ArrowDown': e.preventDefault(); moveCursor(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cols'),10) || 1); break;
+ case 'k': case 'ArrowUp': e.preventDefault(); moveCursor(-(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--cols'),10) || 1)); break;
+ case 'h': case 'ArrowLeft': e.preventDefault(); moveCursor(-1); break;
+ case 'l': case 'ArrowRight': e.preventDefault(); moveCursor(1); break;
+ case ' ': case 'x': e.preventDefault(); toggleCursor(); break;
+ case 'Enter': e.preventDefault(); if (CURRENT.rows[cursor]) openModal(CURRENT.rows[cursor].id); break;
+ case 'a': selectAll(); break;
+ case 'n': selectNone(); break;
+ case 'p': if (!CURRENT.readonly && selected.size) bulk('publish'); break;
+ case 'u': if (!CURRENT.readonly && selected.size) bulk('unpublish'); break;
+ case 'd': if (!CURRENT.readonly && selected.size) bulk('delete'); break;
+ case '?': toast('/=search · j/k/h/l=navigate · space/x=toggle · enter=detail · a=all · n=none · p=pub · u=unpub · d=del · 3-9=density', 'ok'); break;
+ default:
+ if (/^[3-9]$/.test(e.key)) {
+ const v = e.key; $('#cols').value = v; $('#cols-v').textContent = v;
+ document.documentElement.style.setProperty('--cols', v);
+ localStorage.setItem(LS.cols, v);
+ }
+ }
+});
function selectMatrixRow(slug) {
const ids = CURRENT.rows.filter(r => r.pattern === slug).map(r => r.id);
← 48decb7 feat: card-detail modal with full metadata
·
back to 4square Admin
·
feat: patternbank + vintage-wallpaper archive sources 724d1f8 →