From 08ce8a12668e1190bcecff32464f1848bbbdc735 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Sat, 9 May 2026 09:16:39 -0500 Subject: [PATCH] =?UTF-8?q?feat(tables):=20editable=20cells=20phase=201=20?= =?UTF-8?q?=E2=80=94=20selection=20+=20keyboard=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- tables/build.sh | 1 + tables/css/table.css | 36 ++- tables/js/app.js | 13 +- tables/js/editor.js | 415 +++++++++++++++++++++++++ tables/js/main.js | 12 + tables/js/render.js | 35 ++- tests/tables.spec.js | 170 ++++++++-- zddc/internal/handler/tables.html | 501 ++++++++++++++++++++++++++++-- 8 files changed, 1112 insertions(+), 71 deletions(-) create mode 100644 tables/js/editor.js diff --git a/tables/build.sh b/tables/build.sh index c6ce994..cc230b2 100755 --- a/tables/build.sh +++ b/tables/build.sh @@ -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" \ diff --git a/tables/css/table.css b/tables/css/table.css index 81158e0..7666e94 100644 --- a/tables/css/table.css +++ b/tables/css/table.css @@ -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 { diff --git a/tables/js/app.js b/tables/js/app.js index 4750be0..bbffd7d 100644 --- a/tables/js/app.js +++ b/tables/js/app.js @@ -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: {} }; diff --git a/tables/js/editor.js b/tables/js/editor.js new file mode 100644 index 0000000..83bfb79 --- /dev/null +++ b/tables/js/editor.js @@ -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 + // .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); diff --git a/tables/js/main.js b/tables/js/main.js index cbe010a..cbe1fff 100644 --- a/tables/js/main.js +++ b/tables/js/main.js @@ -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) { diff --git a/tables/js/render.js b/tables/js/render.js index 485d8db..3d5d6cf 100644 --- a/tables/js/render.js +++ b/tables/js/render.js @@ -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); } diff --git a/tests/tables.spec.js b/tests/tables.spec.js index 114108d..2decf27 100644 --- a/tests/tables.spec.js +++ b/tests/tables.spec.js @@ -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); }); diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index b2f34a7..28e8378 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -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 {
ZDDC Table - v0.0.17-alpha · 2026-05-09 14:14:07 · 2ce5336-dirty + v0.0.17-alpha · 2026-05-09 14:15:35 · e6d9966-dirty
@@ -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 + // .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) {