@birdycom/omniaui 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +21 -21
  3. package/bin/lib/add.mjs +126 -126
  4. package/bin/lib/catalog.mjs +169 -169
  5. package/bin/lib/project.mjs +90 -90
  6. package/bin/omniaui.mjs +69 -69
  7. package/dist/code-block.css +25 -25
  8. package/dist/components-extra.css +916 -914
  9. package/dist/components.css +6559 -6469
  10. package/dist/components.min.css +1 -1
  11. package/dist/data-table.d.ts +9 -9
  12. package/dist/data-table.js +505 -505
  13. package/dist/docs-ui.css +267 -265
  14. package/dist/oc-theme.css +69 -69
  15. package/dist/parts/app-accordion-accent.css +60 -60
  16. package/dist/parts/app-alpha-tabs.css +68 -68
  17. package/dist/parts/app-busy-overlay.css +25 -25
  18. package/dist/parts/app-column-menu.css +85 -85
  19. package/dist/parts/app-confirm-dialog.css +70 -70
  20. package/dist/parts/app-cookie-consent.css +63 -63
  21. package/dist/parts/app-data-table.css +174 -174
  22. package/dist/parts/app-greeting.css +47 -47
  23. package/dist/parts/app-list-toolbar.css +81 -81
  24. package/dist/parts/app-page-header.css +54 -54
  25. package/dist/parts/app-severity-card.css +91 -91
  26. package/dist/parts/app-sidebar-dark.css +15 -15
  27. package/dist/parts/app-sortable-row.css +52 -52
  28. package/dist/parts/app-stat-card.css +68 -68
  29. package/dist/parts/app-table-actions.css +71 -71
  30. package/dist/parts/app-table-compact.css +54 -54
  31. package/dist/parts/app-top-progress.css +68 -68
  32. package/dist/parts/app-tree.css +104 -104
  33. package/dist/parts/app-upgrade-banner.css +90 -90
  34. package/dist/parts/app-version-banner.css +80 -80
  35. package/dist/parts/data-chart-line.css +167 -167
  36. package/dist/parts/forms-affix-input.css +75 -75
  37. package/dist/parts/forms-barcode-input.css +75 -75
  38. package/dist/parts/forms-chip-filter.css +91 -91
  39. package/dist/parts/forms-color-select.css +97 -89
  40. package/dist/parts/forms-decimal-input.css +51 -51
  41. package/dist/parts/forms-filter-counts.css +94 -94
  42. package/dist/parts/forms-language-select.css +89 -76
  43. package/dist/parts/forms-number-input.css +98 -95
  44. package/dist/parts/forms-password-requirements.css +55 -55
  45. package/dist/parts/forms-phone-input.css +85 -85
  46. package/dist/parts/forms-time-input.css +74 -74
  47. package/dist/parts/forms-toggle-field.css +69 -69
  48. package/dist/parts/media-art.css +21 -21
  49. package/dist/parts/media-attachment.css +160 -160
  50. package/dist/parts/media-browser-frame.css +73 -73
  51. package/dist/parts/media-card.css +83 -83
  52. package/dist/parts/media-chat-composer.css +90 -90
  53. package/dist/parts/media-doc-viewer.css +151 -151
  54. package/dist/parts/media-emoji-picker.css +88 -88
  55. package/dist/parts/media-entity-card.css +52 -52
  56. package/dist/parts/media-flip-card.css +78 -78
  57. package/dist/parts/media-image-zoom.css +68 -68
  58. package/dist/parts/media-lightbox.css +131 -131
  59. package/dist/parts/media-message-thread.css +136 -136
  60. package/dist/parts/media-message.css +160 -160
  61. package/dist/parts/media-row.css +78 -78
  62. package/dist/parts/media-stack-card.css +82 -82
  63. package/dist/parts/media-terminal-log.css +76 -76
  64. package/dist/parts/media-thumb.css +71 -71
  65. package/dist/parts/site-fact-list.css +25 -25
  66. package/dist/parts/site-link-arrow.css +34 -34
  67. package/dist/parts/site-marquee.css +34 -34
  68. package/dist/parts/site-next-teaser.css +75 -75
  69. package/dist/parts/site-price-card.css +91 -75
  70. package/dist/parts/site-product-mark.css +66 -66
  71. package/dist/parts/site-project-card.css +111 -111
  72. package/dist/parts/site-prose.css +124 -124
  73. package/dist/parts/site-stack-list.css +48 -48
  74. package/dist/parts/site-timeline.css +54 -54
  75. package/dist/showcase.css +404 -404
  76. package/package.json +69 -69
  77. package/snippets/accordion-accent.html +24 -24
  78. package/snippets/accordion.html +11 -11
  79. package/snippets/affix-input.html +30 -30
  80. package/snippets/alert-dialog.html +22 -22
  81. package/snippets/alert.html +5 -5
  82. package/snippets/alpha-tabs.html +37 -37
  83. package/snippets/aspect-ratio.html +15 -15
  84. package/snippets/attachment.html +43 -43
  85. package/snippets/avatar.html +6 -6
  86. package/snippets/back-link.html +5 -5
  87. package/snippets/badge.html +5 -5
  88. package/snippets/barcode-input.html +18 -18
  89. package/snippets/breadcrumb.html +6 -6
  90. package/snippets/browser-frame.html +18 -18
  91. package/snippets/busy-overlay.html +31 -31
  92. package/snippets/button-group.html +21 -21
  93. package/snippets/button.html +8 -8
  94. package/snippets/calendar.html +43 -43
  95. package/snippets/card.html +17 -17
  96. package/snippets/carousel.html +21 -21
  97. package/snippets/chart-line.html +46 -46
  98. package/snippets/chart.html +27 -27
  99. package/snippets/chat-composer.html +21 -21
  100. package/snippets/checkbox.html +5 -5
  101. package/snippets/checklist.html +23 -23
  102. package/snippets/chip-filter.html +14 -14
  103. package/snippets/code-window.html +20 -20
  104. package/snippets/collapsible.html +19 -19
  105. package/snippets/color-field.html +13 -13
  106. package/snippets/color-select.html +18 -18
  107. package/snippets/column-menu.html +38 -38
  108. package/snippets/combobox.html +19 -19
  109. package/snippets/command.html +24 -24
  110. package/snippets/confirm-dialog.html +32 -32
  111. package/snippets/context-menu.html +25 -25
  112. package/snippets/cookie-consent.html +70 -70
  113. package/snippets/copy-field.html +19 -19
  114. package/snippets/counter.html +2 -2
  115. package/snippets/data-table.html +151 -151
  116. package/snippets/date-picker.html +32 -32
  117. package/snippets/decimal-input.html +20 -20
  118. package/snippets/definition-list.html +20 -20
  119. package/snippets/demo-panel.html +18 -18
  120. package/snippets/doc-viewer.html +58 -58
  121. package/snippets/drawer.html +30 -30
  122. package/snippets/dropdown-menu.html +27 -27
  123. package/snippets/dropdown.html +9 -9
  124. package/snippets/dropzone.html +13 -13
  125. package/snippets/emoji-picker.html +51 -51
  126. package/snippets/empty-state.html +17 -17
  127. package/snippets/entity-card.html +26 -26
  128. package/snippets/expandable-table.html +26 -26
  129. package/snippets/fact-list.html +14 -14
  130. package/snippets/filter-counts.html +30 -30
  131. package/snippets/flip-card.html +22 -22
  132. package/snippets/form-field.html +6 -6
  133. package/snippets/greeting.html +22 -22
  134. package/snippets/heading.html +14 -14
  135. package/snippets/hover-card.html +18 -18
  136. package/snippets/image-zoom.html +30 -30
  137. package/snippets/input-group.html +10 -10
  138. package/snippets/input.html +5 -5
  139. package/snippets/item.html +26 -26
  140. package/snippets/kbd.html +13 -13
  141. package/snippets/label.html +9 -9
  142. package/snippets/language-select.html +26 -26
  143. package/snippets/launcher-card.html +14 -14
  144. package/snippets/lightbox.html +50 -50
  145. package/snippets/link-arrow.html +10 -10
  146. package/snippets/list-toolbar.html +33 -33
  147. package/snippets/marquee.html +15 -15
  148. package/snippets/media-card.html +28 -28
  149. package/snippets/media-row.html +21 -21
  150. package/snippets/media-thumb.html +12 -12
  151. package/snippets/menubar.html +30 -30
  152. package/snippets/message-thread.html +123 -123
  153. package/snippets/message.html +51 -51
  154. package/snippets/modal.html +15 -15
  155. package/snippets/native-select.html +15 -15
  156. package/snippets/navmenu.html +23 -23
  157. package/snippets/next-teaser.html +15 -15
  158. package/snippets/number-input.html +27 -27
  159. package/snippets/otp-input.html +9 -9
  160. package/snippets/page-header.html +24 -24
  161. package/snippets/pagination.html +6 -6
  162. package/snippets/password-input.html +24 -24
  163. package/snippets/password-requirements.html +30 -30
  164. package/snippets/phone-input.html +22 -22
  165. package/snippets/popover.html +15 -15
  166. package/snippets/price-card.html +34 -34
  167. package/snippets/product-mark.html +13 -13
  168. package/snippets/progress.html +4 -4
  169. package/snippets/project-card.html +23 -23
  170. package/snippets/prose.html +54 -54
  171. package/snippets/radio.html +17 -17
  172. package/snippets/recovery-codes.html +20 -20
  173. package/snippets/resizable.html +16 -16
  174. package/snippets/scroll-area.html +20 -20
  175. package/snippets/search.html +8 -8
  176. package/snippets/segmented.html +11 -11
  177. package/snippets/select.html +7 -7
  178. package/snippets/separator.html +15 -15
  179. package/snippets/severity-card.html +35 -35
  180. package/snippets/sheet.html +32 -32
  181. package/snippets/sidebar-docs.html +24 -24
  182. package/snippets/sidebar.html +55 -55
  183. package/snippets/site-footer.html +6 -6
  184. package/snippets/site-header.html +26 -26
  185. package/snippets/skeleton.html +4 -4
  186. package/snippets/slider.html +17 -17
  187. package/snippets/sortable-row.html +31 -31
  188. package/snippets/spinner.html +14 -14
  189. package/snippets/stack-card.html +17 -17
  190. package/snippets/stack-list.html +16 -16
  191. package/snippets/stat-card.html +24 -24
  192. package/snippets/stepper.html +13 -13
  193. package/snippets/switch.html +6 -6
  194. package/snippets/table-actions.html +39 -39
  195. package/snippets/table-compact.html +24 -24
  196. package/snippets/table.html +29 -29
  197. package/snippets/tabs.html +9 -9
  198. package/snippets/tag-input.html +60 -17
  199. package/snippets/terminal-log.html +23 -23
  200. package/snippets/textarea.html +3 -3
  201. package/snippets/theme-switch.html +6 -6
  202. package/snippets/time-input.html +26 -26
  203. package/snippets/timeline.html +28 -28
  204. package/snippets/toast-stack.html +10 -10
  205. package/snippets/toast.html +36 -36
  206. package/snippets/toc.html +13 -13
  207. package/snippets/toggle-field.html +20 -20
  208. package/snippets/toggle.html +22 -22
  209. package/snippets/tooltip.html +4 -4
  210. package/snippets/top-progress.html +12 -12
  211. package/snippets/tree.html +38 -38
  212. package/snippets/upgrade-banner.html +28 -28
  213. package/snippets/user.html +15 -15
  214. package/snippets/version-banner.html +15 -15
@@ -1,505 +1,505 @@
1
- // File: packages/ui/src/data-table.js
2
- // Map: page numbers -> compare -> csv -> row filter -> bind one table (search, sort, pages, columns, resize, drag, trash, bulk) -> boot
3
- // The list table used in Korrekt and OmniaDesk. Markup stays plain HTML.
4
- // This script adds the behaviour. Excel, PDF and the real delete API stay in the app:
5
- // listen for oc-datatable-bulk, oc-datatable-reorder, oc-datatable-search and oc-datatable-import.
6
-
7
- /** Page numbers with ellipses, same window as OmniaDesk (current and last are 1-based). */
8
- export function pageItems(current, last) {
9
- if (last < 1) return [];
10
- if (last === 1) return [1];
11
- const range = [];
12
- for (let i = Math.max(2, current - 2); i <= Math.min(last - 1, current + 2); i++) range.push(i);
13
- const out = [1];
14
- if (range.length && range[0] > 2) out.push("…");
15
- for (const n of range) out.push(n);
16
- if (range.length && range[range.length - 1] < last - 1) out.push("…");
17
- if (last > 1) out.push(last);
18
- return out;
19
- }
20
-
21
- /** Sort two cell values. Numeric columns accept 5,15 and 5.15. */
22
- export function compareValues(a, b, numeric) {
23
- if (numeric) {
24
- const n = (v) => Number(String(v ?? "").replace(/\s/g, "").replace(",", "."));
25
- const na = n(a);
26
- const nb = n(b);
27
- if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
28
- }
29
- return String(a ?? "").localeCompare(String(b ?? ""), undefined, { numeric: true, sensitivity: "base" });
30
- }
31
-
32
- /** One CSV field. Quotes when the value contains a comma, quote or newline. */
33
- function csvField(value) {
34
- const s = String(value ?? "");
35
- return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
36
- }
37
-
38
- /** Headers plus rows, as a CSV string the browser can download. */
39
- export function toCsv(headers, rows) {
40
- return [headers, ...rows].map((line) => line.map(csvField).join(",")).join("\n");
41
- }
42
-
43
- /**
44
- * Does this row stay visible?
45
- * filter is "active", "deleted", "archived" or "all". query is already lower-case.
46
- */
47
- export function rowPasses(status, filter, text, query) {
48
- const state = status || "active";
49
- if (filter === "active" && state !== "active") return false;
50
- if (filter === "deleted" && state !== "deleted") return false;
51
- if (filter === "archived" && state !== "archived") return false;
52
- if (!query) return true;
53
- return String(text).toLowerCase().includes(query);
54
- }
55
-
56
- const controls = new WeakMap();
57
- const SKIP_COL = new Set(["select", "order", "actions"]);
58
-
59
- function readStore(id, name) {
60
- try {
61
- return JSON.parse(localStorage.getItem(`oc-data-table:${id}:${name}`) || "null");
62
- } catch {
63
- return null;
64
- }
65
- }
66
-
67
- function writeStore(id, name, value) {
68
- try {
69
- localStorage.setItem(`oc-data-table:${id}:${name}`, JSON.stringify(value));
70
- } catch {
71
- // Private mode can block storage. The table still works for this visit.
72
- }
73
- }
74
-
75
- function download(filename, text) {
76
- const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
77
- const link = document.createElement("a");
78
- link.href = url;
79
- link.download = filename;
80
- link.click();
81
- URL.revokeObjectURL(url);
82
- }
83
-
84
- /** Wire one .oc-data-table. Calling it again only refreshes the rows. */
85
- export function bindDataTable(root) {
86
- if (!root) return;
87
- const prev = controls.get(root);
88
- if (prev) {
89
- prev.apply();
90
- return;
91
- }
92
-
93
- const table = root.querySelector("table");
94
- const tbody = table?.tBodies[0];
95
- if (!table || !tbody) return;
96
-
97
- const id = root.id || "data-table";
98
- const search = root.querySelector(".oc-data-table-search");
99
- const summary = root.querySelector("[data-oc-summary]");
100
- const pager = root.querySelector("[data-oc-pager]");
101
- const pageSelect = root.querySelector("[data-oc-page-size]");
102
- const selectAll = root.querySelector("[data-oc-select-all]");
103
- const bulk = root.querySelector(".oc-data-table-bulk");
104
- const count = root.querySelector("[data-oc-selected-count]");
105
- const empty = root.querySelector(".oc-data-table-empty");
106
- const sortSummary = root.querySelector("[data-oc-sort-summary]");
107
- const frame = root.querySelector(".oc-data-table-frame") || table.parentElement;
108
- const live = root.dataset.ocLive === "true";
109
-
110
- let query = "";
111
- let filter = root.querySelector("[data-oc-filter][aria-current='true']")?.dataset.ocFilter
112
- || (root.querySelector("[data-oc-filter]") ? "active" : "all");
113
- let sortKey = "";
114
- let sortDir = "asc";
115
- let page = 0;
116
- let pageSize = Number(pageSelect?.value || root.dataset.pageSize || 15);
117
- const selected = new Set();
118
-
119
- // Remembered choices from the last visit: page size, density, view, column widths, hidden columns.
120
- const savedSize = readStore(id, "pageSize");
121
- if (savedSize && pageSelect?.querySelector(`option[value="${savedSize}"]`)) {
122
- pageSelect.value = String(savedSize);
123
- pageSize = Number(savedSize);
124
- }
125
- if (readStore(id, "density") === "compact") root.classList.add("oc-data-table--compact");
126
- if (readStore(id, "view") === "grid") root.classList.add("oc-data-table--grid");
127
- const savedWidths = readStore(id, "widths") || {};
128
- for (const th of table.tHead.rows[0].cells) {
129
- if (savedWidths[th.dataset.ocCol]) {
130
- th.style.width = savedWidths[th.dataset.ocCol];
131
- table.classList.add("oc-data-table-table--sized");
132
- }
133
- }
134
- const savedCols = readStore(id, "cols") || {};
135
- root.querySelectorAll("[data-col]").forEach((box) => {
136
- if (!box.disabled && savedCols[box.dataset.col] != null) box.checked = savedCols[box.dataset.col];
137
- });
138
-
139
- const rows = () => [...tbody.querySelectorAll("[data-oc-row]")];
140
- const textOf = (row) => [...row.cells]
141
- .filter((td) => td.dataset.ocCol && !SKIP_COL.has(td.dataset.ocCol))
142
- .map((td) => td.dataset.ocValue || td.textContent)
143
- .join(" ");
144
- const valueOf = (row, key) => {
145
- const td = [...row.cells].find((cell) => cell.dataset.ocCol === key);
146
- return td ? (td.dataset.ocValue || td.textContent.trim()) : "";
147
- };
148
- const matched = () => rows().filter((row) => rowPasses(row.dataset.ocStatus, filter, textOf(row), query));
149
-
150
- function paintChecks() {
151
- for (const row of rows()) {
152
- const on = selected.has(row.dataset.ocKey);
153
- row.setAttribute("aria-selected", on ? "true" : "false");
154
- const box = row.querySelector("[data-oc-select]");
155
- if (box) box.checked = on;
156
- }
157
- const keys = matched().map((row) => row.dataset.ocKey);
158
- const picked = keys.filter((key) => selected.has(key)).length;
159
- if (selectAll) {
160
- selectAll.checked = keys.length > 0 && picked === keys.length;
161
- selectAll.indeterminate = picked > 0 && picked < keys.length;
162
- }
163
- if (count) count.textContent = selected.size === 1 ? "1 selected" : `${selected.size} selected`;
164
- if (bulk) bulk.hidden = selected.size === 0;
165
- }
166
-
167
- function renderPager(current, last) {
168
- if (!pager) return;
169
- pager.replaceChildren();
170
- if (last <= 1) return;
171
- const add = (label, target, extra) => {
172
- const button = document.createElement("button");
173
- button.type = "button";
174
- button.className = "oc-data-table-page";
175
- button.textContent = label;
176
- button.dataset.ocPage = String(target);
177
- if (extra?.current) button.setAttribute("aria-current", "page");
178
- if (extra?.disabled) button.disabled = true;
179
- if (extra?.label) button.setAttribute("aria-label", extra.label);
180
- pager.append(button);
181
- };
182
- add("«", 1, { disabled: current === 1, label: "First page" });
183
- add("‹", current - 1, { disabled: current === 1, label: "Previous page" });
184
- for (const item of pageItems(current, last)) {
185
- if (item === "…") {
186
- const span = document.createElement("span");
187
- span.className = "oc-data-table-ellipsis";
188
- span.textContent = "…";
189
- pager.append(span);
190
- } else add(String(item), item, { current: item === current });
191
- }
192
- add("›", current + 1, { disabled: current === last, label: "Next page" });
193
- add("»", last, { disabled: current === last, label: "Last page" });
194
- }
195
-
196
- function syncGrid(list) {
197
- let grid = root.querySelector(".oc-data-table-grid");
198
- if (!grid && frame) {
199
- grid = document.createElement("div");
200
- grid.className = "oc-data-table-grid";
201
- frame.append(grid);
202
- }
203
- if (!grid) return;
204
- const onPage = new Set(list.map((row) => row.dataset.ocKey));
205
- grid.replaceChildren();
206
- for (const row of rows()) {
207
- if (!onPage.has(row.dataset.ocKey)) continue;
208
- const card = document.createElement("article");
209
- card.className = "oc-data-table-card";
210
- card.dataset.ocKey = row.dataset.ocKey;
211
- const title = document.createElement("h3");
212
- title.className = "oc-data-table-card-title";
213
- const name = [...row.cells].find((td) => td.dataset.ocCol === "name") || [...row.cells].find((td) => td.dataset.ocCol && !SKIP_COL.has(td.dataset.ocCol));
214
- title.textContent = name ? name.textContent.trim() : row.dataset.ocKey;
215
- card.append(title);
216
- grid.append(card);
217
- }
218
- }
219
-
220
- function apply() {
221
- const all = rows();
222
- const list = matched();
223
- if (sortKey) {
224
- const th = [...table.tHead.rows[0].cells].find((cell) => cell.dataset.ocCol === sortKey);
225
- const numeric = th?.dataset.ocSort === "numeric" || th?.hasAttribute("data-numeric");
226
- const dir = sortDir === "desc" ? -1 : 1;
227
- list.sort((a, b) => dir * compareValues(valueOf(a, sortKey), valueOf(b, sortKey), numeric));
228
- const rest = all.filter((row) => !list.includes(row));
229
- for (const row of [...list, ...rest]) tbody.append(row);
230
- }
231
- const last = pageSize > 0 ? Math.max(1, Math.ceil(list.length / pageSize)) : 1;
232
- if (page > last - 1) page = Math.max(0, last - 1);
233
- const start = pageSize > 0 ? page * pageSize : 0;
234
- const visible = new Set((pageSize > 0 ? list.slice(start, start + pageSize) : list));
235
- for (const row of all) row.hidden = !visible.has(row);
236
- if (empty) empty.hidden = list.length !== 0;
237
- if (summary) {
238
- summary.textContent = list.length === 0
239
- ? "Showing 0 of 0"
240
- : `Showing ${start + 1}–${start + visible.size} of ${list.length}`;
241
- }
242
- if (sortSummary) {
243
- const label = sortKey ? table.querySelector(`th[data-oc-col="${sortKey}"] .oc-table-sort`)?.textContent?.trim() : "";
244
- sortSummary.textContent = label ? `Sorted by ${label}, ${sortDir === "asc" ? "ascending" : "descending"}` : "";
245
- }
246
- renderPager(page + 1, list.length === 0 ? 1 : last);
247
- syncGrid([...visible]);
248
- paintChecks();
249
- }
250
-
251
- function exportCsv(onlyKeys) {
252
- const hidden = new Set([...root.querySelectorAll("[data-col]:not(:checked)")].map((box) => box.dataset.col));
253
- const heads = [...table.tHead.rows[0].cells].filter((th, index) => {
254
- return th.dataset.ocCol && !SKIP_COL.has(th.dataset.ocCol) && !hidden.has(String(index + 1));
255
- });
256
- const source = onlyKeys
257
- ? rows().filter((row) => onlyKeys.has(row.dataset.ocKey))
258
- : matched();
259
- const body = source.map((row) => heads.map((th) => valueOf(row, th.dataset.ocCol)));
260
- download(`${id}.csv`, toCsv(heads.map((th) => th.textContent.trim()), body));
261
- }
262
-
263
- function emit(name, detail) {
264
- root.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
265
- }
266
-
267
- // Live mode (the docs demo) changes the rows in the page. An app listens and updates its own data.
268
- function liveAction(action) {
269
- if (!live) return false;
270
- for (const key of selected) {
271
- const row = tbody.querySelector(`[data-oc-key="${CSS.escape(key)}"]`);
272
- if (!row) continue;
273
- if (action === "archive" && (row.dataset.ocStatus || "active") === "active") row.dataset.ocStatus = "deleted";
274
- if (action === "restore" && row.dataset.ocStatus === "deleted") row.dataset.ocStatus = "active";
275
- if (action === "delete") row.remove();
276
- if (action === "duplicate" && (row.dataset.ocStatus || "active") === "active") {
277
- const copy = row.cloneNode(true);
278
- copy.dataset.ocKey = `${key}-copy`;
279
- tbody.insertBefore(copy, row.nextSibling);
280
- }
281
- }
282
- selected.clear();
283
- apply();
284
- return true;
285
- }
286
-
287
- root.addEventListener("input", (event) => {
288
- const target = event.target;
289
- if (target === search) {
290
- query = target.value.trim().toLowerCase();
291
- page = 0;
292
- apply();
293
- }
294
- });
295
-
296
- root.addEventListener("change", (event) => {
297
- const target = event.target;
298
- if (target === pageSelect) {
299
- pageSize = Number(target.value) || 0;
300
- page = 0;
301
- writeStore(id, "pageSize", pageSize);
302
- apply();
303
- }
304
- if (target.matches?.("[data-col]")) {
305
- const cols = {};
306
- root.querySelectorAll("[data-col]").forEach((box) => { cols[box.dataset.col] = box.checked; });
307
- writeStore(id, "cols", cols);
308
- }
309
- if (target.matches?.("[data-oc-select]")) {
310
- const row = target.closest("[data-oc-row]");
311
- if (!row || row.dataset.ocSelectable === "false") return;
312
- if (target.checked) selected.add(row.dataset.ocKey);
313
- else selected.delete(row.dataset.ocKey);
314
- paintChecks();
315
- }
316
- if (target === selectAll) {
317
- for (const row of matched()) {
318
- if (row.dataset.ocSelectable === "false") continue;
319
- if (target.checked) selected.add(row.dataset.ocKey);
320
- else selected.delete(row.dataset.ocKey);
321
- }
322
- paintChecks();
323
- }
324
- if (target.matches?.("[data-oc-file]")) {
325
- const file = target.files?.[0];
326
- if (file) emit("oc-datatable-import", { file });
327
- target.value = "";
328
- }
329
- });
330
-
331
- root.addEventListener("keydown", (event) => {
332
- if (event.key === "Enter" && event.target === search) {
333
- emit("oc-datatable-search", { query: search.value });
334
- }
335
- });
336
-
337
- root.addEventListener("click", (event) => {
338
- const sortBtn = event.target.closest(".oc-table-sort");
339
- if (sortBtn && root.contains(sortBtn)) {
340
- const th = sortBtn.closest("th");
341
- if (!th?.dataset.ocCol) return;
342
- event.preventDefault();
343
- if (sortKey === th.dataset.ocCol) sortDir = sortDir === "asc" ? "desc" : "asc";
344
- else { sortKey = th.dataset.ocCol; sortDir = "asc"; }
345
- for (const head of table.tHead.rows[0].cells) head.removeAttribute("aria-sort");
346
- th.setAttribute("aria-sort", sortDir === "asc" ? "ascending" : "descending");
347
- page = 0;
348
- apply();
349
- return;
350
- }
351
- const filterBtn = event.target.closest("[data-oc-filter]");
352
- if (filterBtn && root.contains(filterBtn)) {
353
- filter = filterBtn.dataset.ocFilter;
354
- page = 0;
355
- root.querySelectorAll("[data-oc-filter]").forEach((btn) => {
356
- if (btn === filterBtn) btn.setAttribute("aria-current", "true");
357
- else btn.removeAttribute("aria-current");
358
- });
359
- apply();
360
- return;
361
- }
362
- const viewBtn = event.target.closest("[data-oc-view]");
363
- if (viewBtn && root.contains(viewBtn)) {
364
- const mode = viewBtn.dataset.ocView;
365
- root.classList.toggle("oc-data-table--grid", mode === "grid");
366
- root.querySelectorAll("[data-oc-view]").forEach((btn) => {
367
- btn.setAttribute("aria-pressed", btn === viewBtn ? "true" : "false");
368
- });
369
- writeStore(id, "view", mode);
370
- return;
371
- }
372
- const densityBtn = event.target.closest("[data-oc-density]");
373
- if (densityBtn && root.contains(densityBtn)) {
374
- const compact = densityBtn.dataset.ocDensity === "compact";
375
- root.classList.toggle("oc-data-table--compact", compact);
376
- writeStore(id, "density", compact ? "compact" : "comfortable");
377
- return;
378
- }
379
- const pageBtn = event.target.closest("[data-oc-page]");
380
- if (pageBtn && root.contains(pageBtn) && !pageBtn.disabled) {
381
- page = Number(pageBtn.dataset.ocPage) - 1;
382
- apply();
383
- return;
384
- }
385
- if (event.target.closest("[data-oc-export]")) {
386
- exportCsv(null);
387
- return;
388
- }
389
- if (event.target.closest("[data-oc-reset-widths]")) {
390
- for (const th of table.tHead.rows[0].cells) th.style.width = "";
391
- table.classList.remove("oc-data-table-table--sized");
392
- writeStore(id, "widths", {});
393
- return;
394
- }
395
- if (event.target.closest("[data-oc-import]")) {
396
- root.querySelector("[data-oc-file]")?.click();
397
- return;
398
- }
399
- const bulkBtn = event.target.closest("[data-oc-bulk]");
400
- if (bulkBtn && root.contains(bulkBtn)) {
401
- const action = bulkBtn.dataset.ocBulk;
402
- // Snapshot first: live mode clears the selection while it updates the rows.
403
- const keys = [...selected];
404
- if (action === "clear") { selected.clear(); paintChecks(); return; }
405
- if (action === "export") { exportCsv(selected); return; }
406
- liveAction(action);
407
- emit("oc-datatable-bulk", { action, keys });
408
- return;
409
- }
410
- // Row click opens data-oc-href, except when the click is on a control inside the row.
411
- if (event.target.closest("a, button, input, select, textarea, label")) return;
412
- const row = event.target.closest("[data-oc-row]");
413
- if (!row || !root.contains(row) || !row.dataset.ocHref) return;
414
- if (event.metaKey || event.ctrlKey) window.open(row.dataset.ocHref, "_blank", "noopener");
415
- else window.location.href = row.dataset.ocHref;
416
- });
417
-
418
- // Column resize: drag the handle on the header. Width is remembered per table id.
419
- root.addEventListener("pointerdown", (event) => {
420
- const handle = event.target.closest(".oc-data-table-resize");
421
- if (!handle || !root.contains(handle)) return;
422
- const th = handle.closest("th");
423
- if (!th) return;
424
- event.preventDefault();
425
- const startX = event.clientX;
426
- const startW = th.getBoundingClientRect().width;
427
- const min = Number(th.dataset.ocMin || 64);
428
- root.classList.add("is-resizing");
429
- table.classList.add("oc-data-table-table--sized");
430
- const move = (ev) => {
431
- const width = Math.max(min, Math.round(startW + ev.clientX - startX));
432
- th.style.width = `${width}px`;
433
- };
434
- const up = () => {
435
- root.classList.remove("is-resizing");
436
- window.removeEventListener("pointermove", move);
437
- window.removeEventListener("pointerup", up);
438
- const widths = {};
439
- for (const head of table.tHead.rows[0].cells) if (head.style.width) widths[head.dataset.ocCol] = head.style.width;
440
- writeStore(id, "widths", widths);
441
- };
442
- window.addEventListener("pointermove", move);
443
- window.addEventListener("pointerup", up);
444
- });
445
-
446
- // Row drag starts from the grip only, so selecting text in a cell still works.
447
- let dragKey = "";
448
- root.addEventListener("pointerdown", (event) => {
449
- const grip = event.target.closest(".oc-sortable-row-grip");
450
- const row = grip?.closest("[data-oc-row]");
451
- if (row && root.contains(row)) row.draggable = true;
452
- });
453
- root.addEventListener("dragstart", (event) => {
454
- const row = event.target.closest?.("[data-oc-row]");
455
- if (!row?.draggable) return;
456
- dragKey = row.dataset.ocKey;
457
- row.classList.add("is-dragging", "oc-sortable-row");
458
- event.dataTransfer.effectAllowed = "move";
459
- });
460
- root.addEventListener("dragover", (event) => {
461
- const row = event.target.closest?.("[data-oc-row]");
462
- if (!row || !dragKey || row.dataset.ocKey === dragKey) return;
463
- event.preventDefault();
464
- const box = row.getBoundingClientRect();
465
- const after = event.clientY > box.top + box.height / 2;
466
- tbody.querySelectorAll(".is-drop-before, .is-drop-after").forEach((el) => el.classList.remove("is-drop-before", "is-drop-after"));
467
- row.classList.add("oc-sortable-row", after ? "is-drop-after" : "is-drop-before");
468
- });
469
- root.addEventListener("drop", (event) => {
470
- const row = event.target.closest?.("[data-oc-row]");
471
- const from = dragKey ? tbody.querySelector(`[data-oc-key="${CSS.escape(dragKey)}"]`) : null;
472
- if (!row || !from || row === from) return;
473
- event.preventDefault();
474
- tbody.insertBefore(from, row.classList.contains("is-drop-after") ? row.nextSibling : row);
475
- emit("oc-datatable-reorder", { keys: rows().map((item) => item.dataset.ocKey) });
476
- apply();
477
- });
478
- root.addEventListener("dragend", () => {
479
- dragKey = "";
480
- for (const row of rows()) {
481
- row.draggable = false;
482
- row.classList.remove("is-dragging", "is-drop-before", "is-drop-after");
483
- }
484
- });
485
-
486
- root.querySelectorAll("[data-oc-view]").forEach((btn) => {
487
- const on = (btn.dataset.ocView === "grid") === root.classList.contains("oc-data-table--grid");
488
- btn.setAttribute("aria-pressed", on ? "true" : "false");
489
- });
490
-
491
- apply();
492
- controls.set(root, { apply });
493
- }
494
-
495
- /** Wire every table under scope that is not wired yet. */
496
- export function initDataTables(scope = document) {
497
- scope.querySelectorAll(".oc-data-table").forEach((root) => bindDataTable(root));
498
- }
499
-
500
- // The docs site loads this file as /js/oc-data-table.js. A wrapper import must not scan the page.
501
- if (typeof document !== "undefined" && String(import.meta.url).includes("oc-data-table")) {
502
- const boot = () => initDataTables(document);
503
- if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
504
- else boot();
505
- }
1
+ // File: packages/ui/src/data-table.js
2
+ // Map: page numbers -> compare -> csv -> row filter -> bind one table (search, sort, pages, columns, resize, drag, trash, bulk) -> boot
3
+ // The list table used in Korrekt and OmniaDesk. Markup stays plain HTML.
4
+ // This script adds the behaviour. Excel, PDF and the real delete API stay in the app:
5
+ // listen for oc-datatable-bulk, oc-datatable-reorder, oc-datatable-search and oc-datatable-import.
6
+
7
+ /** Page numbers with ellipses, same window as OmniaDesk (current and last are 1-based). */
8
+ export function pageItems(current, last) {
9
+ if (last < 1) return [];
10
+ if (last === 1) return [1];
11
+ const range = [];
12
+ for (let i = Math.max(2, current - 2); i <= Math.min(last - 1, current + 2); i++) range.push(i);
13
+ const out = [1];
14
+ if (range.length && range[0] > 2) out.push("…");
15
+ for (const n of range) out.push(n);
16
+ if (range.length && range[range.length - 1] < last - 1) out.push("…");
17
+ if (last > 1) out.push(last);
18
+ return out;
19
+ }
20
+
21
+ /** Sort two cell values. Numeric columns accept 5,15 and 5.15. */
22
+ export function compareValues(a, b, numeric) {
23
+ if (numeric) {
24
+ const n = (v) => Number(String(v ?? "").replace(/\s/g, "").replace(",", "."));
25
+ const na = n(a);
26
+ const nb = n(b);
27
+ if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
28
+ }
29
+ return String(a ?? "").localeCompare(String(b ?? ""), undefined, { numeric: true, sensitivity: "base" });
30
+ }
31
+
32
+ /** One CSV field. Quotes when the value contains a comma, quote or newline. */
33
+ function csvField(value) {
34
+ const s = String(value ?? "");
35
+ return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
36
+ }
37
+
38
+ /** Headers plus rows, as a CSV string the browser can download. */
39
+ export function toCsv(headers, rows) {
40
+ return [headers, ...rows].map((line) => line.map(csvField).join(",")).join("\n");
41
+ }
42
+
43
+ /**
44
+ * Does this row stay visible?
45
+ * filter is "active", "deleted", "archived" or "all". query is already lower-case.
46
+ */
47
+ export function rowPasses(status, filter, text, query) {
48
+ const state = status || "active";
49
+ if (filter === "active" && state !== "active") return false;
50
+ if (filter === "deleted" && state !== "deleted") return false;
51
+ if (filter === "archived" && state !== "archived") return false;
52
+ if (!query) return true;
53
+ return String(text).toLowerCase().includes(query);
54
+ }
55
+
56
+ const controls = new WeakMap();
57
+ const SKIP_COL = new Set(["select", "order", "actions"]);
58
+
59
+ function readStore(id, name) {
60
+ try {
61
+ return JSON.parse(localStorage.getItem(`oc-data-table:${id}:${name}`) || "null");
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function writeStore(id, name, value) {
68
+ try {
69
+ localStorage.setItem(`oc-data-table:${id}:${name}`, JSON.stringify(value));
70
+ } catch {
71
+ // Private mode can block storage. The table still works for this visit.
72
+ }
73
+ }
74
+
75
+ function download(filename, text) {
76
+ const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
77
+ const link = document.createElement("a");
78
+ link.href = url;
79
+ link.download = filename;
80
+ link.click();
81
+ URL.revokeObjectURL(url);
82
+ }
83
+
84
+ /** Wire one .oc-data-table. Calling it again only refreshes the rows. */
85
+ export function bindDataTable(root) {
86
+ if (!root) return;
87
+ const prev = controls.get(root);
88
+ if (prev) {
89
+ prev.apply();
90
+ return;
91
+ }
92
+
93
+ const table = root.querySelector("table");
94
+ const tbody = table?.tBodies[0];
95
+ if (!table || !tbody) return;
96
+
97
+ const id = root.id || "data-table";
98
+ const search = root.querySelector(".oc-data-table-search");
99
+ const summary = root.querySelector("[data-oc-summary]");
100
+ const pager = root.querySelector("[data-oc-pager]");
101
+ const pageSelect = root.querySelector("[data-oc-page-size]");
102
+ const selectAll = root.querySelector("[data-oc-select-all]");
103
+ const bulk = root.querySelector(".oc-data-table-bulk");
104
+ const count = root.querySelector("[data-oc-selected-count]");
105
+ const empty = root.querySelector(".oc-data-table-empty");
106
+ const sortSummary = root.querySelector("[data-oc-sort-summary]");
107
+ const frame = root.querySelector(".oc-data-table-frame") || table.parentElement;
108
+ const live = root.dataset.ocLive === "true";
109
+
110
+ let query = "";
111
+ let filter = root.querySelector("[data-oc-filter][aria-current='true']")?.dataset.ocFilter
112
+ || (root.querySelector("[data-oc-filter]") ? "active" : "all");
113
+ let sortKey = "";
114
+ let sortDir = "asc";
115
+ let page = 0;
116
+ let pageSize = Number(pageSelect?.value || root.dataset.pageSize || 15);
117
+ const selected = new Set();
118
+
119
+ // Remembered choices from the last visit: page size, density, view, column widths, hidden columns.
120
+ const savedSize = readStore(id, "pageSize");
121
+ if (savedSize && pageSelect?.querySelector(`option[value="${savedSize}"]`)) {
122
+ pageSelect.value = String(savedSize);
123
+ pageSize = Number(savedSize);
124
+ }
125
+ if (readStore(id, "density") === "compact") root.classList.add("oc-data-table--compact");
126
+ if (readStore(id, "view") === "grid") root.classList.add("oc-data-table--grid");
127
+ const savedWidths = readStore(id, "widths") || {};
128
+ for (const th of table.tHead.rows[0].cells) {
129
+ if (savedWidths[th.dataset.ocCol]) {
130
+ th.style.width = savedWidths[th.dataset.ocCol];
131
+ table.classList.add("oc-data-table-table--sized");
132
+ }
133
+ }
134
+ const savedCols = readStore(id, "cols") || {};
135
+ root.querySelectorAll("[data-col]").forEach((box) => {
136
+ if (!box.disabled && savedCols[box.dataset.col] != null) box.checked = savedCols[box.dataset.col];
137
+ });
138
+
139
+ const rows = () => [...tbody.querySelectorAll("[data-oc-row]")];
140
+ const textOf = (row) => [...row.cells]
141
+ .filter((td) => td.dataset.ocCol && !SKIP_COL.has(td.dataset.ocCol))
142
+ .map((td) => td.dataset.ocValue || td.textContent)
143
+ .join(" ");
144
+ const valueOf = (row, key) => {
145
+ const td = [...row.cells].find((cell) => cell.dataset.ocCol === key);
146
+ return td ? (td.dataset.ocValue || td.textContent.trim()) : "";
147
+ };
148
+ const matched = () => rows().filter((row) => rowPasses(row.dataset.ocStatus, filter, textOf(row), query));
149
+
150
+ function paintChecks() {
151
+ for (const row of rows()) {
152
+ const on = selected.has(row.dataset.ocKey);
153
+ row.setAttribute("aria-selected", on ? "true" : "false");
154
+ const box = row.querySelector("[data-oc-select]");
155
+ if (box) box.checked = on;
156
+ }
157
+ const keys = matched().map((row) => row.dataset.ocKey);
158
+ const picked = keys.filter((key) => selected.has(key)).length;
159
+ if (selectAll) {
160
+ selectAll.checked = keys.length > 0 && picked === keys.length;
161
+ selectAll.indeterminate = picked > 0 && picked < keys.length;
162
+ }
163
+ if (count) count.textContent = selected.size === 1 ? "1 selected" : `${selected.size} selected`;
164
+ if (bulk) bulk.hidden = selected.size === 0;
165
+ }
166
+
167
+ function renderPager(current, last) {
168
+ if (!pager) return;
169
+ pager.replaceChildren();
170
+ if (last <= 1) return;
171
+ const add = (label, target, extra) => {
172
+ const button = document.createElement("button");
173
+ button.type = "button";
174
+ button.className = "oc-data-table-page";
175
+ button.textContent = label;
176
+ button.dataset.ocPage = String(target);
177
+ if (extra?.current) button.setAttribute("aria-current", "page");
178
+ if (extra?.disabled) button.disabled = true;
179
+ if (extra?.label) button.setAttribute("aria-label", extra.label);
180
+ pager.append(button);
181
+ };
182
+ add("«", 1, { disabled: current === 1, label: "First page" });
183
+ add("‹", current - 1, { disabled: current === 1, label: "Previous page" });
184
+ for (const item of pageItems(current, last)) {
185
+ if (item === "…") {
186
+ const span = document.createElement("span");
187
+ span.className = "oc-data-table-ellipsis";
188
+ span.textContent = "…";
189
+ pager.append(span);
190
+ } else add(String(item), item, { current: item === current });
191
+ }
192
+ add("›", current + 1, { disabled: current === last, label: "Next page" });
193
+ add("»", last, { disabled: current === last, label: "Last page" });
194
+ }
195
+
196
+ function syncGrid(list) {
197
+ let grid = root.querySelector(".oc-data-table-grid");
198
+ if (!grid && frame) {
199
+ grid = document.createElement("div");
200
+ grid.className = "oc-data-table-grid";
201
+ frame.append(grid);
202
+ }
203
+ if (!grid) return;
204
+ const onPage = new Set(list.map((row) => row.dataset.ocKey));
205
+ grid.replaceChildren();
206
+ for (const row of rows()) {
207
+ if (!onPage.has(row.dataset.ocKey)) continue;
208
+ const card = document.createElement("article");
209
+ card.className = "oc-data-table-card";
210
+ card.dataset.ocKey = row.dataset.ocKey;
211
+ const title = document.createElement("h3");
212
+ title.className = "oc-data-table-card-title";
213
+ const name = [...row.cells].find((td) => td.dataset.ocCol === "name") || [...row.cells].find((td) => td.dataset.ocCol && !SKIP_COL.has(td.dataset.ocCol));
214
+ title.textContent = name ? name.textContent.trim() : row.dataset.ocKey;
215
+ card.append(title);
216
+ grid.append(card);
217
+ }
218
+ }
219
+
220
+ function apply() {
221
+ const all = rows();
222
+ const list = matched();
223
+ if (sortKey) {
224
+ const th = [...table.tHead.rows[0].cells].find((cell) => cell.dataset.ocCol === sortKey);
225
+ const numeric = th?.dataset.ocSort === "numeric" || th?.hasAttribute("data-numeric");
226
+ const dir = sortDir === "desc" ? -1 : 1;
227
+ list.sort((a, b) => dir * compareValues(valueOf(a, sortKey), valueOf(b, sortKey), numeric));
228
+ const rest = all.filter((row) => !list.includes(row));
229
+ for (const row of [...list, ...rest]) tbody.append(row);
230
+ }
231
+ const last = pageSize > 0 ? Math.max(1, Math.ceil(list.length / pageSize)) : 1;
232
+ if (page > last - 1) page = Math.max(0, last - 1);
233
+ const start = pageSize > 0 ? page * pageSize : 0;
234
+ const visible = new Set((pageSize > 0 ? list.slice(start, start + pageSize) : list));
235
+ for (const row of all) row.hidden = !visible.has(row);
236
+ if (empty) empty.hidden = list.length !== 0;
237
+ if (summary) {
238
+ summary.textContent = list.length === 0
239
+ ? "Showing 0 of 0"
240
+ : `Showing ${start + 1}–${start + visible.size} of ${list.length}`;
241
+ }
242
+ if (sortSummary) {
243
+ const label = sortKey ? table.querySelector(`th[data-oc-col="${sortKey}"] .oc-table-sort`)?.textContent?.trim() : "";
244
+ sortSummary.textContent = label ? `Sorted by ${label}, ${sortDir === "asc" ? "ascending" : "descending"}` : "";
245
+ }
246
+ renderPager(page + 1, list.length === 0 ? 1 : last);
247
+ syncGrid([...visible]);
248
+ paintChecks();
249
+ }
250
+
251
+ function exportCsv(onlyKeys) {
252
+ const hidden = new Set([...root.querySelectorAll("[data-col]:not(:checked)")].map((box) => box.dataset.col));
253
+ const heads = [...table.tHead.rows[0].cells].filter((th, index) => {
254
+ return th.dataset.ocCol && !SKIP_COL.has(th.dataset.ocCol) && !hidden.has(String(index + 1));
255
+ });
256
+ const source = onlyKeys
257
+ ? rows().filter((row) => onlyKeys.has(row.dataset.ocKey))
258
+ : matched();
259
+ const body = source.map((row) => heads.map((th) => valueOf(row, th.dataset.ocCol)));
260
+ download(`${id}.csv`, toCsv(heads.map((th) => th.textContent.trim()), body));
261
+ }
262
+
263
+ function emit(name, detail) {
264
+ root.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
265
+ }
266
+
267
+ // Live mode (the docs demo) changes the rows in the page. An app listens and updates its own data.
268
+ function liveAction(action) {
269
+ if (!live) return false;
270
+ for (const key of selected) {
271
+ const row = tbody.querySelector(`[data-oc-key="${CSS.escape(key)}"]`);
272
+ if (!row) continue;
273
+ if (action === "archive" && (row.dataset.ocStatus || "active") === "active") row.dataset.ocStatus = "deleted";
274
+ if (action === "restore" && row.dataset.ocStatus === "deleted") row.dataset.ocStatus = "active";
275
+ if (action === "delete") row.remove();
276
+ if (action === "duplicate" && (row.dataset.ocStatus || "active") === "active") {
277
+ const copy = row.cloneNode(true);
278
+ copy.dataset.ocKey = `${key}-copy`;
279
+ tbody.insertBefore(copy, row.nextSibling);
280
+ }
281
+ }
282
+ selected.clear();
283
+ apply();
284
+ return true;
285
+ }
286
+
287
+ root.addEventListener("input", (event) => {
288
+ const target = event.target;
289
+ if (target === search) {
290
+ query = target.value.trim().toLowerCase();
291
+ page = 0;
292
+ apply();
293
+ }
294
+ });
295
+
296
+ root.addEventListener("change", (event) => {
297
+ const target = event.target;
298
+ if (target === pageSelect) {
299
+ pageSize = Number(target.value) || 0;
300
+ page = 0;
301
+ writeStore(id, "pageSize", pageSize);
302
+ apply();
303
+ }
304
+ if (target.matches?.("[data-col]")) {
305
+ const cols = {};
306
+ root.querySelectorAll("[data-col]").forEach((box) => { cols[box.dataset.col] = box.checked; });
307
+ writeStore(id, "cols", cols);
308
+ }
309
+ if (target.matches?.("[data-oc-select]")) {
310
+ const row = target.closest("[data-oc-row]");
311
+ if (!row || row.dataset.ocSelectable === "false") return;
312
+ if (target.checked) selected.add(row.dataset.ocKey);
313
+ else selected.delete(row.dataset.ocKey);
314
+ paintChecks();
315
+ }
316
+ if (target === selectAll) {
317
+ for (const row of matched()) {
318
+ if (row.dataset.ocSelectable === "false") continue;
319
+ if (target.checked) selected.add(row.dataset.ocKey);
320
+ else selected.delete(row.dataset.ocKey);
321
+ }
322
+ paintChecks();
323
+ }
324
+ if (target.matches?.("[data-oc-file]")) {
325
+ const file = target.files?.[0];
326
+ if (file) emit("oc-datatable-import", { file });
327
+ target.value = "";
328
+ }
329
+ });
330
+
331
+ root.addEventListener("keydown", (event) => {
332
+ if (event.key === "Enter" && event.target === search) {
333
+ emit("oc-datatable-search", { query: search.value });
334
+ }
335
+ });
336
+
337
+ root.addEventListener("click", (event) => {
338
+ const sortBtn = event.target.closest(".oc-table-sort");
339
+ if (sortBtn && root.contains(sortBtn)) {
340
+ const th = sortBtn.closest("th");
341
+ if (!th?.dataset.ocCol) return;
342
+ event.preventDefault();
343
+ if (sortKey === th.dataset.ocCol) sortDir = sortDir === "asc" ? "desc" : "asc";
344
+ else { sortKey = th.dataset.ocCol; sortDir = "asc"; }
345
+ for (const head of table.tHead.rows[0].cells) head.removeAttribute("aria-sort");
346
+ th.setAttribute("aria-sort", sortDir === "asc" ? "ascending" : "descending");
347
+ page = 0;
348
+ apply();
349
+ return;
350
+ }
351
+ const filterBtn = event.target.closest("[data-oc-filter]");
352
+ if (filterBtn && root.contains(filterBtn)) {
353
+ filter = filterBtn.dataset.ocFilter;
354
+ page = 0;
355
+ root.querySelectorAll("[data-oc-filter]").forEach((btn) => {
356
+ if (btn === filterBtn) btn.setAttribute("aria-current", "true");
357
+ else btn.removeAttribute("aria-current");
358
+ });
359
+ apply();
360
+ return;
361
+ }
362
+ const viewBtn = event.target.closest("[data-oc-view]");
363
+ if (viewBtn && root.contains(viewBtn)) {
364
+ const mode = viewBtn.dataset.ocView;
365
+ root.classList.toggle("oc-data-table--grid", mode === "grid");
366
+ root.querySelectorAll("[data-oc-view]").forEach((btn) => {
367
+ btn.setAttribute("aria-pressed", btn === viewBtn ? "true" : "false");
368
+ });
369
+ writeStore(id, "view", mode);
370
+ return;
371
+ }
372
+ const densityBtn = event.target.closest("[data-oc-density]");
373
+ if (densityBtn && root.contains(densityBtn)) {
374
+ const compact = densityBtn.dataset.ocDensity === "compact";
375
+ root.classList.toggle("oc-data-table--compact", compact);
376
+ writeStore(id, "density", compact ? "compact" : "comfortable");
377
+ return;
378
+ }
379
+ const pageBtn = event.target.closest("[data-oc-page]");
380
+ if (pageBtn && root.contains(pageBtn) && !pageBtn.disabled) {
381
+ page = Number(pageBtn.dataset.ocPage) - 1;
382
+ apply();
383
+ return;
384
+ }
385
+ if (event.target.closest("[data-oc-export]")) {
386
+ exportCsv(null);
387
+ return;
388
+ }
389
+ if (event.target.closest("[data-oc-reset-widths]")) {
390
+ for (const th of table.tHead.rows[0].cells) th.style.width = "";
391
+ table.classList.remove("oc-data-table-table--sized");
392
+ writeStore(id, "widths", {});
393
+ return;
394
+ }
395
+ if (event.target.closest("[data-oc-import]")) {
396
+ root.querySelector("[data-oc-file]")?.click();
397
+ return;
398
+ }
399
+ const bulkBtn = event.target.closest("[data-oc-bulk]");
400
+ if (bulkBtn && root.contains(bulkBtn)) {
401
+ const action = bulkBtn.dataset.ocBulk;
402
+ // Snapshot first: live mode clears the selection while it updates the rows.
403
+ const keys = [...selected];
404
+ if (action === "clear") { selected.clear(); paintChecks(); return; }
405
+ if (action === "export") { exportCsv(selected); return; }
406
+ liveAction(action);
407
+ emit("oc-datatable-bulk", { action, keys });
408
+ return;
409
+ }
410
+ // Row click opens data-oc-href, except when the click is on a control inside the row.
411
+ if (event.target.closest("a, button, input, select, textarea, label")) return;
412
+ const row = event.target.closest("[data-oc-row]");
413
+ if (!row || !root.contains(row) || !row.dataset.ocHref) return;
414
+ if (event.metaKey || event.ctrlKey) window.open(row.dataset.ocHref, "_blank", "noopener");
415
+ else window.location.href = row.dataset.ocHref;
416
+ });
417
+
418
+ // Column resize: drag the handle on the header. Width is remembered per table id.
419
+ root.addEventListener("pointerdown", (event) => {
420
+ const handle = event.target.closest(".oc-data-table-resize");
421
+ if (!handle || !root.contains(handle)) return;
422
+ const th = handle.closest("th");
423
+ if (!th) return;
424
+ event.preventDefault();
425
+ const startX = event.clientX;
426
+ const startW = th.getBoundingClientRect().width;
427
+ const min = Number(th.dataset.ocMin || 64);
428
+ root.classList.add("is-resizing");
429
+ table.classList.add("oc-data-table-table--sized");
430
+ const move = (ev) => {
431
+ const width = Math.max(min, Math.round(startW + ev.clientX - startX));
432
+ th.style.width = `${width}px`;
433
+ };
434
+ const up = () => {
435
+ root.classList.remove("is-resizing");
436
+ window.removeEventListener("pointermove", move);
437
+ window.removeEventListener("pointerup", up);
438
+ const widths = {};
439
+ for (const head of table.tHead.rows[0].cells) if (head.style.width) widths[head.dataset.ocCol] = head.style.width;
440
+ writeStore(id, "widths", widths);
441
+ };
442
+ window.addEventListener("pointermove", move);
443
+ window.addEventListener("pointerup", up);
444
+ });
445
+
446
+ // Row drag starts from the grip only, so selecting text in a cell still works.
447
+ let dragKey = "";
448
+ root.addEventListener("pointerdown", (event) => {
449
+ const grip = event.target.closest(".oc-sortable-row-grip");
450
+ const row = grip?.closest("[data-oc-row]");
451
+ if (row && root.contains(row)) row.draggable = true;
452
+ });
453
+ root.addEventListener("dragstart", (event) => {
454
+ const row = event.target.closest?.("[data-oc-row]");
455
+ if (!row?.draggable) return;
456
+ dragKey = row.dataset.ocKey;
457
+ row.classList.add("is-dragging", "oc-sortable-row");
458
+ event.dataTransfer.effectAllowed = "move";
459
+ });
460
+ root.addEventListener("dragover", (event) => {
461
+ const row = event.target.closest?.("[data-oc-row]");
462
+ if (!row || !dragKey || row.dataset.ocKey === dragKey) return;
463
+ event.preventDefault();
464
+ const box = row.getBoundingClientRect();
465
+ const after = event.clientY > box.top + box.height / 2;
466
+ tbody.querySelectorAll(".is-drop-before, .is-drop-after").forEach((el) => el.classList.remove("is-drop-before", "is-drop-after"));
467
+ row.classList.add("oc-sortable-row", after ? "is-drop-after" : "is-drop-before");
468
+ });
469
+ root.addEventListener("drop", (event) => {
470
+ const row = event.target.closest?.("[data-oc-row]");
471
+ const from = dragKey ? tbody.querySelector(`[data-oc-key="${CSS.escape(dragKey)}"]`) : null;
472
+ if (!row || !from || row === from) return;
473
+ event.preventDefault();
474
+ tbody.insertBefore(from, row.classList.contains("is-drop-after") ? row.nextSibling : row);
475
+ emit("oc-datatable-reorder", { keys: rows().map((item) => item.dataset.ocKey) });
476
+ apply();
477
+ });
478
+ root.addEventListener("dragend", () => {
479
+ dragKey = "";
480
+ for (const row of rows()) {
481
+ row.draggable = false;
482
+ row.classList.remove("is-dragging", "is-drop-before", "is-drop-after");
483
+ }
484
+ });
485
+
486
+ root.querySelectorAll("[data-oc-view]").forEach((btn) => {
487
+ const on = (btn.dataset.ocView === "grid") === root.classList.contains("oc-data-table--grid");
488
+ btn.setAttribute("aria-pressed", on ? "true" : "false");
489
+ });
490
+
491
+ apply();
492
+ controls.set(root, { apply });
493
+ }
494
+
495
+ /** Wire every table under scope that is not wired yet. */
496
+ export function initDataTables(scope = document) {
497
+ scope.querySelectorAll(".oc-data-table").forEach((root) => bindDataTable(root));
498
+ }
499
+
500
+ // The docs site loads this file as /js/oc-data-table.js. A wrapper import must not scan the page.
501
+ if (typeof document !== "undefined" && String(import.meta.url).includes("oc-data-table")) {
502
+ const boot = () => initDataTables(document);
503
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
504
+ else boot();
505
+ }