// editor.js — Phase 1 of editable-cell mode. // // Owns the cell-selection + per-cell edit lifecycle. Implements the // W3C ARIA grid-pattern keyboard semantics: // // - Arrow keys move the selected cell. // - Tab / Shift-Tab move right / left, wrapping to next / prev row. // - Enter, F2, double-click, or any printable character enter edit // mode (Enter and F2 keep the existing value; printable chars // replace it; double-click opens with the existing value). // - In edit mode: Enter commits and moves down, Tab commits and // moves right, Escape cancels (restoring the prior value), blur // commits. // // Roving tabindex: only the selected cell carries tabindex=0; all // others are tabindex=-1. This makes the grid a single tab-stop in // the page's tab order, which is the documented spreadsheet UX. // // Edits in this phase live in app.state.drafts and never hit the // network — Phase 3 wires the row-blur PUT. (function (app) { 'use strict'; // --- Helpers ------------------------------------------------------ function tableEl() { return document.getElementById('table-root'); } function cellAt(r, c) { return cellsByRowCol(r, c); } // The displayed table is filtered+sorted; selection is keyed by // VISIBLE row index, not row id, so arrow keys behave intuitively // even after sort / filter changes (the cell at row 3 column 2 // stays at row 3 column 2 even if the underlying row id moved). // This is how Excel and Google Sheets behave too. function cellsByRowCol(r, c) { const t = tableEl(); if (!t) return null; const tbody = t.querySelector('tbody'); if (!tbody) return null; const tr = tbody.children[r]; if (!tr) return null; return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]'); } function isPrintableKey(ev) { // A "printable" key produces a single character of text — e.g. // 'a', '7', '$'. Function keys, arrows, modifiers etc. either // have multi-char `key` values ('ArrowDown') or are non-text. // ev.ctrlKey / metaKey suppress so Cmd-A et al. don't trigger // edit mode. if (ev.key.length !== 1) return false; if (ev.ctrlKey || ev.metaKey || ev.altKey) return false; return true; } function rowCount() { const t = tableEl(); if (!t) return 0; return t.querySelectorAll('tbody > tr').length; } function colCount() { const cols = (app.context && app.context.columns) || []; return Array.isArray(cols) ? cols.length : 0; } function colAt(c) { const cols = (app.context && app.context.columns) || []; return cols[c] || null; } function rowDataAt(r) { // The visible row at index r. Walk the rendered tbody to find // its data-row-id, then look up the row in app.state.rows. // app.state.rows holds the SORTED+FILTERED current view (kept // in sync by main.js paint()). const t = tableEl(); if (!t) return null; const tr = t.querySelectorAll('tbody > tr')[r]; if (!tr) return null; const rowId = tr.getAttribute('data-row-id'); if (rowId == null) return null; const all = app.state.rows || []; for (let i = 0; i < all.length; i++) { if (rowKey(all[i]) === rowId) { return all[i]; } } return null; } function rowKey(row) { // Stable per-row identity. Each context row has a `url` (the // .yaml.html re-edit URL); the file basename inside that // URL is unique per directory and survives sort/filter. if (!row || !row.url) return ''; return row.url; } // --- Draft buffer ------------------------------------------------- function getDraft(rowId, field) { const r = app.state.drafts[rowId]; if (!r) return undefined; return r[field]; } function setDraft(rowId, field, value) { if (!app.state.drafts[rowId]) { app.state.drafts[rowId] = {}; } app.state.drafts[rowId][field] = value; } function clearDraftField(rowId, field) { const r = app.state.drafts[rowId]; if (!r) return; delete r[field]; if (Object.keys(r).length === 0) { delete app.state.drafts[rowId]; } } function effectiveCellValue(row, col) { // Display draft value if present; otherwise the row's stored // value. Used by render to keep the visible cell content in // sync with uncommitted edits. const drafted = getDraft(rowKey(row), col.field); if (drafted !== undefined) { return drafted; } return app.modules.util.resolveField(row.data, col.field); } // --- Selection (roving tabindex) ---------------------------------- function setSelected(r, c, opts) { opts = opts || {}; const total = rowCount(); const cols = colCount(); if (total === 0 || cols === 0) { app.state.selected = null; return; } if (r < 0) r = 0; if (r > total - 1) r = total - 1; if (c < 0) c = 0; if (c > cols - 1) c = cols - 1; const t = tableEl(); if (t) { const all = t.querySelectorAll('[role="gridcell"]'); for (let i = 0; i < all.length; i++) { all[i].setAttribute('tabindex', '-1'); all[i].classList.remove('zddc-table__cell--selected'); } } const target = cellAt(r, c); if (target) { target.setAttribute('tabindex', '0'); target.classList.add('zddc-table__cell--selected'); if (!opts.noFocus) { target.focus({ preventScroll: false }); } } app.state.selected = { row: r, col: c }; } function clearSelection() { const t = tableEl(); if (t) { const all = t.querySelectorAll('[role="gridcell"]'); for (let i = 0; i < all.length; i++) { all[i].setAttribute('tabindex', '-1'); all[i].classList.remove('zddc-table__cell--selected'); } } app.state.selected = null; } // --- Edit mode ---------------------------------------------------- function enterEdit(initial) { if (!app.state.selected) return; if (app.state.editing) return; const { row: r, col: c } = app.state.selected; const cell = cellAt(r, c); if (!cell) return; const row = rowDataAt(r); const col = colAt(c); if (!row || !col) return; const currentText = (initial != null) ? String(initial) : (effectiveCellValue(row, col) == null ? '' : String(effectiveCellValue(row, col))); const input = document.createElement('input'); input.type = 'text'; input.className = 'zddc-table__cell-input'; input.value = currentText; input.setAttribute('aria-label', 'Edit ' + (col.title || col.field)); // Replace the cell's text content with the input. We don't // wipe innerHTML — preserves any error-marker spans Phase 2 // adds — but wrap the input in a way that overlays the text. // For now: stash the original text in dataset, swap in input. cell.setAttribute('data-display', cell.textContent || ''); cell.textContent = ''; cell.appendChild(input); input.focus(); // If user pressed Enter/F2, position cursor at end. If they // started typing a printable char, that char already replaced // the value; cursor is at end naturally. try { input.setSelectionRange(input.value.length, input.value.length); } catch (_) { /* type=text supports it; defensive */ } app.state.editing = true; function commit() { if (!app.state.editing) return; const newValue = input.value; const oldRaw = app.modules.util.resolveField(row.data, col.field); const oldStr = oldRaw == null ? '' : String(oldRaw); if (newValue === oldStr) { // No change — clear any draft entry for this field // so we don't show a "dirty" badge for a no-op edit. clearDraftField(rowKey(row), col.field); } else { setDraft(rowKey(row), col.field, coerceForSchema(newValue, col)); } tearDown(coerceForSchema(newValue, col)); } function cancel() { tearDown(null); // null = restore from data-display, no draft change } function tearDown(displayValue) { input.removeEventListener('keydown', onKey); input.removeEventListener('blur', onBlur); const display = (displayValue != null) ? renderableText(displayValue, col) : (cell.getAttribute('data-display') || ''); cell.removeAttribute('data-display'); cell.textContent = display; app.state.editing = false; cell.focus({ preventScroll: false }); } function onKey(ev) { if (ev.key === 'Enter') { ev.preventDefault(); commit(); setSelected(r + 1, c); } else if (ev.key === 'Escape') { ev.preventDefault(); cancel(); } else if (ev.key === 'Tab') { ev.preventDefault(); commit(); if (ev.shiftKey) { moveSelection('left-wrap'); } else { moveSelection('right-wrap'); } } // Other keys: stay in edit mode, let the input handle them. } function onBlur(_ev) { // Blur (focus moved elsewhere). Commit any pending value. // Schedule via setTimeout(0) so a programmatic refocus by // tearDown→cell.focus doesn't re-fire blur during teardown. if (app.state.editing) { commit(); } } input.addEventListener('keydown', onKey); input.addEventListener('blur', onBlur); } function coerceForSchema(text, col) { // Phase 1 stores raw strings as drafts. Phase 2 will type-coerce // here based on the row schema (integer→Number, boolean→bool, // etc.). Until then, also handle the obvious case so number // columns don't display "42" as a string in the table. if (col.format === 'number' || col.format === 'integer') { const n = Number(text); if (!Number.isNaN(n) && text.trim() !== '') return n; } return text; } function renderableText(value, col) { return app.modules.util.formatCell(value, col.format); } // --- Keyboard nav ------------------------------------------------- function moveSelection(dir) { if (!app.state.selected) return; let { row: r, col: c } = app.state.selected; const total = rowCount(); const cols = colCount(); if (total === 0 || cols === 0) return; switch (dir) { case 'up': r = Math.max(0, r - 1); break; case 'down': r = Math.min(total - 1, r + 1); break; case 'left': c = Math.max(0, c - 1); break; case 'right': c = Math.min(cols - 1, c + 1); break; case 'home': c = 0; break; case 'end': c = cols - 1; break; case 'home-row': r = 0; c = 0; break; case 'end-row': r = total - 1; c = cols - 1; break; case 'left-wrap': if (c > 0) { c--; } else if (r > 0) { r--; c = cols - 1; } break; case 'right-wrap': if (c < cols - 1) { c++; } else if (r < total - 1) { r++; c = 0; } break; } setSelected(r, c); } function onCellKey(ev) { if (app.state.editing) return; // input owns its own keys if (!app.state.selected) return; const { row: r, col: c } = app.state.selected; switch (ev.key) { case 'ArrowUp': ev.preventDefault(); moveSelection('up'); return; case 'ArrowDown': ev.preventDefault(); moveSelection('down'); return; case 'ArrowLeft': ev.preventDefault(); moveSelection('left'); return; case 'ArrowRight': ev.preventDefault(); moveSelection('right'); return; case 'Home': ev.preventDefault(); if (ev.ctrlKey || ev.metaKey) moveSelection('home-row'); else moveSelection('home'); return; case 'End': ev.preventDefault(); if (ev.ctrlKey || ev.metaKey) moveSelection('end-row'); else moveSelection('end'); return; case 'Tab': ev.preventDefault(); moveSelection(ev.shiftKey ? 'left-wrap' : 'right-wrap'); return; case 'Enter': case 'F2': ev.preventDefault(); enterEdit(); return; case 'Escape': ev.preventDefault(); clearSelection(); return; } if (isPrintableKey(ev)) { // Replace value with the typed character (Excel convention). ev.preventDefault(); enterEdit(ev.key); } } // --- Wiring ------------------------------------------------------- function attachToTable() { const t = tableEl(); if (!t) return; t.setAttribute('role', 'grid'); t.addEventListener('keydown', onCellKey); } function attachToRow(tr, rowId) { tr.setAttribute('role', 'row'); tr.setAttribute('data-row-id', rowId); } function attachToCell(td, rowIdx, colIdx) { td.setAttribute('role', 'gridcell'); td.setAttribute('data-col-idx', String(colIdx)); td.setAttribute('data-row-idx', String(rowIdx)); td.setAttribute('tabindex', '-1'); td.addEventListener('click', function (ev) { ev.stopPropagation(); setSelected(rowIdx, colIdx); }); td.addEventListener('dblclick', function (ev) { ev.stopPropagation(); setSelected(rowIdx, colIdx, { noFocus: true }); enterEdit(); }); } app.modules.editor = { attachToTable: attachToTable, attachToRow: attachToRow, attachToCell: attachToCell, setSelected: setSelected, clearSelection: clearSelection, moveSelection: moveSelection, enterEdit: enterEdit, rowKey: rowKey, getDraft: getDraft, setDraft: setDraft, clearDraftField: clearDraftField, effectiveCellValue: effectiveCellValue }; })(window.tablesApp);