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>
136 lines
5.4 KiB
JavaScript
136 lines
5.4 KiB
JavaScript
(function (app) {
|
|
'use strict';
|
|
|
|
async function init() {
|
|
// Both apps (table + form) ship in the same bundle. Skip if
|
|
// mode dispatcher said this isn't our mode — form-mode requests
|
|
// are handled by formApp.
|
|
if (window.zddcMode === 'form') {
|
|
return;
|
|
}
|
|
const ctx = await app.modules.context.load();
|
|
app.context = ctx;
|
|
|
|
const titleEl = document.getElementById('table-title');
|
|
if (ctx.title && titleEl) {
|
|
titleEl.textContent = ctx.title;
|
|
document.title = 'ZDDC — ' + ctx.title;
|
|
}
|
|
|
|
const descEl = document.getElementById('table-description');
|
|
if (descEl && ctx.description) {
|
|
descEl.textContent = ctx.description;
|
|
descEl.hidden = false;
|
|
}
|
|
|
|
const tableEl = document.getElementById('table-root');
|
|
const theadEl = tableEl.querySelector('thead');
|
|
const tbodyEl = tableEl.querySelector('tbody');
|
|
const emptyEl = document.getElementById('table-empty');
|
|
const countEl = document.getElementById('table-rowcount');
|
|
const clearBtn = document.getElementById('table-clear-filters');
|
|
const addRowBtn = document.getElementById('table-add-row');
|
|
|
|
// Add-row button: link to <name>.form.html, the form-system's
|
|
// empty-form URL for this table's row schema. POST creates a
|
|
// new submission and the server redirects to the row's edit
|
|
// URL. Hidden when we can't derive a table name from the
|
|
// pathname (e.g. inline-context test harness opening tables.html
|
|
// directly without a *.table.html URL).
|
|
if (addRowBtn) {
|
|
// Page is at <dir>/table.html; the row-creation form is at
|
|
// <dir>/form.html — same directory, just swap the basename.
|
|
if (/\/table\.html$/.test(location.pathname || '')) {
|
|
addRowBtn.href = 'form.html';
|
|
addRowBtn.hidden = false;
|
|
}
|
|
}
|
|
|
|
const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
|
|
const allRows = Array.isArray(ctx.rows) ? ctx.rows : [];
|
|
|
|
const state = app.state;
|
|
state.rows = allRows;
|
|
state.sort = app.modules.sort.defaultsFromContext(ctx);
|
|
state.filter = {};
|
|
|
|
// Seed default filters from context.defaults.filter (per-column).
|
|
if (ctx.defaults && ctx.defaults.filter && typeof ctx.defaults.filter === 'object') {
|
|
for (let i = 0; i < columns.length; i++) {
|
|
const col = columns[i];
|
|
const seeded = ctx.defaults.filter[col.field];
|
|
if (seeded == null) {
|
|
continue;
|
|
}
|
|
// Filter UI is uniformly text-contains. If the spec
|
|
// seeds an array (legacy enum-style), coerce to a
|
|
// comma-joined contains string — partial match on any
|
|
// listed value still narrows the table sensibly.
|
|
const seedStr = Array.isArray(seeded) ? seeded.join(',') : String(seeded);
|
|
state.filter[col.field] = { kind: 'contains', value: seedStr };
|
|
}
|
|
}
|
|
|
|
function anyFilterActive() {
|
|
const filters = app.modules.filters;
|
|
const keys = Object.keys(state.filter);
|
|
for (let i = 0; i < keys.length; i++) {
|
|
if (!filters.isEmpty(state.filter[keys[i]])) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function paint() {
|
|
const filtered = app.modules.filters.apply(state.rows, columns, state.filter, app.modules.util.resolveField);
|
|
const sorted = app.modules.sort.apply(filtered, state.sort, columns, app.modules.util);
|
|
app.modules.render.header(theadEl, columns, state.sort, state.filter, onHeaderClick, onFilterChange);
|
|
app.modules.render.body(tbodyEl, sorted, columns);
|
|
app.modules.render.rowCount(countEl, sorted.length, state.rows.length);
|
|
if (emptyEl) {
|
|
emptyEl.hidden = sorted.length > 0 || state.rows.length === 0;
|
|
}
|
|
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) {
|
|
state.sort = app.modules.sort.cycle(state.sort, field, shiftKey);
|
|
paint();
|
|
}
|
|
|
|
function onFilterChange(field, value) {
|
|
state.filter[field] = value;
|
|
paint();
|
|
}
|
|
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', function () {
|
|
state.filter = {};
|
|
paint();
|
|
});
|
|
}
|
|
|
|
paint();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})(window.tablesApp);
|