@birdycom/omniaui 0.12.5 → 0.13.0

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 (102) hide show
  1. package/README.md +1 -1
  2. package/bin/lib/copy-js.mjs +12 -1
  3. package/bin/omniaui.mjs +2 -2
  4. package/dist/barcode.d.ts +15 -0
  5. package/dist/barcode.js +164 -0
  6. package/dist/components-extra.css +15 -1
  7. package/dist/components.css +2237 -256
  8. package/dist/components.min.css +1 -1
  9. package/dist/contact-form.d.ts +5 -0
  10. package/dist/contact-form.js +138 -0
  11. package/dist/data-table.js +135 -98
  12. package/dist/fly.d.ts +4 -0
  13. package/dist/fly.js +80 -0
  14. package/dist/form-extras.d.ts +11 -0
  15. package/dist/form-extras.js +214 -0
  16. package/dist/lazy.d.ts +5 -0
  17. package/dist/lazy.js +37 -0
  18. package/dist/oc-theme.css +3 -1
  19. package/dist/parts/app-data-table-footer.css +38 -0
  20. package/dist/parts/app-data-table-rich.css +161 -0
  21. package/dist/parts/app-data-table.css +44 -74
  22. package/dist/parts/app-name-card.css +65 -0
  23. package/dist/parts/app-result.css +39 -0
  24. package/dist/parts/app-titlebar.css +16 -8
  25. package/dist/parts/data-meter-row.css +40 -0
  26. package/dist/parts/data-price.css +20 -0
  27. package/dist/parts/data-ribbon.css +76 -0
  28. package/dist/parts/data-spec-list.css +36 -0
  29. package/dist/parts/data-stock.css +41 -0
  30. package/dist/parts/forms-affix-input.css +1 -1
  31. package/dist/parts/forms-avatar-picker.css +43 -0
  32. package/dist/parts/forms-date-field.css +53 -0
  33. package/dist/parts/forms-density.css +13 -0
  34. package/dist/parts/forms-form-section.css +64 -0
  35. package/dist/parts/forms-form.css +76 -0
  36. package/dist/parts/forms-quantity.css +55 -0
  37. package/dist/parts/forms-rating.css +81 -0
  38. package/dist/parts/media-attachment.css +1 -1
  39. package/dist/parts/media-barcode.css +23 -0
  40. package/dist/parts/media-chat-composer.css +3 -0
  41. package/dist/parts/media-emoji-picker.css +3 -1
  42. package/dist/parts/media-gallery.css +55 -0
  43. package/dist/parts/media-glass-liquid.css +99 -0
  44. package/dist/parts/media-glass-options.css +50 -0
  45. package/dist/parts/media-lazy.css +46 -0
  46. package/dist/parts/media-lightbox.css +4 -0
  47. package/dist/parts/media-message-thread.css +1 -1
  48. package/dist/parts/media-message.css +1 -1
  49. package/dist/parts/nav-topbar-variants.css +96 -0
  50. package/dist/parts/site-base.css +33 -0
  51. package/dist/parts/site-brand-mark.css +56 -0
  52. package/dist/parts/site-cards.css +183 -0
  53. package/dist/parts/site-fly.css +40 -0
  54. package/dist/parts/site-header-variants.css +70 -0
  55. package/dist/parts/site-layout.css +69 -0
  56. package/dist/parts/site-panel.css +29 -0
  57. package/dist/parts/site-prose.css +13 -1
  58. package/dist/parts/utility-hover.css +44 -0
  59. package/dist/showcase.css +114 -22
  60. package/manifest.json +193 -9
  61. package/package.json +7 -2
  62. package/snippets/accordion-accent.html +1 -1
  63. package/snippets/attachment.html +4 -4
  64. package/snippets/avatar-picker.html +40 -0
  65. package/snippets/barcode.html +20 -0
  66. package/snippets/brand-mark.html +41 -0
  67. package/snippets/chat-composer.html +3 -3
  68. package/snippets/data-table.html +51 -76
  69. package/snippets/date-field.html +44 -0
  70. package/snippets/doc-viewer.html +1 -1
  71. package/snippets/emoji-picker.html +1 -1
  72. package/snippets/feature-card.html +52 -0
  73. package/snippets/fly-in.html +30 -0
  74. package/snippets/form-density.html +25 -0
  75. package/snippets/form-section.html +47 -0
  76. package/snippets/gallery.html +25 -0
  77. package/snippets/hover-effects.html +19 -0
  78. package/snippets/icon-list.html +28 -0
  79. package/snippets/lazy.html +39 -0
  80. package/snippets/message-thread.html +12 -12
  81. package/snippets/message.html +10 -10
  82. package/snippets/meter-row.html +29 -0
  83. package/snippets/name-card.html +72 -0
  84. package/snippets/page-header.html +1 -1
  85. package/snippets/page-sections.html +50 -0
  86. package/snippets/panel.html +18 -0
  87. package/snippets/price.html +25 -0
  88. package/snippets/prose.html +3 -3
  89. package/snippets/quantity.html +12 -0
  90. package/snippets/rating.html +26 -0
  91. package/snippets/result.html +31 -0
  92. package/snippets/ribbon.html +48 -0
  93. package/snippets/shot-grid.html +27 -0
  94. package/snippets/sidebar.html +3 -3
  95. package/snippets/spec-list.html +34 -0
  96. package/snippets/stock.html +20 -0
  97. package/snippets/table-actions.html +5 -5
  98. package/snippets/table.html +2 -2
  99. package/snippets/titlebar.html +1 -1
  100. package/snippets/user.html +3 -3
  101. package/dist/parts/app-column-menu.css +0 -85
  102. package/snippets/column-menu.html +0 -38
@@ -0,0 +1,5 @@
1
+ // File: packages/ui/src/contact-form.d.ts
2
+ // Types for the contact form script.
3
+
4
+ export function initContactForm(form: HTMLFormElement): void;
5
+ export function initContactForms(scope?: ParentNode): void;
@@ -0,0 +1,138 @@
1
+ // File: packages/ui/src/contact-form.js
2
+ // Map: messages -> field errors -> busy -> send (fetch, JSON) -> result / errors -> "send another" -> boot
3
+ // Contact form without a page reload. The form keeps working without this script (POST + redirect); with it, it sends
4
+ // with fetch and shows the result or the errors in place.
5
+ // Protocol: POST the FormData to form.action with "Accept: application/json"; the server answers
6
+ // { sent: true } (200) or { sent: false, errors: { field: "message", form?: "message" } } (422); a list per field
7
+ // (Laravel: { errors: { email: ["…"] } }) shows its first message.
8
+ // Markup (data attributes, all inside [data-contact-root] or the document):
9
+ // form[data-contact-form] the form (novalidate); [data-contact-submit] its button, [data-contact-label] the text
10
+ // [data-contact-body] what hides after sending (title, intro, alert, form)
11
+ // [data-contact-success] the .oc-result that shows after sending (hidden, role="status", tabindex="-1")
12
+ // [data-contact-alert] the .oc-alert above the form (hidden), for errors.form or the general message
13
+ // [data-contact-again] the "Send another message" link or button in the result
14
+ // Field errors: aria-invalid="true" + aria-describedby on the field and <p class="oc-field-error" id="err-<name>">.
15
+ // Texts: data-busy-label, data-error-fields, data-error-network on the form (English by default).
16
+
17
+ const TEXT = {
18
+ busy: "Sending…",
19
+ fields: "Please check the fields in red.",
20
+ network: "Sending failed. Check your connection, or mail or call us directly.",
21
+ };
22
+
23
+ function clearFieldError(field) {
24
+ field.removeAttribute("aria-invalid");
25
+ field.removeAttribute("aria-describedby");
26
+ field.ownerDocument.getElementById(`err-${field.name}`)?.remove();
27
+ }
28
+
29
+ function setFieldError(field, message) {
30
+ const error = field.ownerDocument.createElement("p");
31
+ error.className = "oc-field-error";
32
+ error.id = `err-${field.name}`;
33
+ error.textContent = message;
34
+ field.setAttribute("aria-invalid", "true");
35
+ field.setAttribute("aria-describedby", error.id);
36
+ field.insertAdjacentElement("afterend", error);
37
+ }
38
+
39
+ /** Send one form[data-contact-form] with fetch and show the result or the errors in place. */
40
+ export function initContactForm(form) {
41
+ if (!form || form.dataset.ocBound !== undefined || typeof fetch === "undefined") return;
42
+ form.dataset.ocBound = "";
43
+ const root = form.closest("[data-contact-root]") || form.ownerDocument;
44
+ const body = root.querySelector("[data-contact-body]");
45
+ const success = root.querySelector("[data-contact-success]");
46
+ const alertBox = root.querySelector("[data-contact-alert]");
47
+ const button = form.querySelector("[data-contact-submit]") || form.querySelector('[type="submit"]');
48
+ const label = form.querySelector("[data-contact-label]");
49
+ const idleLabel = label?.textContent ?? "";
50
+ const text = {
51
+ busy: form.dataset.busyLabel || TEXT.busy,
52
+ fields: form.dataset.errorFields || TEXT.fields,
53
+ network: form.dataset.errorNetwork || TEXT.network,
54
+ };
55
+
56
+ const showAlert = (message) => {
57
+ if (!alertBox) return;
58
+ alertBox.textContent = message;
59
+ alertBox.hidden = message === "";
60
+ };
61
+
62
+ const setBusy = (busy) => {
63
+ if (button) button.disabled = busy;
64
+ form.setAttribute("aria-busy", String(busy));
65
+ if (label) label.textContent = busy ? text.busy : idleLabel;
66
+ };
67
+
68
+ // A field you change loses its error at once.
69
+ form.addEventListener("input", (event) => {
70
+ if (event.target.name) clearFieldError(event.target);
71
+ });
72
+
73
+ form.addEventListener("submit", async (event) => {
74
+ event.preventDefault();
75
+ form.querySelectorAll("[aria-invalid]").forEach(clearFieldError);
76
+ showAlert("");
77
+ setBusy(true);
78
+ try {
79
+ const response = await fetch(form.action, {
80
+ method: (form.getAttribute("method") || "POST").toUpperCase(),
81
+ body: new FormData(form),
82
+ headers: { Accept: "application/json" },
83
+ });
84
+ const result = await response.json();
85
+ if (result.sent) {
86
+ form.reset();
87
+ if (body) body.hidden = true;
88
+ if (success) {
89
+ success.hidden = false;
90
+ success.focus({ preventScroll: true });
91
+ success.scrollIntoView?.({ behavior: "smooth", block: "center" });
92
+ }
93
+ form.dispatchEvent(new CustomEvent("oc-contact-sent", { bubbles: true }));
94
+ return;
95
+ }
96
+ // Laravel sends a list per field ({ email: ["…"] }): the first message is shown.
97
+ const first = (message) => (Array.isArray(message) ? message[0] : message);
98
+ const errors = result.errors || {};
99
+ for (const [name, message] of Object.entries(errors)) {
100
+ const field = form.elements.namedItem(name);
101
+ if (field instanceof HTMLElement && "name" in field) setFieldError(field, first(message));
102
+ }
103
+ showAlert(first(errors.form) || text.fields);
104
+ (form.querySelector('[aria-invalid="true"]') || alertBox)?.focus?.();
105
+ } catch {
106
+ showAlert(text.network);
107
+ } finally {
108
+ setBusy(false);
109
+ }
110
+ });
111
+
112
+ // "Send another message": the form comes back without a reload.
113
+ root.querySelector("[data-contact-again]")?.addEventListener("click", (event) => {
114
+ event.preventDefault();
115
+ if (success) success.hidden = true;
116
+ if (body) body.hidden = false;
117
+ form.querySelector("input:not([type=hidden]):not([tabindex='-1']), textarea, select")?.focus();
118
+ });
119
+ }
120
+
121
+ /** Bind every contact form under scope. */
122
+ export function initContactForms(scope = document) {
123
+ scope.querySelectorAll("form[data-contact-form]").forEach(initContactForm);
124
+ }
125
+
126
+ // Starts on its own as /js/oc-contact-form.js (`npx omniaui copy-js`), with ?auto in the URL or data-oc-auto on the tag.
127
+ function autoStart() {
128
+ if (typeof document === "undefined") return false;
129
+ const url = String(import.meta.url);
130
+ if (url.includes("oc-contact-form") || /[?&]auto(=|&|$)/.test(url)) return true;
131
+ const file = url.split(/[?#]/)[0];
132
+ return [...document.querySelectorAll("script[data-oc-auto][src]")].some((s) => s.src.split(/[?#]/)[0] === file);
133
+ }
134
+ if (autoStart()) {
135
+ const boot = () => initContactForms(document);
136
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
137
+ else boot();
138
+ }
@@ -1,10 +1,11 @@
1
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.
2
+ // Map: page numbers -> compare -> csv -> row filter -> bind one table (search, column filters, sort, pages, columns, trash,
3
+ // bulk, cards) -> boot. Column width and row drag are OmniaUI Pro (data-table-pro.js).
4
+ // The list table for business apps. Markup stays plain HTML.
4
5
  // 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
+ // listen for oc-datatable-bulk, oc-datatable-row, oc-datatable-search and oc-datatable-import.
6
7
 
7
- /** Page numbers with ellipses, same window as OmniaDesk (current and last are 1-based). */
8
+ /** Page numbers with ellipses, a window around the current page (current and last are 1-based). */
8
9
  export function pageItems(current, last) {
9
10
  if (last < 1) return [];
10
11
  if (last === 1) return [1];
@@ -110,13 +111,20 @@ export function bindDataTable(root) {
110
111
  let query = "";
111
112
  let filter = root.querySelector("[data-oc-filter][aria-current='true']")?.dataset.ocFilter
112
113
  || (root.querySelector("[data-oc-filter]") ? "active" : "all");
113
- let sortKey = "";
114
- let sortDir = "asc";
114
+ // Column filters: a <select data-oc-col-filter="type"> or tab buttons with data-oc-col-filter="type" data-oc-value="…".
115
+ // A row passes when its cell in that column has that value (data-oc-value or its text); "" means all.
116
+ const colFilters = new Map();
117
+ root.querySelectorAll("select[data-oc-col-filter]").forEach((sel) => colFilters.set(sel.dataset.ocColFilter, sel.value));
118
+ root.querySelectorAll("[data-oc-col-filter][aria-selected='true']").forEach((tab) => colFilters.set(tab.dataset.ocColFilter, tab.dataset.ocValue ?? ""));
119
+ // aria-sort on a header in the HTML: that column is sorted when the table starts.
120
+ const sortedTh = [...table.tHead.rows[0].cells].find((th) => th.dataset.ocCol && th.hasAttribute("aria-sort"));
121
+ let sortKey = sortedTh ? sortedTh.dataset.ocCol : "";
122
+ let sortDir = sortedTh?.getAttribute("aria-sort") === "descending" ? "desc" : "asc";
115
123
  let page = 0;
116
124
  let pageSize = Number(pageSelect?.value || root.dataset.pageSize || 15);
117
125
  const selected = new Set();
118
126
 
119
- // Remembered choices from the last visit: page size, density, view, column widths, hidden columns.
127
+ // Remembered choices from the last visit: page size, density, view, hidden columns.
120
128
  const savedSize = readStore(id, "pageSize");
121
129
  if (savedSize && pageSelect?.querySelector(`option[value="${savedSize}"]`)) {
122
130
  pageSelect.value = String(savedSize);
@@ -124,19 +132,30 @@ export function bindDataTable(root) {
124
132
  }
125
133
  if (readStore(id, "density") === "compact") root.classList.add("oc-data-table--compact");
126
134
  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
135
  const savedCols = readStore(id, "cols") || {};
135
136
  root.querySelectorAll("[data-col]").forEach((box) => {
136
137
  if (!box.disabled && savedCols[box.dataset.col] != null) box.checked = savedCols[box.dataset.col];
137
138
  });
138
139
 
139
140
  const rows = () => [...tbody.querySelectorAll("[data-oc-row]")];
141
+ // Move a row to the end without taking it out of the page (Element.moveBefore), so <iconify-icon> and other web
142
+ // components keep their content; where moveBefore is missing, append and let the icons draw again.
143
+ // <iconify-icon> empties itself while its row is hidden (another page, another filter) and does not always draw
144
+ // again when the row comes back: set its icon again so it renders.
145
+ const redrawIcons = (row) => {
146
+ row.querySelectorAll("iconify-icon[icon]").forEach((icon) => {
147
+ if (icon.shadowRoot?.querySelector("svg")) return;
148
+ const name = icon.getAttribute("icon");
149
+ icon.removeAttribute("icon");
150
+ icon.setAttribute("icon", name);
151
+ });
152
+ };
153
+ const moveRow = (row) => {
154
+ if (tbody.lastElementChild === row) return;
155
+ if (typeof tbody.moveBefore === "function") { tbody.moveBefore(row, null); return; }
156
+ tbody.append(row);
157
+ redrawIcons(row);
158
+ };
140
159
  const textOf = (row) => [...row.cells]
141
160
  .filter((td) => td.dataset.ocCol && !SKIP_COL.has(td.dataset.ocCol))
142
161
  .map((td) => td.dataset.ocValue || td.textContent)
@@ -145,7 +164,8 @@ export function bindDataTable(root) {
145
164
  const td = [...row.cells].find((cell) => cell.dataset.ocCol === key);
146
165
  return td ? (td.dataset.ocValue || td.textContent.trim()) : "";
147
166
  };
148
- const matched = () => rows().filter((row) => rowPasses(row.dataset.ocStatus, filter, textOf(row), query));
167
+ const passesColumns = (row) => [...colFilters].every(([key, want]) => !want || valueOf(row, key) === want);
168
+ const matched = () => rows().filter((row) => rowPasses(row.dataset.ocStatus, filter, textOf(row), query) && passesColumns(row));
149
169
 
150
170
  function paintChecks() {
151
171
  for (const row of rows()) {
@@ -202,17 +222,51 @@ export function bindDataTable(root) {
202
222
  }
203
223
  if (!grid) return;
204
224
  const onPage = new Set(list.map((row) => row.dataset.ocKey));
225
+ const heads = [...table.tHead.rows[0].cells];
226
+ const hiddenCols = new Set([...root.querySelectorAll("[data-col]:not(:checked)")].map((box) => box.dataset.col));
205
227
  grid.replaceChildren();
206
228
  for (const row of rows()) {
207
229
  if (!onPage.has(row.dataset.ocKey)) continue;
208
230
  const card = document.createElement("article");
209
231
  card.className = "oc-data-table-card";
210
232
  card.dataset.ocKey = row.dataset.ocKey;
211
- const title = document.createElement("h3");
233
+ const cells = [...row.cells];
234
+ const name = cells.find((td) => td.dataset.ocCol === "name") || cells.find((td) => td.dataset.ocCol && !SKIP_COL.has(td.dataset.ocCol));
235
+ // Title: the rich name cell (avatar, badge, second line) when there is one, else its text.
236
+ const title = document.createElement("div");
212
237
  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;
238
+ if (name?.querySelector(".oc-data-table-name")) title.append(name.querySelector(".oc-data-table-name").cloneNode(true));
239
+ else title.textContent = name ? name.textContent.trim() : row.dataset.ocKey;
215
240
  card.append(title);
241
+ // The other visible columns as label / value.
242
+ const fields = document.createElement("dl");
243
+ fields.className = "oc-data-table-card-fields";
244
+ cells.forEach((td, index) => {
245
+ const col = td.dataset.ocCol;
246
+ if (!col || td === name || SKIP_COL.has(col) || hiddenCols.has(String(index + 1))) return;
247
+ const dt = document.createElement("dt");
248
+ dt.textContent = heads[index]?.textContent.trim() || col;
249
+ const dd = document.createElement("dd");
250
+ dd.append(...[...td.childNodes].map((node) => node.cloneNode(true)));
251
+ if (dd.textContent.trim()) fields.append(dt, dd);
252
+ });
253
+ if (fields.childElementCount) card.append(fields);
254
+ // Row actions (buttons and links; popover ids are not copied).
255
+ const actions = cells.find((td) => td.dataset.ocCol === "actions");
256
+ if (actions?.children.length) {
257
+ const bar = document.createElement("div");
258
+ bar.className = "oc-data-table-card-actions";
259
+ const trashed = row.dataset.ocStatus === "deleted";
260
+ for (const el of actions.querySelectorAll("button, a")) {
261
+ const when = el.closest("[data-oc-when]")?.dataset.ocWhen;
262
+ if (when && (when === "deleted") !== trashed) continue;
263
+ const copy = el.cloneNode(true);
264
+ copy.removeAttribute("id");
265
+ copy.removeAttribute("popovertarget");
266
+ bar.append(copy);
267
+ }
268
+ card.append(bar);
269
+ }
216
270
  grid.append(card);
217
271
  }
218
272
  }
@@ -226,18 +280,23 @@ export function bindDataTable(root) {
226
280
  const dir = sortDir === "desc" ? -1 : 1;
227
281
  list.sort((a, b) => dir * compareValues(valueOf(a, sortKey), valueOf(b, sortKey), numeric));
228
282
  const rest = all.filter((row) => !list.includes(row));
229
- for (const row of [...list, ...rest]) tbody.append(row);
283
+ for (const row of [...list, ...rest]) moveRow(row);
230
284
  }
231
285
  const last = pageSize > 0 ? Math.max(1, Math.ceil(list.length / pageSize)) : 1;
232
286
  if (page > last - 1) page = Math.max(0, last - 1);
233
287
  const start = pageSize > 0 ? page * pageSize : 0;
234
288
  const visible = new Set((pageSize > 0 ? list.slice(start, start + pageSize) : list));
235
- for (const row of all) row.hidden = !visible.has(row);
289
+ for (const row of all) {
290
+ const shown = visible.has(row);
291
+ if (row.hidden && shown) queueMicrotask(() => redrawIcons(row));
292
+ row.hidden = !shown;
293
+ }
236
294
  if (empty) empty.hidden = list.length !== 0;
237
295
  if (summary) {
238
- summary.textContent = list.length === 0
239
- ? "Showing 0 of 0"
240
- : `Showing ${start + 1}–${start + visible.size} of ${list.length}`;
296
+ // data-oc-summary="Toont {from} t/m {to} van {total} resultaten" translates the line; empty = English.
297
+ const template = summary.dataset.ocSummary || "Showing {from}–{to} of {total}";
298
+ const values = { from: list.length ? start + 1 : 0, to: start + visible.size, total: list.length };
299
+ summary.textContent = template.replace(/\{(from|to|total)\}/g, (_, key) => String(values[key]));
241
300
  }
242
301
  if (sortSummary) {
243
302
  const label = sortKey ? table.querySelector(`th[data-oc-col="${sortKey}"] .oc-table-sort`)?.textContent?.trim() : "";
@@ -301,6 +360,11 @@ export function bindDataTable(root) {
301
360
  writeStore(id, "pageSize", pageSize);
302
361
  apply();
303
362
  }
363
+ if (target.matches?.("select[data-oc-col-filter]")) {
364
+ colFilters.set(target.dataset.ocColFilter, target.value);
365
+ page = 0;
366
+ apply();
367
+ }
304
368
  if (target.matches?.("[data-col]")) {
305
369
  const cols = {};
306
370
  root.querySelectorAll("[data-col]").forEach((box) => { cols[box.dataset.col] = box.checked; });
@@ -335,7 +399,10 @@ export function bindDataTable(root) {
335
399
  });
336
400
 
337
401
  root.addEventListener("click", (event) => {
338
- const sortBtn = event.target.closest(".oc-table-sort");
402
+ // The whole header cell sorts, not only its label (wide columns leave a lot of empty cell around the label).
403
+ const headCell = event.target.closest("thead th");
404
+ const sortBtn = event.target.closest(".oc-table-sort")
405
+ || (headCell && !event.target.closest("a, button, input, select, label, .oc-data-table-resize") ? headCell.querySelector(".oc-table-sort") : null);
339
406
  if (sortBtn && root.contains(sortBtn)) {
340
407
  const th = sortBtn.closest("th");
341
408
  if (!th?.dataset.ocCol) return;
@@ -359,6 +426,40 @@ export function bindDataTable(root) {
359
426
  apply();
360
427
  return;
361
428
  }
429
+ // Per-row actions of a trashed row: data-oc-row-action="restore" or "delete" fires oc-datatable-row { action, key };
430
+ // in the demo (data-oc-live) restore makes the row active again and delete removes it.
431
+ const rowBtn = event.target.closest("[data-oc-row-action]");
432
+ if (rowBtn && root.contains(rowBtn)) {
433
+ const row = rowBtn.closest("[data-oc-row]");
434
+ if (!row) return;
435
+ const action = rowBtn.dataset.ocRowAction;
436
+ emit("oc-datatable-row", { action, key: row.dataset.ocKey });
437
+ if (live && action === "restore") row.dataset.ocStatus = "active";
438
+ if (live && action === "delete") { selected.delete(row.dataset.ocKey); row.remove(); }
439
+ apply();
440
+ return;
441
+ }
442
+ // The sliders button next to the title shows or hides the filter row.
443
+ const filtersBtn = event.target.closest("[data-oc-filters-toggle]");
444
+ if (filtersBtn && root.contains(filtersBtn)) {
445
+ const row = root.querySelector(".oc-data-table-filters");
446
+ if (row) {
447
+ row.hidden = !row.hidden;
448
+ filtersBtn.setAttribute("aria-expanded", row.hidden ? "false" : "true");
449
+ }
450
+ return;
451
+ }
452
+ const colTab = event.target.closest("button[data-oc-col-filter]");
453
+ if (colTab && root.contains(colTab)) {
454
+ const key = colTab.dataset.ocColFilter;
455
+ colFilters.set(key, colTab.dataset.ocValue ?? "");
456
+ root.querySelectorAll(`button[data-oc-col-filter="${key}"]`).forEach((tab) => {
457
+ tab.setAttribute("aria-selected", tab === colTab ? "true" : "false");
458
+ });
459
+ page = 0;
460
+ apply();
461
+ return;
462
+ }
362
463
  const viewBtn = event.target.closest("[data-oc-view]");
363
464
  if (viewBtn && root.contains(viewBtn)) {
364
465
  const mode = viewBtn.dataset.ocView;
@@ -386,12 +487,6 @@ export function bindDataTable(root) {
386
487
  exportCsv(null);
387
488
  return;
388
489
  }
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
490
  if (event.target.closest("[data-oc-import]")) {
396
491
  root.querySelector("[data-oc-file]")?.click();
397
492
  return;
@@ -415,73 +510,15 @@ export function bindDataTable(root) {
415
510
  else window.location.href = row.dataset.ocHref;
416
511
  });
417
512
 
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
- });
513
+ // OmniaUI Pro (data-table-pro.js) changes the rows (drag) and asks the table to show them again.
514
+ root.addEventListener("oc-datatable-refresh", () => apply());
445
515
 
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
- });
516
+ // Column widths from all rows (every page, also the trash), measured before the first page is shown, so the header
517
+ // does not jump when sorting or paging brings other rows. Pro column resizing sets its own widths.
518
+ for (const th of table.tHead.rows[0].cells) {
519
+ const width = th.getBoundingClientRect().width;
520
+ if (width && !th.style.width) th.style.minWidth = `${Math.ceil(width)}px`;
521
+ }
485
522
 
486
523
  root.querySelectorAll("[data-oc-view]").forEach((btn) => {
487
524
  const on = (btn.dataset.ocView === "grid") === root.classList.contains("oc-data-table--grid");
@@ -492,9 +529,9 @@ export function bindDataTable(root) {
492
529
  controls.set(root, { apply });
493
530
  }
494
531
 
495
- /** Wire every table under scope that is not wired yet. */
532
+ /** Wire every table under scope that is not wired yet (also the data table block: [data-oc-data-table]). */
496
533
  export function initDataTables(scope = document) {
497
- scope.querySelectorAll(".oc-data-table").forEach((root) => bindDataTable(root));
534
+ scope.querySelectorAll(".oc-data-table, [data-oc-data-table]").forEach((root) => bindDataTable(root));
498
535
  }
499
536
 
500
537
  // Starts on its own when loaded straight from a <script type="module"> tag: as /js/oc-data-table.js (the docs site,
package/dist/fly.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ // File: packages/ui/src/fly.d.ts
2
+ // Types for the fly-in script.
3
+
4
+ export function initFly(scope?: ParentNode): () => void;
package/dist/fly.js ADDED
@@ -0,0 +1,80 @@
1
+ // File: packages/ui/src/fly.js
2
+ // Map: row of a card (index, count) -> start offset -> delay -> observe (once or both ways) -> boot
3
+ // Cards that fly in (.oc-reveal--fly): each card starts from the middle of its row (--oc-fly-x) and flies to its place
4
+ // when it scrolls into view (.is-visible). Adds .oc-fly-ready on <html> first, so without this script, or with
5
+ // "reduce motion", nothing is hidden.
6
+ // Options (CSS custom properties on a card or a parent, and one attribute):
7
+ // --oc-fly-step: 45px start offset per column from the middle of the row (instead of the full distance)
8
+ // --oc-fly-stagger: 90ms delay per column, so a row lands from left to right
9
+ // data-oc-fly-repeat the cards fly out again when they leave the view (both scroll directions)
10
+
11
+ /** Index of the card in its row (cards with the same top in the same parent) and the number of cards in that row. */
12
+ function rowOf(card) {
13
+ const top = card.offsetTop;
14
+ const row = [...(card.parentElement?.children ?? [])].filter((el) => el.classList.contains("oc-reveal--fly") && el.offsetTop === top);
15
+ return { index: Math.max(0, row.indexOf(card)), count: row.length || 1 };
16
+ }
17
+
18
+ /** Horizontal distance from the card to the middle of its parent, so the row opens from the center. */
19
+ function flyOffset(card, step, index, count) {
20
+ if (step) return Math.round(((count - 1) / 2 - index) * step);
21
+ const box = card.getBoundingClientRect();
22
+ const parent = card.parentElement?.getBoundingClientRect();
23
+ if (!parent || !box.width) return 0;
24
+ return Math.round(parent.left + parent.width / 2 - (box.left + box.width / 2));
25
+ }
26
+
27
+ /** "90ms", "0.09s" or "45px" -> number (ms or px); 0 when empty. */
28
+ function num(value, unitS = false) {
29
+ const v = String(value).trim();
30
+ const n = parseFloat(v);
31
+ if (!v || Number.isNaN(n)) return 0;
32
+ return unitS && /\ds$/.test(v) ? n * 1000 : n;
33
+ }
34
+
35
+ /** Let every .oc-reveal--fly under scope fly in once it comes into view. Returns a function that stops watching. */
36
+ export function initFly(scope = document) {
37
+ const doc = scope.ownerDocument || scope;
38
+ const win = doc.defaultView;
39
+ const cards = [...scope.querySelectorAll(".oc-reveal--fly:not(.is-visible)")];
40
+ if (!cards.length || !win) return () => {};
41
+ if (!("IntersectionObserver" in win) || win.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
42
+ cards.forEach((c) => c.classList.add("is-visible"));
43
+ return () => {};
44
+ }
45
+ for (const card of cards) {
46
+ const style = win.getComputedStyle(card);
47
+ const { index, count } = rowOf(card);
48
+ card.style.setProperty("--oc-fly-x", `${flyOffset(card, num(style.getPropertyValue("--oc-fly-step")), index, count)}px`);
49
+ const stagger = num(style.getPropertyValue("--oc-fly-stagger"), true);
50
+ if (stagger) card.style.setProperty("--oc-fly-delay", `${index * stagger}ms`);
51
+ }
52
+ doc.documentElement.classList.add("oc-fly-ready");
53
+ const io = new win.IntersectionObserver((entries) => {
54
+ for (const entry of entries) {
55
+ const repeat = entry.target.closest("[data-oc-fly-repeat]");
56
+ if (entry.isIntersecting) {
57
+ entry.target.classList.add("is-visible");
58
+ if (!repeat) io.unobserve(entry.target);
59
+ } else if (repeat) {
60
+ entry.target.classList.remove("is-visible");
61
+ }
62
+ }
63
+ }, { rootMargin: "0px 0px -10% 0px", threshold: 0.12 });
64
+ cards.forEach((c) => io.observe(c));
65
+ return () => io.disconnect();
66
+ }
67
+
68
+ // Starts on its own as /js/oc-fly.js (`npx omniaui copy-js`), with ?auto in the URL or data-oc-auto on the tag.
69
+ function autoStart() {
70
+ if (typeof document === "undefined") return false;
71
+ const url = String(import.meta.url);
72
+ if (url.includes("oc-fly") || /[?&]auto(=|&|$)/.test(url)) return true;
73
+ const file = url.split(/[?#]/)[0];
74
+ return [...document.querySelectorAll("script[data-oc-auto][src]")].some((s) => s.src.split(/[?#]/)[0] === file);
75
+ }
76
+ if (autoStart()) {
77
+ const boot = () => initFly(document);
78
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
79
+ else boot();
80
+ }
@@ -0,0 +1,11 @@
1
+ // File: packages/ui/src/form-extras.d.ts
2
+ // Types for the date field (ISO week), name card (rename), avatar picker (preview) and quantity (step) helpers.
3
+
4
+ export function isoWeek(date: Date): { week: number; year: number };
5
+ export function bindDateField(root: HTMLElement): void;
6
+ export function bindNameCard(root: HTMLElement): void;
7
+ export function acceptsFile(file: { type?: string; name?: string } | null | undefined, accept?: string): boolean;
8
+ export function bindAvatarPicker(root: HTMLElement): void;
9
+ export function stepQuantity(value: number, direction: -1 | 1, limits?: { min?: number; max?: number; step?: number }): number;
10
+ export function bindQuantity(root: HTMLElement): void;
11
+ export function initFormExtras(scope?: ParentNode): void;