// 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; notifySelectionChanged(); 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 }; notifySelectionChanged(); } function notifySelectionChanged() { // Phase 3 wires the row-blur save trigger here. save module is // optional in test fixtures that don't include it. const save = app.modules.save; if (save && typeof save.onSelectionChanged === 'function') { save.onSelectionChanged(app.state.selected); } } 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 propSchema = propertySchemaFor(col); // Complex-type cells (nested object, generic array, oneOf) // can't be inline-edited cleanly — punt to the row's form // editor in a side panel / new page. Phase 2 ships the // navigation; Phase 5 may add a side-panel mount. if (isComplexSchema(propSchema)) { navigateToRowForm(row); return; } const currentValue = effectiveCellValue(row, col); const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue); const inputEl = widget.element; inputEl.classList.add('zddc-table__cell-input'); inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field)); // Replace the cell's text content with the editor widget. // Stash the original text in dataset so cancel can restore it // verbatim without re-running the formatCell logic. cell.setAttribute('data-display', cell.textContent || ''); cell.textContent = ''; cell.appendChild(inputEl); widget.focus(); app.state.editing = true; function commit() { if (!app.state.editing) return; const newValue = widget.getValue(); const oldRaw = app.modules.util.resolveField(row.data, col.field); // Compare by JSON-string equality so number 42 == "42" // entered into a number input doesn't false-positive as // a change. resolveField already returns the raw typed // value from row.data. if (sameValue(oldRaw, newValue)) { clearDraftField(rowKey(row), col.field); } else { setDraft(rowKey(row), col.field, newValue); } tearDown(newValue); } function cancel() { tearDown(null); // null = restore from data-display, no draft change } function tearDown(displayValue) { inputEl.removeEventListener('keydown', onKey); inputEl.removeEventListener('blur', onBlur); const display = (displayValue !== undefined && 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(); } } inputEl.addEventListener('keydown', onKey); inputEl.addEventListener('blur', onBlur); } function renderableText(value, col) { return app.modules.util.formatCell(value, col.format); } // --- Schema → editor widget factory -------------------------------- function propertySchemaFor(col) { // Walk the row schema for this column's field. Returns null // when no schema is present (best-effort: cells fall back to // plain text editors). Supports a single dot-separated path // — `properties.a.properties.b` for `field: "a.b"` — to mirror // the existing util.resolveField conventions. const ctx = app.context || {}; if (!ctx.rowSchema) return null; const parts = String(col.field || '').split('.').filter(Boolean); let s = ctx.rowSchema; for (let i = 0; i < parts.length; i++) { if (!s || !s.properties || !s.properties[parts[i]]) return null; s = s.properties[parts[i]]; } return s; } function isComplexSchema(s) { if (!s) return false; if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true; if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true; if (Array.isArray(s.allOf) && s.allOf.length > 0) return true; if (s.type === 'object') return true; if (s.type === 'array') { // Multi-select-friendly arrays (string-enum + uniqueItems) // get inline editing; everything else is complex. const items = s.items || {}; const isMultiSelect = items.type === 'string' && Array.isArray(items.enum) && items.enum.length > 0 && s.uniqueItems === true; return !isMultiSelect; } return false; } function makeWidget(propSchema, col, initialValue) { // Prefers explicit JSON Schema hints; falls back to column-spec // hints (col.format / col.enum) for tables without a form.yaml; // defaults to a plain text input. const s = propSchema || {}; const colHint = col || {}; // Boolean → checkbox. if (s.type === 'boolean') { return widgetCheckbox(initialValue); } // Enum (string with explicit choices) → select dropdown. const enumChoices = (Array.isArray(s.enum) && s.enum) || (Array.isArray(colHint.enum) && colHint.enum) || null; if (enumChoices) { return widgetSelect(enumChoices, initialValue); } // Multi-select (array of string-enum with uniqueItems). if (s.type === 'array' && s.items && s.items.type === 'string' && Array.isArray(s.items.enum) && s.uniqueItems === true) { return widgetMultiSelect(s.items.enum, initialValue); } // Number / integer → number input with min/max/step. if (s.type === 'number' || s.type === 'integer' || colHint.format === 'number' || colHint.format === 'integer') { return widgetNumber(s, initialValue); } // Date / date-time / email — typed inputs the browser can // help validate. const fmt = s.format || colHint.format; if (fmt === 'date') return widgetTyped('date', initialValue); if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue); if (fmt === 'email') return widgetTyped('email', initialValue); // Long text → textarea (still inline; Phase 5 may add expand). if (s.type === 'string' && Number(s.maxLength) > 200) { return widgetTextarea(initialValue); } // Default: plain text input. return widgetText(initialValue); } function widgetText(initial) { const el = document.createElement('input'); el.type = 'text'; el.value = stringify(initial); return { element: el, getValue: () => el.value, focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} } }; } function widgetTextarea(initial) { const el = document.createElement('textarea'); el.rows = 1; el.value = stringify(initial); return { element: el, getValue: () => el.value, focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} } }; } function widgetTyped(htmlType, initial) { const el = document.createElement('input'); el.type = htmlType; el.value = stringify(initial); return { element: el, getValue: () => el.value, focus: () => el.focus() }; } function widgetNumber(s, initial) { const el = document.createElement('input'); el.type = 'number'; if (s.minimum != null) el.min = String(s.minimum); if (s.maximum != null) el.max = String(s.maximum); if (s.type === 'integer') el.step = '1'; else if (s.multipleOf != null) el.step = String(s.multipleOf); el.value = (initial == null || initial === '') ? '' : String(initial); return { element: el, getValue: () => { const v = el.value; if (v === '') return null; const n = Number(v); return Number.isNaN(n) ? v : n; }, focus: () => el.focus() }; } function widgetCheckbox(initial) { const el = document.createElement('input'); el.type = 'checkbox'; el.checked = initial === true || initial === 'true'; return { element: el, getValue: () => el.checked, focus: () => el.focus() }; } function widgetSelect(choices, initial) { const el = document.createElement('select'); // Empty option lets the cell go back to "unset" without typing. const empty = document.createElement('option'); empty.value = ''; empty.textContent = '—'; el.appendChild(empty); for (let i = 0; i < choices.length; i++) { const opt = document.createElement('option'); opt.value = String(choices[i]); opt.textContent = String(choices[i]); el.appendChild(opt); } el.value = initial == null ? '' : String(initial); return { element: el, getValue: () => (el.value === '' ? null : el.value), focus: () => el.focus() }; } function widgetMultiSelect(choices, initial) { const el = document.createElement('select'); el.multiple = true; el.size = Math.min(6, choices.length); const initialSet = {}; const initArr = Array.isArray(initial) ? initial : []; for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true; for (let i = 0; i < choices.length; i++) { const opt = document.createElement('option'); opt.value = String(choices[i]); opt.textContent = String(choices[i]); if (initialSet[opt.value]) opt.selected = true; el.appendChild(opt); } return { element: el, getValue: () => { const out = []; for (let i = 0; i < el.options.length; i++) { if (el.options[i].selected) out.push(el.options[i].value); } return out; }, focus: () => el.focus() }; } function stringify(v) { if (v == null) return ''; if (typeof v === 'object') { try { return JSON.stringify(v); } catch (_) { return String(v); } } return String(v); } function sameValue(a, b) { if (a === b) return true; if (a == null && b == null) return true; if (a == null || b == null) return false; if (typeof a === 'object' || typeof b === 'object') { try { return JSON.stringify(a) === JSON.stringify(b); } catch (_) { return false; } } // Loose-string compare so number 42 == "42" from a text input. return String(a) === String(b); } function navigateToRowForm(row) { // Complex-type cells punt to the row's full form editor. // The url field on each context row already points at // /.yaml.html — the form-mode re-edit URL. if (!row || !row.url) return; const nav = (window.tablesApp && window.tablesApp.navigateTo) || function (u) { window.location.assign(u); }; nav(row.url); } // --- 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);