diff --git a/tables/css/table.css b/tables/css/table.css
index 2309ca9..9aa07b5 100644
--- a/tables/css/table.css
+++ b/tables/css/table.css
@@ -160,7 +160,11 @@
left-border swatch; the row tooltip on hover surfaces the state.
Colors track the state's urgency: dirty (subtle), saving (info),
queued (warm), invalid/stale (warning), errored (alert). */
-.zddc-table__row--dirty td:first-child { box-shadow: inset 3px 0 0 var(--color-info, #4a90e2); }
+/* Dirty row gets a wider swatch (4px → easier to see at a glance) AND
+ a faint blue background so the unsaved state reads as "row is in a
+ different state" not "small marker on the edge". */
+.zddc-table__row--dirty td:first-child { box-shadow: inset 4px 0 0 var(--color-info, #4a90e2); }
+.zddc-table__row--dirty { background: var(--color-bg-info, rgba(74, 144, 226, 0.08)); }
.zddc-table__row--saving td:first-child { box-shadow: inset 3px 0 0 var(--color-muted, #888); }
.zddc-table__row--queued td:first-child { box-shadow: inset 3px 0 0 var(--color-warm, #d4a017); }
.zddc-table__row--stale td:first-child { box-shadow: inset 3px 0 0 var(--color-warning, #e8a33d); background: var(--color-bg-warning, rgba(232, 163, 61, 0.06)); }
diff --git a/tables/js/editor.js b/tables/js/editor.js
index a079c63..86edf5a 100644
--- a/tables/js/editor.js
+++ b/tables/js/editor.js
@@ -109,6 +109,7 @@
app.state.drafts[rowId] = {};
}
app.state.drafts[rowId][field] = value;
+ notifyDraftsChanged();
}
function clearDraftField(rowId, field) {
@@ -118,6 +119,17 @@
if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId];
}
+ notifyDraftsChanged();
+ }
+
+ // Notify the save module that drafts changed so it can update the
+ // toolbar Save button + count. Save module is optional in test
+ // fixtures, so the call is guarded.
+ function notifyDraftsChanged() {
+ const save = app.modules.save;
+ if (save && typeof save.onDraftsChanged === 'function') {
+ save.onDraftsChanged();
+ }
}
function effectiveCellValue(row, col) {
diff --git a/tables/js/main.js b/tables/js/main.js
index 2b0e5bc..2171d19 100644
--- a/tables/js/main.js
+++ b/tables/js/main.js
@@ -31,6 +31,7 @@
const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
const exportBtn = document.getElementById('table-export-csv');
+ const saveBtn = document.getElementById('table-save');
// Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to
/form.html and swaps the
@@ -39,6 +40,50 @@
// context loaded with columns) — the test-fixture inline-context
// harness opens tables.html directly with no URL shape, so we
// gate on having a column list AND running over http(s).
+ // Save: explicit flush of every dirty row. The button is
+ // hidden until a draft exists; save.onDraftsChanged() (called
+ // from editor.setDraft / clearDraftField) toggles visibility +
+ // updates the count label. Backstop for the row-blur trigger,
+ // which only fires when the user navigates to a different
+ // ROW in the table — clicking outside the grid entirely never
+ // fired a save without this.
+ if (saveBtn) {
+ saveBtn.addEventListener('click', function () {
+ const save = app.modules.save;
+ if (save && typeof save.flushAll === 'function') {
+ save.flushAll();
+ }
+ });
+ }
+
+ // Ctrl+S (Cmd+S on mac) flushes all dirty rows. Capturing
+ // phase so we beat the browser's "Save Page As" default.
+ window.addEventListener('keydown', function (ev) {
+ if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
+ const save = app.modules.save;
+ if (save && typeof save.dirtyCount === 'function' && save.dirtyCount() > 0) {
+ ev.preventDefault();
+ save.flushAll();
+ }
+ }
+ });
+
+ // Auto-save when focus leaves the grid entirely (the user
+ // clicked a header link, the URL bar, etc. without moving to
+ // another row first). focusout fires for cell-to-cell moves
+ // too — relatedTarget being outside #table-root distinguishes.
+ const tableRoot = document.getElementById('table-root');
+ if (tableRoot) {
+ tableRoot.addEventListener('focusout', function (ev) {
+ const next = ev.relatedTarget;
+ if (next && tableRoot.contains(next)) return;
+ const save = app.modules.save;
+ if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
+ save.flushAll();
+ }
+ });
+ }
+
// Export CSV: client-side build of the current view (filtered +
// sorted columns + values). No server round-trip, no auth gate
// — the user already has the data on screen. Shown on every
@@ -155,6 +200,11 @@
if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows();
}
+ // Refresh the Save button visibility + count after every
+ // paint — save flow may have settled drafts in the meantime.
+ if (save && typeof save.updateSaveButton === 'function') {
+ save.updateSaveButton();
+ }
}
// Public re-paint entry point so other modules (save.useMine /
diff --git a/tables/js/save.js b/tables/js/save.js
index 5c3f1cb..c6f0f2c 100644
--- a/tables/js/save.js
+++ b/tables/js/save.js
@@ -244,6 +244,7 @@
// If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
+ updateSaveButton();
return { status: 'ok' };
}
@@ -254,6 +255,7 @@
row.data = merged;
delete app.state.drafts[rowId];
setRowState(rowId, 'queued');
+ updateSaveButton();
return { status: 'queued' };
}
@@ -484,6 +486,51 @@
}
}
+ // flushAll fires saves for every dirty row and returns when they
+ // all settle. Used by the explicit Save button and the auto-save
+ // when focus leaves the grid. Unlike flushAllDrafts, this is NOT
+ // keepalive — the page isn't going anywhere, so we wait for real
+ // responses and surface errors normally.
+ async function flushAll() {
+ const drafts = app.state.drafts || {};
+ const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0);
+ if (ids.length === 0) return { status: 'noop' };
+ const results = await Promise.allSettled(ids.map(id => saveRow(id)));
+ const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length;
+ return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok };
+ }
+
+ // Count rows that have at least one unsaved field.
+ function dirtyCount() {
+ const drafts = app.state.drafts || {};
+ let n = 0;
+ for (const id in drafts) {
+ if (drafts[id] && Object.keys(drafts[id]).length > 0) n++;
+ }
+ return n;
+ }
+
+ // Update the toolbar Save button visibility + label from current
+ // draft state. Called from editor.js whenever drafts mutate; also
+ // safe to call anytime (e.g. after a paint).
+ function updateSaveButton() {
+ const btn = document.getElementById('table-save');
+ if (!btn) return;
+ const n = dirtyCount();
+ if (n === 0) {
+ btn.hidden = true;
+ btn.textContent = 'Save';
+ return;
+ }
+ btn.hidden = false;
+ btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)';
+ }
+
+ function onDraftsChanged() {
+ updateSaveButton();
+ markAllDirtyRows();
+ }
+
// Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap.
@@ -496,7 +543,11 @@
useMine: useMine,
reload: reload,
onSelectionChanged: onSelectionChanged,
+ onDraftsChanged: onDraftsChanged,
markAllDirtyRows: markAllDirtyRows,
+ updateSaveButton: updateSaveButton,
+ flushAll: flushAll,
+ dirtyCount: dirtyCount,
flushAllDrafts: flushAllDrafts,
};
})(window.tablesApp);
diff --git a/tables/template.html b/tables/template.html
index 09eda85..65e484e 100644
--- a/tables/template.html
+++ b/tables/template.html
@@ -47,6 +47,7 @@
@@ -4105,6 +4110,7 @@ body.is-elevated::after {
app.state.drafts[rowId] = {};
}
app.state.drafts[rowId][field] = value;
+ notifyDraftsChanged();
}
function clearDraftField(rowId, field) {
@@ -4114,6 +4120,17 @@ body.is-elevated::after {
if (Object.keys(r).length === 0) {
delete app.state.drafts[rowId];
}
+ notifyDraftsChanged();
+ }
+
+ // Notify the save module that drafts changed so it can update the
+ // toolbar Save button + count. Save module is optional in test
+ // fixtures, so the call is guarded.
+ function notifyDraftsChanged() {
+ const save = app.modules.save;
+ if (save && typeof save.onDraftsChanged === 'function') {
+ save.onDraftsChanged();
+ }
}
function effectiveCellValue(row, col) {
@@ -5340,6 +5357,7 @@ body.is-elevated::after {
// If a status prompt was up for this row, drop it.
const sb = document.getElementById('table-status');
if (sb && sb.getAttribute('data-row-id') === rowId) clearStatus();
+ updateSaveButton();
return { status: 'ok' };
}
@@ -5350,6 +5368,7 @@ body.is-elevated::after {
row.data = merged;
delete app.state.drafts[rowId];
setRowState(rowId, 'queued');
+ updateSaveButton();
return { status: 'queued' };
}
@@ -5580,6 +5599,51 @@ body.is-elevated::after {
}
}
+ // flushAll fires saves for every dirty row and returns when they
+ // all settle. Used by the explicit Save button and the auto-save
+ // when focus leaves the grid. Unlike flushAllDrafts, this is NOT
+ // keepalive — the page isn't going anywhere, so we wait for real
+ // responses and surface errors normally.
+ async function flushAll() {
+ const drafts = app.state.drafts || {};
+ const ids = Object.keys(drafts).filter(id => drafts[id] && Object.keys(drafts[id]).length > 0);
+ if (ids.length === 0) return { status: 'noop' };
+ const results = await Promise.allSettled(ids.map(id => saveRow(id)));
+ const ok = results.filter(r => r.status === 'fulfilled' && r.value && r.value.status === 'ok').length;
+ return { status: 'done', total: ids.length, ok: ok, failed: ids.length - ok };
+ }
+
+ // Count rows that have at least one unsaved field.
+ function dirtyCount() {
+ const drafts = app.state.drafts || {};
+ let n = 0;
+ for (const id in drafts) {
+ if (drafts[id] && Object.keys(drafts[id]).length > 0) n++;
+ }
+ return n;
+ }
+
+ // Update the toolbar Save button visibility + label from current
+ // draft state. Called from editor.js whenever drafts mutate; also
+ // safe to call anytime (e.g. after a paint).
+ function updateSaveButton() {
+ const btn = document.getElementById('table-save');
+ if (!btn) return;
+ const n = dirtyCount();
+ if (n === 0) {
+ btn.hidden = true;
+ btn.textContent = 'Save';
+ return;
+ }
+ btn.hidden = false;
+ btn.textContent = n === 1 ? 'Save (1 unsaved)' : 'Save (' + n + ' unsaved)';
+ }
+
+ function onDraftsChanged() {
+ updateSaveButton();
+ markAllDirtyRows();
+ }
+
// Window unload handler — call any in-flight drafts so the user
// doesn't lose typing on tab-close. The PUT uses keepalive:true so
// it survives navigation; that comes with a 64 KB body cap.
@@ -5592,7 +5656,11 @@ body.is-elevated::after {
useMine: useMine,
reload: reload,
onSelectionChanged: onSelectionChanged,
+ onDraftsChanged: onDraftsChanged,
markAllDirtyRows: markAllDirtyRows,
+ updateSaveButton: updateSaveButton,
+ flushAll: flushAll,
+ dirtyCount: dirtyCount,
flushAllDrafts: flushAllDrafts,
};
})(window.tablesApp);
@@ -6355,6 +6423,7 @@ body.is-elevated::after {
const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
const exportBtn = document.getElementById('table-export-csv');
+ const saveBtn = document.getElementById('table-save');
// Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to /form.html and swaps the
@@ -6363,6 +6432,50 @@ body.is-elevated::after {
// context loaded with columns) — the test-fixture inline-context
// harness opens tables.html directly with no URL shape, so we
// gate on having a column list AND running over http(s).
+ // Save: explicit flush of every dirty row. The button is
+ // hidden until a draft exists; save.onDraftsChanged() (called
+ // from editor.setDraft / clearDraftField) toggles visibility +
+ // updates the count label. Backstop for the row-blur trigger,
+ // which only fires when the user navigates to a different
+ // ROW in the table — clicking outside the grid entirely never
+ // fired a save without this.
+ if (saveBtn) {
+ saveBtn.addEventListener('click', function () {
+ const save = app.modules.save;
+ if (save && typeof save.flushAll === 'function') {
+ save.flushAll();
+ }
+ });
+ }
+
+ // Ctrl+S (Cmd+S on mac) flushes all dirty rows. Capturing
+ // phase so we beat the browser's "Save Page As" default.
+ window.addEventListener('keydown', function (ev) {
+ if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
+ const save = app.modules.save;
+ if (save && typeof save.dirtyCount === 'function' && save.dirtyCount() > 0) {
+ ev.preventDefault();
+ save.flushAll();
+ }
+ }
+ });
+
+ // Auto-save when focus leaves the grid entirely (the user
+ // clicked a header link, the URL bar, etc. without moving to
+ // another row first). focusout fires for cell-to-cell moves
+ // too — relatedTarget being outside #table-root distinguishes.
+ const tableRoot = document.getElementById('table-root');
+ if (tableRoot) {
+ tableRoot.addEventListener('focusout', function (ev) {
+ const next = ev.relatedTarget;
+ if (next && tableRoot.contains(next)) return;
+ const save = app.modules.save;
+ if (save && typeof save.flushAll === 'function' && save.dirtyCount() > 0) {
+ save.flushAll();
+ }
+ });
+ }
+
// Export CSV: client-side build of the current view (filtered +
// sorted columns + values). No server round-trip, no auth gate
// — the user already has the data on screen. Shown on every
@@ -6479,6 +6592,11 @@ body.is-elevated::after {
if (save && typeof save.markAllDirtyRows === 'function') {
save.markAllDirtyRows();
}
+ // Refresh the Save button visibility + count after every
+ // paint — save flow may have settled drafts in the meantime.
+ if (save && typeof save.updateSaveButton === 'function') {
+ save.updateSaveButton();
+ }
}
// Public re-paint entry point so other modules (save.useMine /