First step toward the Excel-like editable-table the user asked for. Architecture decisions in this phase came from a focused research pass over Notion / Airtable / AG Grid / Handsontable / Glide / W3C ARIA APG; the design notes are in this commit's predecessor as a research synthesis. Five phases planned; this is phase 1 of 5 and ships the cell-selection + keyboard-navigation + per-cell editor mount-on-demand foundation. Edits in this phase live in a client- side draft buffer only; row-level save + ETag conflict UX is phase 3. Scope: - ARIA grid pattern verbatim (W3C WAI-ARIA APG): role=grid on the table, role=row on rows, role=gridcell on cells, roving tabindex (only one cell carries tabindex=0; arrows move it). This makes the grid one tab stop in the page tab order — the documented spreadsheet UX, and also the basis for screen-reader correctness. - Click selects a cell. Arrow keys move selection. Tab and Shift-Tab move with row-wrap. Home / End jump within row; Ctrl/Cmd+Home / End jump to grid corners. Enter, F2, double- click, or any printable character all enter edit mode. In edit mode: Enter commits and moves down (Excel convention), Tab commits and moves right (with row-wrap), Escape cancels and restores the prior value, blur commits. - Mount-on-demand cell editor: one <input> at a time is instantiated inside the selected cell. Survives 1000-row tables without the focus-ring churn an always-editable design would hit, and lets Phase 2 swap the input for schema-driven widgets (number / date / select / etc.) without restructuring. - Draft buffer at app.state.drafts keyed by row id (the row's re-edit URL — stable across sort and filter). When a cell commits with a value different from row.data, the draft entry is set; render reads from the draft via effectiveCellValue() so the visible cell content reflects unsaved edits. No-op edits (commit returns the original value) clear any pending draft. - Selection survives re-paints. Sort / filter / spec changes trigger a re-render; the editor's setSelected at end of paint() clamps to new bounds and rebinds tabindex. The user's cell doesn't disappear when they sort the column they're editing. - Numeric coercion fast-path: cells whose column declares format=number/integer coerce the input string to Number on commit. Phase 2 will generalize this to schema-driven coercion for date, boolean, enum, etc. UX consequence — single-click semantics change: The pre-existing row-click-navigates-to-form-edit behavior is gone. Single click now selects a cell (spreadsheet-native). The "open this row in the form editor" affordance moves to phase 2 (an explicit "Edit…" button or an icon column). The row-click- navigation tests in tests/tables.spec.js are replaced with seven new tests covering the editor lifecycle. What this phase does NOT do (and which phases own it): - Phase 2: schema-driven editor widgets (right input type per column). Server-side validation 422 → red-corner marks. Complex types (object, generic array, oneOf) get an "Edit…" button that opens the side-panel form-render mode the unified bundle already ships. - Phase 3: row-level save on row-blur via PUT + If-Match. Stale- row badge with "Use mine" / "Reload" on 412. Outbox carries the offline path transparently via the existing source.js layer. - Phase 4: copy/paste from Excel/Sheets via TSV parser, spill- from-anchor or fill-all into a selection range. - Phase 5: undo (linear command stack, Ctrl+Z, session-local) and multi-cell ops (range select, bulk delete, Ctrl+D / Ctrl+R fill). Tests (tests/tables.spec.js, all 15 pass): - clicking a cell selects it (replaces the old row-click-navigates test; verifies single-click does NOT navigate) - arrow keys move cell selection - Tab and Shift-Tab traverse cells with row-wrap - Enter enters edit mode; Enter commits and moves down (verifies draft is applied to visible cell + selection moves) - Escape cancels edit, restoring prior value (verifies no-op on draft buffer) - typing a printable char enters edit and replaces the value - double-click also enters edit mode - non-editable rows still get the readonly class (cosmetic guard for an existing convention; phase 3 will gate write submission) Files: - tables/js/editor.js (new) — selection + keyboard handling + edit-mode lifecycle + draft buffer. - tables/js/app.js — state.selected / state.editing / state.drafts fields. - tables/js/render.js — ARIA roles + editor.attachToCell wiring; cells render via editor.effectiveCellValue so drafts show. - tables/js/main.js — paint()-end editor.attachToTable + setSelected restore. - tables/css/table.css — selected-cell focus ring (outline, doesn't shift surrounding cells); cell-input bare-inside-cell styling. - tables/build.sh — editor.js in the concat list. - zddc/internal/handler/tables.html — regenerated bundle. Bundle size: 117 KB → 124 KB (+7 KB for editor.js + ARIA + draft machinery). Well within the budget the library survey identified (Tabulator would have been +100 KB; SlickGrid +34 KB; custom is +7 KB and we keep the no-third-party-deps invariant). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
415 lines
15 KiB
JavaScript
415 lines
15 KiB
JavaScript
// 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
|
|
// <id>.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);
|