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:
parent
e6d9966593
commit
08ce8a1266
8 changed files with 1112 additions and 71 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
415
tables/js/editor.js
Normal 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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue