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 @@
+
diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index deac1d1..a37dc9c 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1253,7 +1253,11 @@ body.is-elevated::after { 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)); } @@ -1511,7 +1515,7 @@ body.is-elevated::after {
ZDDC Table - v0.0.17-alpha · 2026-05-19 13:30:29 · f3d334a-dirty + v0.0.17-alpha · 2026-05-19 13:37:29 · 1604b62-dirty
@@ -1536,6 +1540,7 @@ body.is-elevated::after {
+
@@ -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 /