ZDDC/tables/js/editor.js
ZDDC e5bb7f216c feat(tables): editable cells phase 2 — schema-driven editor widgets
Replaces the always-text-input cell editor with a per-property
widget factory keyed off the row's JSON Schema (form.yaml). The
table view now picks the right editor for each cell automatically:
strings get text inputs, enums get dropdowns, integers get number
inputs with min/max, dates get date pickers, booleans get
checkboxes, multi-select arrays get a multi-select. Cells whose
schema is a complex type (nested object, generic array, oneOf /
anyOf / allOf) can't be inline-edited and punt to the row's
form-mode editor on Enter / double-click.

Schema discovery:

context.js walkServer fetches <currentdir>/form.yaml as a
companion to <currentdir>/table.yaml — same file the form-mode
renderer already loads, just from the table view's perspective.
Best-effort: a directory with table.yaml but no form.yaml still
renders as a sortable/filterable table; cells just fall back to
plain text inputs without per-property hints. The schema is
exposed as ctx.rowSchema and consumed by the editor's
propertySchemaFor() helper, which walks dot-separated field
names through schema.properties to locate each column's
property schema.

Editor factory (editor.js):

- propertySchemaFor(col) — schema lookup keyed by col.field.
- isComplexSchema(s) — true for nested object, generic array,
  oneOf/anyOf/allOf. Multi-select-friendly arrays
  (string-enum + uniqueItems) are NOT complex; they get an
  inline multi-select widget.
- makeWidget(propSchema, col, initialValue) — dispatches to one
  of the widget builders below based on schema type / format /
  enum + column-spec hints (col.format / col.enum) for tables
  without a form.yaml.

Widget builders, each returning {element, getValue, focus}:

- widgetText        — plain <input type=text>, default fallback.
- widgetTextarea    — for string with maxLength > 200 (long
                      narrative fields).
- widgetTyped(type) — typed inputs the browser can help validate;
                      used for date / date-time / email.
- widgetNumber      — <input type=number> with min/max/step
                      derived from schema.minimum/maximum/
                      multipleOf. Integer schemas force step=1.
                      getValue returns Number, not string, so
                      the draft buffer holds the right type for
                      JSON serialization later.
- widgetCheckbox    — <input type=checkbox>; getValue returns
                      bool. initial value coerces from "true"/
                      true string-or-bool.
- widgetSelect      — <select> with empty placeholder + one
                      option per enum choice; getValue returns
                      the chosen string or null.
- widgetMultiSelect — <select multiple> with size = min(6, N);
                      getValue returns the array of selected
                      values (preserves order in the option list).

Complex-type cells:

isComplexSchema(propSchema) → enterEdit calls navigateToRowForm,
which routes to row.url (already the <id>.yaml.html re-edit URL
the row tracker holds). Phase 5 may swap this for an inline
side-panel mount of form-mode in the same bundle, but the
current navigate-out path delivers the same eventual UX without
needing the side-panel scaffolding.

Type-aware draft equality:

The pre-Phase-2 commit treated every value as a string and
compared via String() equality, which would mark any number-
column edit dirty even when the user re-typed the same number.
The new sameValue() helper handles bool/object via JSON-string
equality and falls back to loose string compare so 42 == "42"
isn't a false dirty. Drafts hold typed values (number, bool,
array) instead of all strings, so when Phase 3 wires the row PUT
the body shape matches the JSON Schema the server validates
against without an additional coercion pass.

Tests (tests/tables.spec.js — 7 new specs, total 22 in the
table view, all 27 in the file):

- enum column edits via select dropdown — verifies the empty
  placeholder + 3 enum options render and the chosen value
  displays back in the cell.
- integer column gives a number input with min/max — verifies
  the type/min/max/step attributes derive from the schema, AND
  the draft buffer holds typeof === 'number'.
- boolean column gives a checkbox — verifies type=checkbox and
  the draft holds true after Space-toggle. (Toggle via Space,
  not Playwright's .check() helper, to dodge the click+blur
  race a focused-checkbox-inside-grid-cell hits.)
- format:date column gives a date input — verifies type=date
  and the existing value pre-populates as YYYY-MM-DD.
- multi-select enum-array column gives a multi-select.
- complex (object) column navigates to the row form on edit —
  verifies no inline editor mounts AND the navigate seam
  receives the row's URL.
- no rowSchema → falls back to plain text editor — verifies the
  best-effort behavior for directories with only table.yaml.

Bundle size: 124 KB → 127 KB (+3 KB for the factory + widget
builders).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:18:25 -05:00

633 lines
23 KiB
JavaScript

// editor.js — Phase 1 of editable-cell mode.
//
// Owns the cell-selection + per-cell edit lifecycle. Implements the
// W3C ARIA grid-pattern keyboard semantics:
//
// - Arrow keys move the selected cell.
// - Tab / Shift-Tab move right / left, wrapping to next / prev row.
// - Enter, F2, double-click, or any printable character enter edit
// mode (Enter and F2 keep the existing value; printable chars
// replace it; double-click opens with the existing value).
// - In edit mode: Enter commits and moves down, Tab commits and
// moves right, Escape cancels (restoring the prior value), blur
// commits.
//
// Roving tabindex: only the selected cell carries tabindex=0; all
// others are tabindex=-1. This makes the grid a single tab-stop in
// the page's tab order, which is the documented spreadsheet UX.
//
// Edits in this phase live in app.state.drafts and never hit the
// network — Phase 3 wires the row-blur PUT.
(function (app) {
'use strict';
// --- Helpers ------------------------------------------------------
function tableEl() { return document.getElementById('table-root'); }
function cellAt(r, c) { return cellsByRowCol(r, c); }
// The displayed table is filtered+sorted; selection is keyed by
// VISIBLE row index, not row id, so arrow keys behave intuitively
// even after sort / filter changes (the cell at row 3 column 2
// stays at row 3 column 2 even if the underlying row id moved).
// This is how Excel and Google Sheets behave too.
function cellsByRowCol(r, c) {
const t = tableEl();
if (!t) return null;
const tbody = t.querySelector('tbody');
if (!tbody) return null;
const tr = tbody.children[r];
if (!tr) return null;
return tr.querySelector('[role="gridcell"][data-col-idx="' + c + '"]');
}
function isPrintableKey(ev) {
// A "printable" key produces a single character of text — e.g.
// 'a', '7', '$'. Function keys, arrows, modifiers etc. either
// have multi-char `key` values ('ArrowDown') or are non-text.
// ev.ctrlKey / metaKey suppress so Cmd-A et al. don't trigger
// edit mode.
if (ev.key.length !== 1) return false;
if (ev.ctrlKey || ev.metaKey || ev.altKey) return false;
return true;
}
function rowCount() {
const t = tableEl();
if (!t) return 0;
return t.querySelectorAll('tbody > tr').length;
}
function colCount() {
const cols = (app.context && app.context.columns) || [];
return Array.isArray(cols) ? cols.length : 0;
}
function colAt(c) {
const cols = (app.context && app.context.columns) || [];
return cols[c] || null;
}
function rowDataAt(r) {
// The visible row at index r. Walk the rendered tbody to find
// its data-row-id, then look up the row in app.state.rows.
// app.state.rows holds the SORTED+FILTERED current view (kept
// in sync by main.js paint()).
const t = tableEl();
if (!t) return null;
const tr = t.querySelectorAll('tbody > tr')[r];
if (!tr) return null;
const rowId = tr.getAttribute('data-row-id');
if (rowId == null) return null;
const all = app.state.rows || [];
for (let i = 0; i < all.length; i++) {
if (rowKey(all[i]) === rowId) {
return all[i];
}
}
return null;
}
function rowKey(row) {
// Stable per-row identity. Each context row has a `url` (the
// <id>.yaml.html re-edit URL); the file basename inside that
// URL is unique per directory and survives sort/filter.
if (!row || !row.url) return '';
return row.url;
}
// --- Draft buffer -------------------------------------------------
function getDraft(rowId, field) {
const r = app.state.drafts[rowId];
if (!r) return undefined;
return r[field];
}
function setDraft(rowId, field, value) {
if (!app.state.drafts[rowId]) {
app.state.drafts[rowId] = {};
}
app.state.drafts[rowId][field] = value;
}
function clearDraftField(rowId, field) {
const r = app.state.drafts[rowId];
if (!r) return;
delete r[field];
if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId];
}
}
function effectiveCellValue(row, col) {
// Display draft value if present; otherwise the row's stored
// value. Used by render to keep the visible cell content in
// sync with uncommitted edits.
const drafted = getDraft(rowKey(row), col.field);
if (drafted !== undefined) {
return drafted;
}
return app.modules.util.resolveField(row.data, col.field);
}
// --- Selection (roving tabindex) ----------------------------------
function setSelected(r, c, opts) {
opts = opts || {};
const total = rowCount();
const cols = colCount();
if (total === 0 || cols === 0) {
app.state.selected = null;
return;
}
if (r < 0) r = 0;
if (r > total - 1) r = total - 1;
if (c < 0) c = 0;
if (c > cols - 1) c = cols - 1;
const t = tableEl();
if (t) {
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].setAttribute('tabindex', '-1');
all[i].classList.remove('zddc-table__cell--selected');
}
}
const target = cellAt(r, c);
if (target) {
target.setAttribute('tabindex', '0');
target.classList.add('zddc-table__cell--selected');
if (!opts.noFocus) {
target.focus({ preventScroll: false });
}
}
app.state.selected = { row: r, col: c };
}
function clearSelection() {
const t = tableEl();
if (t) {
const all = t.querySelectorAll('[role="gridcell"]');
for (let i = 0; i < all.length; i++) {
all[i].setAttribute('tabindex', '-1');
all[i].classList.remove('zddc-table__cell--selected');
}
}
app.state.selected = null;
}
// --- Edit mode ----------------------------------------------------
function enterEdit(initial) {
if (!app.state.selected) return;
if (app.state.editing) return;
const { row: r, col: c } = app.state.selected;
const cell = cellAt(r, c);
if (!cell) return;
const row = rowDataAt(r);
const col = colAt(c);
if (!row || !col) return;
const propSchema = propertySchemaFor(col);
// Complex-type cells (nested object, generic array, oneOf)
// can't be inline-edited cleanly — punt to the row's form
// editor in a side panel / new page. Phase 2 ships the
// navigation; Phase 5 may add a side-panel mount.
if (isComplexSchema(propSchema)) {
navigateToRowForm(row);
return;
}
const currentValue = effectiveCellValue(row, col);
const widget = makeWidget(propSchema, col, initial != null ? initial : currentValue);
const inputEl = widget.element;
inputEl.classList.add('zddc-table__cell-input');
inputEl.setAttribute('aria-label', 'Edit ' + (col.title || col.field));
// Replace the cell's text content with the editor widget.
// Stash the original text in dataset so cancel can restore it
// verbatim without re-running the formatCell logic.
cell.setAttribute('data-display', cell.textContent || '');
cell.textContent = '';
cell.appendChild(inputEl);
widget.focus();
app.state.editing = true;
function commit() {
if (!app.state.editing) return;
const newValue = widget.getValue();
const oldRaw = app.modules.util.resolveField(row.data, col.field);
// Compare by JSON-string equality so number 42 == "42"
// entered into a number input doesn't false-positive as
// a change. resolveField already returns the raw typed
// value from row.data.
if (sameValue(oldRaw, newValue)) {
clearDraftField(rowKey(row), col.field);
} else {
setDraft(rowKey(row), col.field, newValue);
}
tearDown(newValue);
}
function cancel() {
tearDown(null); // null = restore from data-display, no draft change
}
function tearDown(displayValue) {
inputEl.removeEventListener('keydown', onKey);
inputEl.removeEventListener('blur', onBlur);
const display = (displayValue !== undefined && displayValue !== null)
? renderableText(displayValue, col)
: (cell.getAttribute('data-display') || '');
cell.removeAttribute('data-display');
cell.textContent = display;
app.state.editing = false;
cell.focus({ preventScroll: false });
}
function onKey(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
commit();
setSelected(r + 1, c);
} else if (ev.key === 'Escape') {
ev.preventDefault();
cancel();
} else if (ev.key === 'Tab') {
ev.preventDefault();
commit();
if (ev.shiftKey) {
moveSelection('left-wrap');
} else {
moveSelection('right-wrap');
}
}
// Other keys: stay in edit mode, let the input handle them.
}
function onBlur(_ev) {
// Blur (focus moved elsewhere). Commit any pending value.
// Schedule via setTimeout(0) so a programmatic refocus by
// tearDown→cell.focus doesn't re-fire blur during teardown.
if (app.state.editing) {
commit();
}
}
inputEl.addEventListener('keydown', onKey);
inputEl.addEventListener('blur', onBlur);
}
function renderableText(value, col) {
return app.modules.util.formatCell(value, col.format);
}
// --- Schema → editor widget factory --------------------------------
function propertySchemaFor(col) {
// Walk the row schema for this column's field. Returns null
// when no schema is present (best-effort: cells fall back to
// plain text editors). Supports a single dot-separated path
// — `properties.a.properties.b` for `field: "a.b"` — to mirror
// the existing util.resolveField conventions.
const ctx = app.context || {};
if (!ctx.rowSchema) return null;
const parts = String(col.field || '').split('.').filter(Boolean);
let s = ctx.rowSchema;
for (let i = 0; i < parts.length; i++) {
if (!s || !s.properties || !s.properties[parts[i]]) return null;
s = s.properties[parts[i]];
}
return s;
}
function isComplexSchema(s) {
if (!s) return false;
if (Array.isArray(s.oneOf) && s.oneOf.length > 0) return true;
if (Array.isArray(s.anyOf) && s.anyOf.length > 0) return true;
if (Array.isArray(s.allOf) && s.allOf.length > 0) return true;
if (s.type === 'object') return true;
if (s.type === 'array') {
// Multi-select-friendly arrays (string-enum + uniqueItems)
// get inline editing; everything else is complex.
const items = s.items || {};
const isMultiSelect = items.type === 'string'
&& Array.isArray(items.enum) && items.enum.length > 0
&& s.uniqueItems === true;
return !isMultiSelect;
}
return false;
}
function makeWidget(propSchema, col, initialValue) {
// Prefers explicit JSON Schema hints; falls back to column-spec
// hints (col.format / col.enum) for tables without a form.yaml;
// defaults to a plain text input.
const s = propSchema || {};
const colHint = col || {};
// Boolean → checkbox.
if (s.type === 'boolean') {
return widgetCheckbox(initialValue);
}
// Enum (string with explicit choices) → select dropdown.
const enumChoices = (Array.isArray(s.enum) && s.enum)
|| (Array.isArray(colHint.enum) && colHint.enum)
|| null;
if (enumChoices) {
return widgetSelect(enumChoices, initialValue);
}
// Multi-select (array of string-enum with uniqueItems).
if (s.type === 'array'
&& s.items && s.items.type === 'string'
&& Array.isArray(s.items.enum) && s.uniqueItems === true) {
return widgetMultiSelect(s.items.enum, initialValue);
}
// Number / integer → number input with min/max/step.
if (s.type === 'number' || s.type === 'integer'
|| colHint.format === 'number' || colHint.format === 'integer') {
return widgetNumber(s, initialValue);
}
// Date / date-time / email — typed inputs the browser can
// help validate.
const fmt = s.format || colHint.format;
if (fmt === 'date') return widgetTyped('date', initialValue);
if (fmt === 'date-time') return widgetTyped('datetime-local', initialValue);
if (fmt === 'email') return widgetTyped('email', initialValue);
// Long text → textarea (still inline; Phase 5 may add expand).
if (s.type === 'string' && Number(s.maxLength) > 200) {
return widgetTextarea(initialValue);
}
// Default: plain text input.
return widgetText(initialValue);
}
function widgetText(initial) {
const el = document.createElement('input');
el.type = 'text';
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
};
}
function widgetTextarea(initial) {
const el = document.createElement('textarea');
el.rows = 1;
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => { el.focus(); try { el.setSelectionRange(el.value.length, el.value.length); } catch (_) {} }
};
}
function widgetTyped(htmlType, initial) {
const el = document.createElement('input');
el.type = htmlType;
el.value = stringify(initial);
return {
element: el,
getValue: () => el.value,
focus: () => el.focus()
};
}
function widgetNumber(s, initial) {
const el = document.createElement('input');
el.type = 'number';
if (s.minimum != null) el.min = String(s.minimum);
if (s.maximum != null) el.max = String(s.maximum);
if (s.type === 'integer') el.step = '1';
else if (s.multipleOf != null) el.step = String(s.multipleOf);
el.value = (initial == null || initial === '') ? '' : String(initial);
return {
element: el,
getValue: () => {
const v = el.value;
if (v === '') return null;
const n = Number(v);
return Number.isNaN(n) ? v : n;
},
focus: () => el.focus()
};
}
function widgetCheckbox(initial) {
const el = document.createElement('input');
el.type = 'checkbox';
el.checked = initial === true || initial === 'true';
return {
element: el,
getValue: () => el.checked,
focus: () => el.focus()
};
}
function widgetSelect(choices, initial) {
const el = document.createElement('select');
// Empty option lets the cell go back to "unset" without typing.
const empty = document.createElement('option');
empty.value = '';
empty.textContent = '—';
el.appendChild(empty);
for (let i = 0; i < choices.length; i++) {
const opt = document.createElement('option');
opt.value = String(choices[i]);
opt.textContent = String(choices[i]);
el.appendChild(opt);
}
el.value = initial == null ? '' : String(initial);
return {
element: el,
getValue: () => (el.value === '' ? null : el.value),
focus: () => el.focus()
};
}
function widgetMultiSelect(choices, initial) {
const el = document.createElement('select');
el.multiple = true;
el.size = Math.min(6, choices.length);
const initialSet = {};
const initArr = Array.isArray(initial) ? initial : [];
for (let i = 0; i < initArr.length; i++) initialSet[String(initArr[i])] = true;
for (let i = 0; i < choices.length; i++) {
const opt = document.createElement('option');
opt.value = String(choices[i]);
opt.textContent = String(choices[i]);
if (initialSet[opt.value]) opt.selected = true;
el.appendChild(opt);
}
return {
element: el,
getValue: () => {
const out = [];
for (let i = 0; i < el.options.length; i++) {
if (el.options[i].selected) out.push(el.options[i].value);
}
return out;
},
focus: () => el.focus()
};
}
function stringify(v) {
if (v == null) return '';
if (typeof v === 'object') {
try { return JSON.stringify(v); } catch (_) { return String(v); }
}
return String(v);
}
function sameValue(a, b) {
if (a === b) return true;
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (typeof a === 'object' || typeof b === 'object') {
try { return JSON.stringify(a) === JSON.stringify(b); }
catch (_) { return false; }
}
// Loose-string compare so number 42 == "42" from a text input.
return String(a) === String(b);
}
function navigateToRowForm(row) {
// Complex-type cells punt to the row's full form editor.
// The url field on each context row already points at
// <dir>/<id>.yaml.html — the form-mode re-edit URL.
if (!row || !row.url) return;
const nav = (window.tablesApp && window.tablesApp.navigateTo)
|| function (u) { window.location.assign(u); };
nav(row.url);
}
// --- Keyboard nav -------------------------------------------------
function moveSelection(dir) {
if (!app.state.selected) return;
let { row: r, col: c } = app.state.selected;
const total = rowCount();
const cols = colCount();
if (total === 0 || cols === 0) return;
switch (dir) {
case 'up': r = Math.max(0, r - 1); break;
case 'down': r = Math.min(total - 1, r + 1); break;
case 'left': c = Math.max(0, c - 1); break;
case 'right': c = Math.min(cols - 1, c + 1); break;
case 'home': c = 0; break;
case 'end': c = cols - 1; break;
case 'home-row': r = 0; c = 0; break;
case 'end-row': r = total - 1; c = cols - 1; break;
case 'left-wrap':
if (c > 0) { c--; }
else if (r > 0) { r--; c = cols - 1; }
break;
case 'right-wrap':
if (c < cols - 1) { c++; }
else if (r < total - 1) { r++; c = 0; }
break;
}
setSelected(r, c);
}
function onCellKey(ev) {
if (app.state.editing) return; // input owns its own keys
if (!app.state.selected) return;
const { row: r, col: c } = app.state.selected;
switch (ev.key) {
case 'ArrowUp': ev.preventDefault(); moveSelection('up'); return;
case 'ArrowDown': ev.preventDefault(); moveSelection('down'); return;
case 'ArrowLeft': ev.preventDefault(); moveSelection('left'); return;
case 'ArrowRight': ev.preventDefault(); moveSelection('right'); return;
case 'Home':
ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) moveSelection('home-row');
else moveSelection('home');
return;
case 'End':
ev.preventDefault();
if (ev.ctrlKey || ev.metaKey) moveSelection('end-row');
else moveSelection('end');
return;
case 'Tab':
ev.preventDefault();
moveSelection(ev.shiftKey ? 'left-wrap' : 'right-wrap');
return;
case 'Enter':
case 'F2':
ev.preventDefault();
enterEdit();
return;
case 'Escape':
ev.preventDefault();
clearSelection();
return;
}
if (isPrintableKey(ev)) {
// Replace value with the typed character (Excel convention).
ev.preventDefault();
enterEdit(ev.key);
}
}
// --- Wiring -------------------------------------------------------
function attachToTable() {
const t = tableEl();
if (!t) return;
t.setAttribute('role', 'grid');
t.addEventListener('keydown', onCellKey);
}
function attachToRow(tr, rowId) {
tr.setAttribute('role', 'row');
tr.setAttribute('data-row-id', rowId);
}
function attachToCell(td, rowIdx, colIdx) {
td.setAttribute('role', 'gridcell');
td.setAttribute('data-col-idx', String(colIdx));
td.setAttribute('data-row-idx', String(rowIdx));
td.setAttribute('tabindex', '-1');
td.addEventListener('click', function (ev) {
ev.stopPropagation();
setSelected(rowIdx, colIdx);
});
td.addEventListener('dblclick', function (ev) {
ev.stopPropagation();
setSelected(rowIdx, colIdx, { noFocus: true });
enterEdit();
});
}
app.modules.editor = {
attachToTable: attachToTable,
attachToRow: attachToRow,
attachToCell: attachToCell,
setSelected: setSelected,
clearSelection: clearSelection,
moveSelection: moveSelection,
enterEdit: enterEdit,
rowKey: rowKey,
getDraft: getDraft,
setDraft: setDraft,
clearDraftField: clearDraftField,
effectiveCellValue: effectiveCellValue
};
})(window.tablesApp);