[object Object]

← 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 →