@birdycom/omniaui 0.12.4 → 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.
- package/README.md +1 -1
- package/bin/lib/copy-js.mjs +10 -1
- package/bin/omniaui.mjs +2 -2
- package/dist/barcode.d.ts +15 -0
- package/dist/barcode.js +164 -0
- package/dist/components-extra.css +15 -1
- package/dist/components.css +1496 -193
- package/dist/components.min.css +1 -1
- package/dist/data-table.js +135 -98
- package/dist/form-extras.d.ts +11 -0
- package/dist/form-extras.js +214 -0
- package/dist/lazy.d.ts +5 -0
- package/dist/lazy.js +37 -0
- package/dist/oc-theme.css +1 -1
- package/dist/parts/app-data-table-footer.css +38 -0
- package/dist/parts/app-data-table-rich.css +161 -0
- package/dist/parts/app-data-table.css +44 -74
- package/dist/parts/app-name-card.css +65 -0
- package/dist/parts/app-titlebar.css +16 -8
- package/dist/parts/data-meter-row.css +40 -0
- package/dist/parts/data-price.css +20 -0
- package/dist/parts/data-ribbon.css +76 -0
- package/dist/parts/data-spec-list.css +36 -0
- package/dist/parts/data-stock.css +41 -0
- package/dist/parts/forms-affix-input.css +1 -1
- package/dist/parts/forms-avatar-picker.css +43 -0
- package/dist/parts/forms-date-field.css +53 -0
- package/dist/parts/forms-density.css +13 -0
- package/dist/parts/forms-form-section.css +64 -0
- package/dist/parts/forms-quantity.css +55 -0
- package/dist/parts/forms-rating.css +81 -0
- package/dist/parts/media-attachment.css +1 -1
- package/dist/parts/media-barcode.css +23 -0
- package/dist/parts/media-chat-composer.css +3 -0
- package/dist/parts/media-emoji-picker.css +3 -1
- package/dist/parts/media-gallery.css +55 -0
- package/dist/parts/media-glass-liquid.css +99 -0
- package/dist/parts/media-glass-options.css +50 -0
- package/dist/parts/media-lazy.css +46 -0
- package/dist/parts/media-message-thread.css +1 -1
- package/dist/parts/media-message.css +1 -1
- package/dist/parts/nav-topbar-variants.css +96 -0
- package/dist/parts/site-header-variants.css +70 -0
- package/dist/parts/site-prose.css +1 -1
- package/dist/showcase.css +40 -9
- package/manifest.json +121 -9
- package/package.json +5 -2
- package/snippets/accordion-accent.html +1 -1
- package/snippets/attachment.html +4 -4
- package/snippets/avatar-picker.html +40 -0
- package/snippets/barcode.html +20 -0
- package/snippets/chat-composer.html +3 -3
- package/snippets/data-table.html +51 -76
- package/snippets/date-field.html +44 -0
- package/snippets/doc-viewer.html +1 -1
- package/snippets/emoji-picker.html +1 -1
- package/snippets/form-density.html +25 -0
- package/snippets/form-section.html +47 -0
- package/snippets/gallery.html +25 -0
- package/snippets/lazy.html +39 -0
- package/snippets/message-thread.html +12 -12
- package/snippets/message.html +10 -10
- package/snippets/meter-row.html +29 -0
- package/snippets/name-card.html +72 -0
- package/snippets/page-header.html +1 -1
- package/snippets/price.html +25 -0
- package/snippets/prose.html +3 -3
- package/snippets/quantity.html +12 -0
- package/snippets/rating.html +26 -0
- package/snippets/ribbon.html +48 -0
- package/snippets/sidebar.html +3 -3
- package/snippets/spec-list.html +34 -0
- package/snippets/stock.html +20 -0
- package/snippets/table-actions.html +5 -5
- package/snippets/table.html +2 -2
- package/snippets/titlebar.html +1 -1
- package/snippets/user.html +3 -3
- package/dist/parts/app-column-menu.css +0 -85
- package/snippets/column-menu.html +0 -38
package/dist/data-table.js
CHANGED
|
@@ -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,
|
|
3
|
-
//
|
|
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-
|
|
6
|
+
// listen for oc-datatable-bulk, oc-datatable-row, oc-datatable-search and oc-datatable-import.
|
|
6
7
|
|
|
7
|
-
/** Page numbers with ellipses,
|
|
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
|
-
|
|
114
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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])
|
|
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)
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
419
|
-
root.addEventListener("
|
|
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
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
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
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.
|
|
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
|
+
}
|