feat(tables): editable cells phase 1 — selection + keyboard nav

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>
This commit is contained in:
ZDDC 2026-05-09 09:16:39 -05:00
parent e6d9966593
commit 08ce8a1266
8 changed files with 1112 additions and 71 deletions

View file

@ -40,6 +40,7 @@ concat_files \
"js/util.js" \
"js/filters.js" \
"js/sort.js" \
"js/editor.js" \
"js/render.js" \
"js/main.js" \
"../form/js/app.js" \

View file

@ -103,14 +103,6 @@
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
}
.zddc-table__row--editable {
cursor: pointer;
}
.zddc-table__row--editable:hover {
background: var(--color-bg-hover, rgba(50, 100, 200, 0.08));
}
.zddc-table__row--readonly {
color: var(--color-text-muted);
}
@ -119,6 +111,34 @@
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--color-border-soft, rgba(0, 0, 0, 0.06));
vertical-align: top;
cursor: cell;
/* Hide the browser's default outline; the grid pattern renders
its own selection chrome via the --selected class. */
outline: none;
}
/* Currently-selected cell Excel-style focus ring. The 2px outset
border doesn't push surrounding cells around because outline is
used instead of border. */
.zddc-table__cell--selected {
outline: 2px solid var(--color-accent, #2868c8);
outline-offset: -2px;
background: var(--color-bg-selected, rgba(40, 104, 200, 0.08));
}
/* Inline cell-editor input: occupies the cell verbatim, no border so
it visually replaces the cell text. The selected outline on the
surrounding td still shows. */
.zddc-table__cell-input {
width: 100%;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
outline: none;
}
.table-empty {

View file

@ -8,7 +8,18 @@
state: {
rows: [],
sort: [],
filter: {}
filter: {},
// Editor-mode state (Phase 1):
// selected: {row: rowId, col: field} | null — currently
// focused cell. row is the row's id (or rowsRel for the
// row file path); col is the column's `field`.
// editing: bool — whether a cell-editor input is mounted.
// drafts: {rowId: {field: value, ...}, ...} — uncommitted
// edits, displayed in lieu of row.data while present.
// Cleared per-row when that row's PUT succeeds (Phase 3).
selected: null,
editing: false,
drafts: {}
},
modules: {}
};

415
tables/js/editor.js Normal file
View file

@ -0,0 +1,415 @@
// 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);

View file

@ -94,6 +94,18 @@
if (clearBtn) {
clearBtn.hidden = !anyFilterActive();
}
// Restore the editor's selection across re-paints so a sort
// or filter change doesn't dump the user out of the cell
// they were on. Selected coords clamp to the new bounds in
// setSelected; if the row vanished (filter excluded it),
// we land on the last valid cell instead of clearing.
const editor = app.modules.editor;
if (editor) {
editor.attachToTable();
if (state.selected) {
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
}
}
}
function onHeaderClick(field, shiftKey) {

View file

@ -48,32 +48,33 @@
function renderBody(tbodyEl, rows, columns) {
const util = app.modules.util;
const editor = app.modules.editor;
tbodyEl.innerHTML = '';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const tr = util.h('tr', {
className: 'zddc-table__row' + (row.editable ? ' zddc-table__row--editable' : ' zddc-table__row--readonly'),
'data-url': row.url,
'data-editable': row.editable ? '1' : '0',
onClick: function (ev) {
const target = ev.currentTarget;
const editable = target.getAttribute('data-editable') === '1';
const url = target.getAttribute('data-url');
if (editable && url) {
// Indirection so tests can intercept without
// fighting Chromium's location.assign property
// descriptor. Production calls window.location.assign.
const nav = (window.tablesApp && window.tablesApp.navigateTo) ||
function (u) { window.location.assign(u); };
nav(url);
}
}
'data-editable': row.editable ? '1' : '0'
});
const rowId = editor ? editor.rowKey(row) : (row.url || '');
if (editor) {
editor.attachToRow(tr, rowId);
}
for (let c = 0; c < columns.length; c++) {
const col = columns[c];
const raw = util.resolveField(row.data, col.field);
const text = util.formatCell(raw, col.format);
tr.appendChild(util.h('td', { className: 'zddc-table__cell' }, text));
// Editor's draft buffer overrides the row's stored value
// until Phase 3 commits it. Falls back to row.data when
// no draft is present.
const value = editor
? editor.effectiveCellValue(row, col)
: util.resolveField(row.data, col.field);
const text = util.formatCell(value, col.format);
const td = util.h('td', { className: 'zddc-table__cell' }, text);
if (editor) {
editor.attachToCell(td, i, c);
}
tr.appendChild(td);
}
tbodyEl.appendChild(tr);
}

View file

@ -145,28 +145,170 @@ test.describe('tables/ — directory-of-YAML table view', () => {
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
});
test('click on editable row navigates to the row URL', async ({ page }) => {
test('clicking a cell selects it (Phase 1 — replaces row-click navigation)', async ({ page }) => {
// Single click → cell selection. Row navigation moves to a
// dedicated affordance in Phase 2 (open-in-form button) so the
// primary click action can be the spreadsheet-native one.
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Stub the navigate seam render.js consults before falling back
// to window.location.assign (which Chromium won't let us override
// directly via a plain property assignment).
// Stub navigate seam — verifies single-click does NOT navigate.
await page.evaluate(() => {
window.__navTarget = null;
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
});
await page.locator('#table-root tbody tr').first().click();
const target = await page.evaluate(() => window.__navTarget);
expect(target).toBeTruthy();
expect(target).toContain('.yaml.html');
// Click a specific cell.
const firstCell = page.locator('#table-root tbody tr').first().locator('[role="gridcell"]').first();
await firstCell.click();
await expect(firstCell).toHaveClass(/zddc-table__cell--selected/);
await expect(firstCell).toHaveAttribute('tabindex', '0');
await expect(page.evaluate(() => window.__navTarget)).resolves.toBeNull();
});
test('non-editable rows do not navigate on click', async ({ page }) => {
test('arrow keys move cell selection (ARIA grid)', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Click to seed selection at (0,0), then arrow around.
const r0c0 = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(0);
await r0c0.click();
await expect(r0c0).toHaveClass(/zddc-table__cell--selected/);
await page.keyboard.press('ArrowDown');
const r1c0 = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(0);
await expect(r1c0).toHaveClass(/zddc-table__cell--selected/);
await page.keyboard.press('ArrowRight');
const r1c1 = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(1);
await expect(r1c1).toHaveClass(/zddc-table__cell--selected/);
await page.keyboard.press('ArrowUp');
const r0c1 = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
await expect(r0c1).toHaveClass(/zddc-table__cell--selected/);
await page.keyboard.press('ArrowLeft');
await expect(r0c0).toHaveClass(/zddc-table__cell--selected/);
});
test('Tab and Shift-Tab traverse cells with row-wrap', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const numCols = MDL_COLUMNS.length;
// Start at last column of row 0.
const r0Last = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(numCols - 1);
await r0Last.click();
await expect(r0Last).toHaveClass(/zddc-table__cell--selected/);
// Tab → first cell of row 1 (wrap).
await page.keyboard.press('Tab');
const r1First = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(0);
await expect(r1First).toHaveClass(/zddc-table__cell--selected/);
// Shift+Tab → back to last cell of row 0.
await page.keyboard.press('Shift+Tab');
await expect(r0Last).toHaveClass(/zddc-table__cell--selected/);
});
test('Enter enters edit mode; Enter commits and moves down', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
// Edit the title cell (column index 1) of row 0.
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
await titleCell.click();
await page.keyboard.press('Enter');
// Editor input mounted inside the cell.
const input = titleCell.locator('input.zddc-table__cell-input');
await expect(input).toBeVisible();
await expect(input).toBeFocused();
// Type new value, press Enter to commit + move down.
await page.keyboard.press('Control+a');
await page.keyboard.type('New title via cell editor');
await page.keyboard.press('Enter');
// Cell shows new value, input gone.
await expect(titleCell).toContainText('New title via cell editor');
await expect(titleCell.locator('input')).toHaveCount(0);
// Selection moved down one row, same column.
const r1Title = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(1);
await expect(r1Title).toHaveClass(/zddc-table__cell--selected/);
});
test('Escape cancels edit, restoring prior value', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
const originalText = await titleCell.textContent();
await titleCell.click();
await page.keyboard.press('Enter');
await page.keyboard.press('Control+a');
await page.keyboard.type('Should not stick');
await page.keyboard.press('Escape');
// Value restored to original; no draft entry.
await expect(titleCell).toHaveText(originalText.trim());
const draftCount = await page.evaluate(() =>
Object.keys(window.tablesApp.state.drafts).length);
expect(draftCount).toBe(0);
});
test('typing a printable char enters edit and replaces value', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
await titleCell.click();
// Press a printable character — should enter edit mode with
// that char as the new value.
await page.keyboard.press('X');
const input = titleCell.locator('input.zddc-table__cell-input');
await expect(input).toBeVisible();
await expect(input).toHaveValue('X');
});
test('double-click also enters edit mode', async ({ page }) => {
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
rows: ROWS,
});
await page.waitForSelector('#table-root tbody tr');
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
await titleCell.dblclick();
await expect(titleCell.locator('input.zddc-table__cell-input')).toBeVisible();
});
test('non-editable rows still get the readonly class', async ({ page }) => {
// Cosmetic guard for an existing convention: rows where the
// server says editable=false get a visual treatment. Cell
// selection still works in Phase 1; Phase 3 will gate writes
// on the editable flag at save time.
const readOnlyRows = ROWS.map(r => ({ ...r, editable: false }));
await loadTableWithContext(page, {
columns: MDL_COLUMNS,
@ -174,16 +316,6 @@ test.describe('tables/ — directory-of-YAML table view', () => {
});
await page.waitForSelector('#table-root tbody tr');
await page.evaluate(() => {
window.__navTarget = null;
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
});
await page.locator('#table-root tbody tr').first().click();
const target = await page.evaluate(() => window.__navTarget);
expect(target).toBeNull();
// Read-only rows should also lack the editable visual class.
await expect(page.locator('#table-root tbody tr.zddc-table__row--editable')).toHaveCount(0);
await expect(page.locator('#table-root tbody tr.zddc-table__row--readonly')).toHaveCount(ROWS.length);
});

View file

@ -630,14 +630,6 @@ body.help-open .app-header {
background: var(--color-bg-zebra, rgba(0, 0, 0, 0.02));
}
.zddc-table__row--editable {
cursor: pointer;
}
.zddc-table__row--editable:hover {
background: var(--color-bg-hover, rgba(50, 100, 200, 0.08));
}
.zddc-table__row--readonly {
color: var(--color-text-muted);
}
@ -646,6 +638,34 @@ body.help-open .app-header {
padding: var(--spacing-sm) var(--spacing-md);
border-bottom: 1px solid var(--color-border-soft, rgba(0, 0, 0, 0.06));
vertical-align: top;
cursor: cell;
/* Hide the browser's default outline; the grid pattern renders
its own selection chrome via the --selected class. */
outline: none;
}
/* Currently-selected cell — Excel-style focus ring. The 2px outset
border doesn't push surrounding cells around because outline is
used instead of border. */
.zddc-table__cell--selected {
outline: 2px solid var(--color-accent, #2868c8);
outline-offset: -2px;
background: var(--color-bg-selected, rgba(40, 104, 200, 0.08));
}
/* Inline cell-editor input: occupies the cell verbatim, no border so
it visually replaces the cell text. The selected outline on the
surrounding td still shows. */
.zddc-table__cell-input {
width: 100%;
box-sizing: border-box;
padding: 0;
margin: 0;
border: none;
background: var(--color-bg, #fff);
color: var(--color-text, #111);
font: inherit;
outline: none;
}
.table-empty {
@ -871,7 +891,7 @@ body.help-open .app-header {
</svg>
<div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 14:14:07 · 2ce5336-dirty</span></span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-09 14:15:35 · e6d9966-dirty</span></span>
</div>
</div>
<div class="header-right">
@ -2476,6 +2496,422 @@ body.help-open .app-header {
};
})(window.tablesApp);
// 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);
(function (app) {
'use strict';
@ -2526,32 +2962,33 @@ body.help-open .app-header {
function renderBody(tbodyEl, rows, columns) {
const util = app.modules.util;
const editor = app.modules.editor;
tbodyEl.innerHTML = '';
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const tr = util.h('tr', {
className: 'zddc-table__row' + (row.editable ? ' zddc-table__row--editable' : ' zddc-table__row--readonly'),
'data-url': row.url,
'data-editable': row.editable ? '1' : '0',
onClick: function (ev) {
const target = ev.currentTarget;
const editable = target.getAttribute('data-editable') === '1';
const url = target.getAttribute('data-url');
if (editable && url) {
// Indirection so tests can intercept without
// fighting Chromium's location.assign property
// descriptor. Production calls window.location.assign.
const nav = (window.tablesApp && window.tablesApp.navigateTo) ||
function (u) { window.location.assign(u); };
nav(url);
}
}
'data-editable': row.editable ? '1' : '0'
});
const rowId = editor ? editor.rowKey(row) : (row.url || '');
if (editor) {
editor.attachToRow(tr, rowId);
}
for (let c = 0; c < columns.length; c++) {
const col = columns[c];
const raw = util.resolveField(row.data, col.field);
const text = util.formatCell(raw, col.format);
tr.appendChild(util.h('td', { className: 'zddc-table__cell' }, text));
// Editor's draft buffer overrides the row's stored value
// until Phase 3 commits it. Falls back to row.data when
// no draft is present.
const value = editor
? editor.effectiveCellValue(row, col)
: util.resolveField(row.data, col.field);
const text = util.formatCell(value, col.format);
const td = util.h('td', { className: 'zddc-table__cell' }, text);
if (editor) {
editor.attachToCell(td, i, c);
}
tr.appendChild(td);
}
tbodyEl.appendChild(tr);
}
@ -2669,6 +3106,18 @@ body.help-open .app-header {
if (clearBtn) {
clearBtn.hidden = !anyFilterActive();
}
// Restore the editor's selection across re-paints so a sort
// or filter change doesn't dump the user out of the cell
// they were on. Selected coords clamp to the new bounds in
// setSelected; if the row vanished (filter excluded it),
// we land on the last valid cell instead of clearing.
const editor = app.modules.editor;
if (editor) {
editor.attachToTable();
if (state.selected) {
editor.setSelected(state.selected.row, state.selected.col, { noFocus: true });
}
}
}
function onHeaderClick(field, shiftKey) {