diff --git a/tables/build.sh b/tables/build.sh
index d733d81..2b2a667 100755
--- a/tables/build.sh
+++ b/tables/build.sh
@@ -55,6 +55,7 @@ concat_files \
"js/save.js" \
"js/row-ops.js" \
"js/clipboard.js" \
+ "js/export.js" \
"js/render.js" \
"js/main.js" \
"../form/js/app.js" \
diff --git a/tables/js/export.js b/tables/js/export.js
new file mode 100644
index 0000000..71091a0
--- /dev/null
+++ b/tables/js/export.js
@@ -0,0 +1,79 @@
+// export.js — CSV download of the current table view.
+//
+// Exports what the user sees: filter + sort applied, columns in the
+// order declared by the spec. Values pass through util.formatCell so
+// date / number / boolean cells match their on-screen rendering.
+// RFC 4180 quoting (double-quote any cell with a comma, newline, or
+// quote; escape inner quotes by doubling). UTF-8 BOM prepended so
+// Excel detects the encoding without a manual import-wizard step.
+
+(function (app) {
+ 'use strict';
+
+ function csvEscape(value) {
+ if (value == null) return '';
+ const str = String(value);
+ if (/[",\r\n]/.test(str)) {
+ return '"' + str.replace(/"/g, '""') + '"';
+ }
+ return str;
+ }
+
+ function buildCsv(rows, columns, util) {
+ const lines = [];
+ lines.push(columns.map(function (c) {
+ return csvEscape(c.title || c.field || '');
+ }).join(','));
+ for (let i = 0; i < rows.length; i++) {
+ const row = rows[i];
+ const cells = columns.map(function (c) {
+ const raw = util.resolveField(row.data, c.field);
+ return csvEscape(util.formatCell(raw, c.format));
+ });
+ lines.push(cells.join(','));
+ }
+ return lines.join('\r\n') + '\r\n';
+ }
+
+ function suggestFilename() {
+ const titleEl = document.getElementById('table-title');
+ const raw = (titleEl && titleEl.textContent) ? titleEl.textContent : 'table';
+ const base = raw.toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'table';
+ const stamp = new Date().toISOString().slice(0, 10);
+ return base + '-' + stamp + '.csv';
+ }
+
+ function download(csv, filename) {
+ const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
+ }
+
+ function invoke() {
+ const ctx = app.context || {};
+ const columns = Array.isArray(ctx.columns) ? ctx.columns : [];
+ if (columns.length === 0) {
+ return;
+ }
+ const state = app.state;
+ const util = app.modules.util;
+ const filtered = app.modules.filters.apply(state.rows, columns, state.filter, util.resolveField);
+ const sorted = app.modules.sort.apply(filtered, state.sort, columns, util);
+ const csv = buildCsv(sorted, columns, util);
+ download(csv, suggestFilename());
+ }
+
+ app.modules.exportCsv = {
+ invoke: invoke,
+ buildCsv: buildCsv,
+ csvEscape: csvEscape
+ };
+})(window.tablesApp);
diff --git a/tables/js/main.js b/tables/js/main.js
index bf73833..2b0e5bc 100644
--- a/tables/js/main.js
+++ b/tables/js/main.js
@@ -30,6 +30,7 @@
const countEl = document.getElementById('table-rowcount');
const clearBtn = document.getElementById('table-clear-filters');
const addRowBtn = document.getElementById('table-add-row');
+ const exportBtn = document.getElementById('table-export-csv');
// Add-row button: appends a draft row inline. Save fires on
// row-blur, which POSTs to
/form.html and swaps the
@@ -38,6 +39,23 @@
// 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).
+ // 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
+ // table that loaded with columns, regardless of HTTP/file://.
+ if (exportBtn) {
+ const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
+ if (hasCols) {
+ exportBtn.hidden = false;
+ exportBtn.addEventListener('click', function () {
+ const exp = app.modules.exportCsv;
+ if (exp && typeof exp.invoke === 'function') {
+ exp.invoke();
+ }
+ });
+ }
+ }
+
if (addRowBtn) {
const onHttp = location.protocol === 'http:' || location.protocol === 'https:';
const hasCols = Array.isArray(ctx.columns) && ctx.columns.length > 0;
diff --git a/tables/template.html b/tables/template.html
index 486d908..09eda85 100644
--- a/tables/template.html
+++ b/tables/template.html
@@ -47,6 +47,7 @@