@birdycom/omniaui 0.12.5 → 0.12.6

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 (79) hide show
  1. package/README.md +1 -1
  2. package/bin/lib/copy-js.mjs +10 -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 +1489 -187
  8. package/dist/components.min.css +1 -1
  9. package/dist/data-table.js +135 -98
  10. package/dist/form-extras.d.ts +11 -0
  11. package/dist/form-extras.js +214 -0
  12. package/dist/lazy.d.ts +5 -0
  13. package/dist/lazy.js +37 -0
  14. package/dist/oc-theme.css +1 -1
  15. package/dist/parts/app-data-table-footer.css +38 -0
  16. package/dist/parts/app-data-table-rich.css +161 -0
  17. package/dist/parts/app-data-table.css +44 -74
  18. package/dist/parts/app-name-card.css +65 -0
  19. package/dist/parts/app-titlebar.css +16 -8
  20. package/dist/parts/data-meter-row.css +40 -0
  21. package/dist/parts/data-price.css +20 -0
  22. package/dist/parts/data-ribbon.css +76 -0
  23. package/dist/parts/data-spec-list.css +36 -0
  24. package/dist/parts/data-stock.css +41 -0
  25. package/dist/parts/forms-affix-input.css +1 -1
  26. package/dist/parts/forms-avatar-picker.css +43 -0
  27. package/dist/parts/forms-date-field.css +53 -0
  28. package/dist/parts/forms-density.css +13 -0
  29. package/dist/parts/forms-form-section.css +64 -0
  30. package/dist/parts/forms-quantity.css +55 -0
  31. package/dist/parts/forms-rating.css +81 -0
  32. package/dist/parts/media-attachment.css +1 -1
  33. package/dist/parts/media-barcode.css +23 -0
  34. package/dist/parts/media-chat-composer.css +3 -0
  35. package/dist/parts/media-emoji-picker.css +3 -1
  36. package/dist/parts/media-gallery.css +55 -0
  37. package/dist/parts/media-glass-liquid.css +99 -0
  38. package/dist/parts/media-glass-options.css +50 -0
  39. package/dist/parts/media-lazy.css +46 -0
  40. package/dist/parts/media-message-thread.css +1 -1
  41. package/dist/parts/media-message.css +1 -1
  42. package/dist/parts/nav-topbar-variants.css +96 -0
  43. package/dist/parts/site-header-variants.css +70 -0
  44. package/dist/parts/site-prose.css +1 -1
  45. package/dist/showcase.css +33 -3
  46. package/manifest.json +121 -9
  47. package/package.json +5 -2
  48. package/snippets/accordion-accent.html +1 -1
  49. package/snippets/attachment.html +4 -4
  50. package/snippets/avatar-picker.html +40 -0
  51. package/snippets/barcode.html +20 -0
  52. package/snippets/chat-composer.html +3 -3
  53. package/snippets/data-table.html +51 -76
  54. package/snippets/date-field.html +44 -0
  55. package/snippets/doc-viewer.html +1 -1
  56. package/snippets/emoji-picker.html +1 -1
  57. package/snippets/form-density.html +25 -0
  58. package/snippets/form-section.html +47 -0
  59. package/snippets/gallery.html +25 -0
  60. package/snippets/lazy.html +39 -0
  61. package/snippets/message-thread.html +12 -12
  62. package/snippets/message.html +10 -10
  63. package/snippets/meter-row.html +29 -0
  64. package/snippets/name-card.html +72 -0
  65. package/snippets/page-header.html +1 -1
  66. package/snippets/price.html +25 -0
  67. package/snippets/prose.html +3 -3
  68. package/snippets/quantity.html +12 -0
  69. package/snippets/rating.html +26 -0
  70. package/snippets/ribbon.html +48 -0
  71. package/snippets/sidebar.html +3 -3
  72. package/snippets/spec-list.html +34 -0
  73. package/snippets/stock.html +20 -0
  74. package/snippets/table-actions.html +5 -5
  75. package/snippets/table.html +2 -2
  76. package/snippets/titlebar.html +1 -1
  77. package/snippets/user.html +3 -3
  78. package/dist/parts/app-column-menu.css +0 -85
  79. package/snippets/column-menu.html +0 -38
@@ -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,
@@ -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;
@@ -0,0 +1,214 @@
1
+ // File: packages/ui/src/form-extras.js
2
+ // Map: ISO week -> date field (week label) -> name card (rename) -> avatar picker (preview, drop, remove) -> quantity
3
+ // (step buttons) -> init -> boot
4
+ // Small behaviour for four CSS components; each works without it (native date input, <details>, file input, number input).
5
+ // Events on the component root: oc-namecard-rename { name }, oc-avatar-change { file } (null when removed),
6
+ // oc-avatar-invalid { file, reason } (wrong type or too large).
7
+
8
+ /** ISO 8601 week of a date: { week, year } (the week with the year's first Thursday is week 1). */
9
+ export function isoWeek(date) {
10
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
11
+ const day = d.getUTCDay() || 7;
12
+ d.setUTCDate(d.getUTCDate() + 4 - day);
13
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
14
+ return { week: Math.ceil(((d - yearStart) / 86400000 + 1) / 7), year: d.getUTCFullYear() };
15
+ }
16
+
17
+ /** .oc-date-field: write "Week 45" (or the template in data-oc-week, e.g. "W{week} · {year}") into its __week. */
18
+ export function bindDateField(root) {
19
+ const input = root.querySelector(".oc-date-field__input");
20
+ const out = root.querySelector(".oc-date-field__week");
21
+ if (!input || !out || root.dataset.ocBound) return;
22
+ root.dataset.ocBound = "";
23
+ const template = out.dataset.ocWeek || "Week {week}";
24
+ const show = () => {
25
+ const [y, m, d] = (input.value || "").split("-").map(Number);
26
+ if (!y) { out.textContent = ""; return; }
27
+ const { week, year } = isoWeek(new Date(y, m - 1, d));
28
+ out.textContent = template.replace("{week}", String(week)).replace("{year}", String(year));
29
+ };
30
+ input.addEventListener("input", show);
31
+ input.addEventListener("change", show);
32
+ show();
33
+ }
34
+
35
+ /** .oc-name-card: the [data-oc-rename] button swaps the title for an input; Enter or blur saves, Escape cancels. */
36
+ export function bindNameCard(root) {
37
+ const head = root.querySelector(":scope > .oc-name-card__head");
38
+ const title = head?.querySelector(".oc-name-card__title");
39
+ const button = head?.querySelector("[data-oc-rename]");
40
+ if (!title || !button || root.dataset.ocBound) return;
41
+ root.dataset.ocBound = "";
42
+ const input = document.createElement("input");
43
+ input.className = "oc-name-card__input";
44
+ input.hidden = true;
45
+ input.setAttribute("aria-label", button.getAttribute("aria-label") || "Name");
46
+ title.after(input);
47
+ const finish = (save) => {
48
+ if (input.hidden) return;
49
+ const name = input.value.trim();
50
+ if (save && name && name !== title.textContent) {
51
+ title.textContent = name;
52
+ root.dispatchEvent(new CustomEvent("oc-namecard-rename", { bubbles: true, detail: { name } }));
53
+ }
54
+ input.hidden = true;
55
+ title.hidden = false;
56
+ button.focus();
57
+ };
58
+ button.addEventListener("click", (event) => {
59
+ event.preventDefault(); // a button in <summary> would also toggle the card
60
+ input.value = title.textContent.trim();
61
+ title.hidden = true;
62
+ input.hidden = false;
63
+ input.focus();
64
+ input.select();
65
+ });
66
+ // Typing in the input must not toggle the card (Space and Enter in a <summary>).
67
+ input.addEventListener("click", (event) => event.preventDefault());
68
+ input.addEventListener("keydown", (event) => {
69
+ if (event.key === "Enter") { event.preventDefault(); finish(true); }
70
+ if (event.key === "Escape") { event.preventDefault(); finish(false); }
71
+ if (event.key === " ") event.stopPropagation();
72
+ });
73
+ input.addEventListener("keyup", (event) => { if (event.key === " ") event.preventDefault(); });
74
+ input.addEventListener("blur", () => finish(true));
75
+ }
76
+
77
+ /** Does file match an accept list ("image/*", "image/png,.svg")? An empty list accepts images only. */
78
+ export function acceptsFile(file, accept = "image/*") {
79
+ if (!file) return false;
80
+ const rules = (accept || "image/*").split(",").map((r) => r.trim().toLowerCase()).filter(Boolean);
81
+ const type = (file.type || "").toLowerCase();
82
+ const name = (file.name || "").toLowerCase();
83
+ return rules.some((r) => (r.startsWith(".") ? name.endsWith(r) : r.endsWith("/*") ? type.startsWith(r.slice(0, -1)) : type === r));
84
+ }
85
+
86
+ /** .oc-avatar-picker: preview the chosen or dropped image; [data-oc-avatar-remove] goes back to the initials.
87
+ * A file that does not match the input's accept (a PDF picked through "All files") or is larger than
88
+ * data-oc-max-kb is refused: the input is cleared and .oc-avatar-picker__error says why (oc-avatar-invalid). */
89
+ export function bindAvatarPicker(root) {
90
+ const preview = root.querySelector(".oc-avatar-picker__preview");
91
+ const file = root.querySelector(".oc-avatar-picker__file");
92
+ if (!preview || !file || root.dataset.ocBound) return;
93
+ root.dataset.ocBound = "";
94
+ const initials = preview.querySelector("[data-oc-initials]") || null;
95
+ let url = "";
96
+ const error = (text) => {
97
+ let box = root.querySelector(".oc-avatar-picker__error");
98
+ if (!box && text) {
99
+ box = document.createElement("span");
100
+ box.className = "oc-avatar-picker__error";
101
+ box.setAttribute("role", "alert");
102
+ (root.querySelector(".oc-avatar-picker__text") || root).append(box);
103
+ }
104
+ if (box) { box.textContent = text; box.hidden = !text; }
105
+ root.classList.toggle("is-invalid", Boolean(text));
106
+ };
107
+ const show = (picked) => {
108
+ if (url) URL.revokeObjectURL(url);
109
+ url = "";
110
+ preview.querySelector("img")?.remove();
111
+ if (initials) initials.hidden = Boolean(picked);
112
+ if (picked) {
113
+ url = URL.createObjectURL(picked);
114
+ const img = document.createElement("img");
115
+ img.src = url;
116
+ img.alt = "";
117
+ preview.prepend(img);
118
+ }
119
+ root.dispatchEvent(new CustomEvent("oc-avatar-change", { bubbles: true, detail: { file: picked || null } }));
120
+ };
121
+ const take = (picked) => {
122
+ if (!picked) return show(null);
123
+ const maxKb = Number(root.dataset.ocMaxKb || 0);
124
+ const reason = !acceptsFile(picked, file.accept) ? (root.dataset.ocTypeError || "This file is not an image. Choose a PNG, JPG, WebP or SVG.")
125
+ : maxKb && picked.size > maxKb * 1024 ? (root.dataset.ocSizeError || `This image is larger than ${maxKb >= 1024 ? `${Math.round(maxKb / 102.4) / 10} MB` : `${maxKb} KB`}.`) : "";
126
+ if (reason) {
127
+ file.value = "";
128
+ error(reason);
129
+ root.dispatchEvent(new CustomEvent("oc-avatar-invalid", { bubbles: true, detail: { file: picked, reason } }));
130
+ return;
131
+ }
132
+ error("");
133
+ show(picked);
134
+ };
135
+ file.addEventListener("change", () => take(file.files?.[0] || null));
136
+ root.addEventListener("click", (event) => {
137
+ if (event.target.closest("[data-oc-avatar-upload]")) file.click();
138
+ if (event.target.closest("[data-oc-avatar-remove]")) { file.value = ""; error(""); show(null); }
139
+ });
140
+ root.addEventListener("dragover", (event) => { event.preventDefault(); root.classList.add("is-dragover"); });
141
+ root.addEventListener("dragleave", () => root.classList.remove("is-dragover"));
142
+ root.addEventListener("drop", (event) => {
143
+ event.preventDefault();
144
+ root.classList.remove("is-dragover");
145
+ const picked = event.dataTransfer?.files?.[0];
146
+ if (!picked) return;
147
+ // A dropped file never reaches the input, so put it there for the form (where the browser allows it).
148
+ if (acceptsFile(picked, file.accept)) try { const dt = new DataTransfer(); dt.items.add(picked); file.files = dt.files; } catch { /* old browsers */ }
149
+ take(picked);
150
+ });
151
+ }
152
+
153
+ /** Next value of a quantity: one step (data-oc-step = -1 or 1) within min and max, on the step grid. */
154
+ export function stepQuantity(value, direction, { min = -Infinity, max = Infinity, step = 1 } = {}) {
155
+ const size = step > 0 ? step : 1;
156
+ const base = Number.isFinite(min) ? min : 0;
157
+ const current = Number.isFinite(value) ? value : base;
158
+ // Off the step grid (2.5 with step 1), the first step lands on the grid: 3 up, 2 down.
159
+ const k = (current - base) / size;
160
+ const onGrid = Math.abs(k - Math.round(k)) < 1e-9;
161
+ const next = onGrid ? current + direction * size : base + (direction > 0 ? Math.ceil(k) : Math.floor(k)) * size;
162
+ return Math.min(max, Math.max(min, Number(next.toFixed(6))));
163
+ }
164
+
165
+ /** .oc-quantity: the [data-oc-step] buttons change the number input and disable themselves at min / max. */
166
+ export function bindQuantity(root) {
167
+ const input = root.querySelector(".oc-quantity__input");
168
+ if (!input || root.dataset.ocBound) return;
169
+ root.dataset.ocBound = "";
170
+ const num = (attr, fallback) => (input.getAttribute(attr) === null || input.getAttribute(attr) === "" ? fallback : Number(input.getAttribute(attr)));
171
+ const limits = () => ({ min: num("min", -Infinity), max: num("max", Infinity), step: num("step", 1) });
172
+ const sync = () => {
173
+ const { min, max } = limits();
174
+ const value = Number(input.value);
175
+ root.querySelectorAll("[data-oc-step]").forEach((button) => {
176
+ const down = Number(button.dataset.ocStep) < 0;
177
+ button.disabled = input.disabled || (down ? value <= min : value >= max);
178
+ });
179
+ };
180
+ root.addEventListener("click", (event) => {
181
+ const button = event.target.closest("[data-oc-step]");
182
+ if (!button || !root.contains(button) || input.disabled || input.readOnly) return;
183
+ const next = stepQuantity(Number(input.value), Number(button.dataset.ocStep) < 0 ? -1 : 1, limits());
184
+ if (String(next) === input.value) return;
185
+ input.value = String(next);
186
+ input.dispatchEvent(new Event("input", { bubbles: true }));
187
+ input.dispatchEvent(new Event("change", { bubbles: true }));
188
+ });
189
+ input.addEventListener("input", sync);
190
+ input.addEventListener("change", sync);
191
+ sync();
192
+ }
193
+
194
+ /** Wire every date field, name card, avatar picker and quantity field under scope. */
195
+ export function initFormExtras(scope = document) {
196
+ scope.querySelectorAll(".oc-date-field").forEach(bindDateField);
197
+ scope.querySelectorAll(".oc-name-card").forEach(bindNameCard);
198
+ scope.querySelectorAll(".oc-avatar-picker").forEach(bindAvatarPicker);
199
+ scope.querySelectorAll(".oc-quantity").forEach(bindQuantity);
200
+ }
201
+
202
+ // Starts on its own as /js/oc-form-extras.js (`npx omniaui copy-js`), with ?auto in the URL or data-oc-auto on the tag.
203
+ function autoStart() {
204
+ if (typeof document === "undefined") return false;
205
+ const url = String(import.meta.url);
206
+ if (url.includes("oc-form-extras") || /[?&]auto(=|&|$)/.test(url)) return true;
207
+ const file = url.split(/[?#]/)[0];
208
+ return [...document.querySelectorAll("script[data-oc-auto][src]")].some((s) => s.src.split(/[?#]/)[0] === file);
209
+ }
210
+ if (autoStart()) {
211
+ const boot = () => initFormExtras(document);
212
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
213
+ else boot();
214
+ }
package/dist/lazy.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ // File: packages/ui/src/lazy.d.ts
2
+ // Types for the lazy image script.
3
+
4
+ export function watchLazy(img: HTMLImageElement): void;
5
+ export function initLazy(scope?: ParentNode): void;
package/dist/lazy.js ADDED
@@ -0,0 +1,37 @@
1
+ // File: packages/ui/src/lazy.js
2
+ // Map: mark one image (loaded / error) -> bind every .oc-lazy -> boot
3
+ // Lazy images: <figure class="oc-lazy"><img loading="lazy" …></figure>. The browser loads the image when it comes near
4
+ // the screen; this script only adds .is-loaded (the photo fades in over the placeholder) or .is-error. Without it the
5
+ // image simply shows when it is there.
6
+
7
+ /** Mark the .oc-lazy box of one image as loaded or failed, now or when the image finishes. */
8
+ export function watchLazy(img) {
9
+ const box = img.closest(".oc-lazy");
10
+ if (!box || box.dataset.ocBound) return;
11
+ box.dataset.ocBound = "";
12
+ const done = () => box.classList.add(img.naturalWidth ? "is-loaded" : "is-error");
13
+ if (img.complete) done();
14
+ else {
15
+ img.addEventListener("load", done, { once: true });
16
+ img.addEventListener("error", () => box.classList.add("is-error"), { once: true });
17
+ }
18
+ }
19
+
20
+ /** Watch every lazy image under scope. */
21
+ export function initLazy(scope = document) {
22
+ scope.querySelectorAll(".oc-lazy img").forEach(watchLazy);
23
+ }
24
+
25
+ // Starts on its own as /js/oc-lazy.js (`npx omniaui copy-js`), with ?auto in the URL or data-oc-auto on the tag.
26
+ function autoStart() {
27
+ if (typeof document === "undefined") return false;
28
+ const url = String(import.meta.url);
29
+ if (url.includes("oc-lazy") || /[?&]auto(=|&|$)/.test(url)) return true;
30
+ const file = url.split(/[?#]/)[0];
31
+ return [...document.querySelectorAll("script[data-oc-auto][src]")].some((s) => s.src.split(/[?#]/)[0] === file);
32
+ }
33
+ if (autoStart()) {
34
+ const boot = () => initLazy(document);
35
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
36
+ else boot();
37
+ }
package/dist/oc-theme.css CHANGED
@@ -1,4 +1,4 @@
1
- /* File: oc-theme.css — generated from tokens/semantic.json by @birdycom/omniacss 0.12.5. Do not edit. */
1
+ /* File: oc-theme.css — generated from tokens/semantic.json by @birdycom/omniacss 0.12.6. Do not edit. */
2
2
  /* Semantic theme tokens: every color is light-dark() of an Omnia palette token (oc-colors.css). */
3
3
  /* Same source as the desktop export: npx omniacss tokens --format json|dtcg|python */
4
4
  :root {
@@ -0,0 +1,38 @@
1
+ /* File: packages/ui/src/parts/app-data-table-footer.css | Map: footer grid (count, rows per page, pager) -> page buttons -> narrow screens */
2
+ /* Footer of the list table (app-data-table.css): the count on the left, rows per page in the middle, the pager on the right. */
3
+
4
+ /* Footer: count on the left, rows-per-page in the middle, pager on the right. */
5
+ .oc-data-table-footer {
6
+ display: grid;
7
+ grid-template-columns: 1fr auto 1fr;
8
+ align-items: center;
9
+ gap: 0.75rem;
10
+ }
11
+ .oc-data-table-summary { margin: 0; font-size: 0.8125rem; color: var(--oc-muted-foreground); }
12
+ .oc-data-table-per-page { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; }
13
+ .oc-data-table-per-page .oc-select { inline-size: auto; }
14
+ .oc-data-table-pager { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.25rem; }
15
+ .oc-data-table-page {
16
+ min-inline-size: 2rem;
17
+ padding: 0.25rem 0.45rem;
18
+ font: inherit;
19
+ font-size: 0.8125rem;
20
+ color: var(--oc-foreground);
21
+ background: var(--oc-card);
22
+ border: 1px solid var(--oc-border);
23
+ border-radius: calc(var(--oc-radius) - 2px);
24
+ cursor: pointer;
25
+ }
26
+ .oc-data-table-page[aria-current="page"] {
27
+ color: var(--oc-primary);
28
+ background: oklch(from var(--oc-primary) l c h / 0.1);
29
+ border-color: oklch(from var(--oc-primary) l c h / 0.35);
30
+ }
31
+ .oc-data-table-page:disabled { opacity: 0.4; cursor: default; }
32
+ .oc-data-table-page:focus-visible { outline: 2px solid var(--oc-ring); outline-offset: 1px; }
33
+ .oc-data-table-ellipsis { padding-inline: 0.25rem; color: var(--oc-muted-foreground); }
34
+
35
+ @media (max-width: 40rem) {
36
+ .oc-data-table-footer { grid-template-columns: 1fr; }
37
+ .oc-data-table-pager { justify-content: flex-start; }
38
+ }