@wpmoo/ui 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1080 @@
1
+ const instances = new WeakMap();
2
+
3
+ function normalize(value) {
4
+ return (value ?? "").toString().trim().toLowerCase();
5
+ }
6
+
7
+ export default class DataTable {
8
+ static getInstance(element) {
9
+ return element?.nodeType === 1 ? instances.get(element) || null : null;
10
+ }
11
+
12
+ static getOrCreateInstance(element, config = {}) {
13
+ return DataTable.getInstance(element) || new DataTable(element, config);
14
+ }
15
+
16
+ constructor(element, config = {}) {
17
+ if (element?.nodeType !== 1 || !element.matches(".datatable")) {
18
+ throw new TypeError("DataTable requires a .datatable root element.");
19
+ }
20
+ const existing = instances.get(element);
21
+ if (existing) {
22
+ return existing;
23
+ }
24
+
25
+ this._element = element;
26
+ this._document = element.ownerDocument;
27
+ this._window = this._document.defaultView;
28
+ this._table = element.querySelector(":scope .datatable-table");
29
+ this._tbody = this._table?.querySelector(":scope > tbody");
30
+ if (!this._table || !this._tbody) {
31
+ throw new TypeError("DataTable requires a .datatable-table with a tbody.");
32
+ }
33
+ this._cards = element.querySelector("[data-datatable-cards]");
34
+ const cardById = new Map(
35
+ Array.from(element.querySelectorAll("[data-datatable-card]")).map((card) => [card.dataset.datatableCardFor, card])
36
+ );
37
+
38
+ this._config = { ...config };
39
+ this._listeners = [];
40
+ this._pageSize = Number(element.dataset.datatablePageSize) || 10;
41
+ this._currentPage = 1;
42
+ this._sortKey = null;
43
+ this._sortDirection = null;
44
+ this._selectedIds = new Set();
45
+ this._facetSelections = new Map();
46
+ this._visibleFacets = new Map();
47
+ this._searchTerm = "";
48
+ this._tooltips = [];
49
+ this._filterMode = element.dataset.datatableFilterMode || "inline";
50
+
51
+ this._rows = Array.from(this._tbody.querySelectorAll(":scope > tr[data-datatable-row]")).map((tr, index) => ({
52
+ element: tr,
53
+ cardElement: cardById.get(tr.id) || null,
54
+ index,
55
+ search: normalize(tr.dataset.datatableSearch),
56
+ facets: this._readFacets(tr),
57
+ }));
58
+ this._readInitialFacetVisibility();
59
+ this._readInitialSort();
60
+
61
+ instances.set(element, this);
62
+ this._bindEvents();
63
+ this._render();
64
+ this._initBulkTooltips();
65
+ this._initViewToggle();
66
+ }
67
+
68
+ dispose() {
69
+ this._listeners.forEach(({ target, type, handler, options }) => {
70
+ target.removeEventListener(type, handler, options);
71
+ });
72
+ this._listeners = [];
73
+ this._tooltips.forEach((tooltip) => tooltip.dispose());
74
+ this._tooltips = [];
75
+ instances.delete(this._element);
76
+ }
77
+
78
+ getSelectedIds() {
79
+ return Array.from(this._selectedIds);
80
+ }
81
+
82
+ _listen(target, type, handler, options) {
83
+ if (!target) {
84
+ return;
85
+ }
86
+ target.addEventListener(type, handler, options);
87
+ this._listeners.push({ target, type, handler, options });
88
+ }
89
+
90
+ _bootstrap(name) {
91
+ return this._window.bootstrap?.[name] || null;
92
+ }
93
+
94
+ // The bulk-actions bar's icon-only buttons carry data-bs-title instead of
95
+ // a bare title attribute so Bootstrap's own Tooltip owns the hover/focus
96
+ // affordance (matching the rest of Moo's icon-button contracts) rather
97
+ // than the browser's unstyled, delayed native tooltip. Tooltip has no
98
+ // Data API auto-init the way Dropdown does, so it needs this explicit,
99
+ // one-time setup; the bar's markup is static, so it never needs re-init.
100
+ // animation: false keeps show()/hide() synchronous -- with the default
101
+ // CSS fade, a click on a tooltip's own dropdown-toggle button fires
102
+ // "show.bs.dropdown" while the hover-triggered tooltip is still mid-fade,
103
+ // so the hide() below (see show.bs.dropdown in _bindEvents) would find
104
+ // it not yet fully shown and no-op, leaving it stuck open over the menu.
105
+ _initBulkTooltips() {
106
+ const Tooltip = this._bootstrap("Tooltip");
107
+ if (!Tooltip) {
108
+ return;
109
+ }
110
+ // Bootstrap's Data store allows only one bound component instance per
111
+ // element (dom/data.js logs "doesn't allow more than one instance per
112
+ // element" and silently drops the second). The bulk-action option
113
+ // trigger (e.g. "Update status") carries both data-bs-title and
114
+ // data-bs-toggle="dropdown" on the same button, so it must keep its
115
+ // Dropdown instance and skip the JS-managed Tooltip here; its
116
+ // aria-label already gives it an accessible name without the hover
117
+ // affordance.
118
+ this._element
119
+ .querySelectorAll("[data-datatable-bulk-actions] [data-bs-title]:not([data-bs-toggle=\"dropdown\"])")
120
+ .forEach((trigger) => {
121
+ this._tooltips.push(Tooltip.getOrCreateInstance(trigger, { animation: false }));
122
+ });
123
+ }
124
+
125
+ _hideBulkTooltips({ blur = false } = {}) {
126
+ this._tooltips.forEach((tooltip) => {
127
+ tooltip.hide();
128
+ });
129
+
130
+ if (!blur) {
131
+ return;
132
+ }
133
+
134
+ const active = this._document.activeElement;
135
+ if (active?.closest?.("[data-datatable-bulk-actions]")) {
136
+ active.blur();
137
+ }
138
+ }
139
+
140
+ // responsive_mode="toggle" renders both the table and the card list and
141
+ // lets the reader pick between them (Odoo's List/Kanban switcher, not a
142
+ // developer-chosen breakpoint), so the choice belongs to the reader across
143
+ // visits, not just the current render.
144
+ _initViewToggle() {
145
+ const toggles = Array.from(this._element.querySelectorAll(".datatable-view-toggle"));
146
+ if (!toggles.length) {
147
+ return;
148
+ }
149
+ const inputs = toggles.flatMap((toggle) => Array.from(toggle.querySelectorAll("input")));
150
+ const storageKey = `moo-datatable-view:${this._element.id}`;
151
+ const setView = (value, { persist = false } = {}) => {
152
+ this._element.dataset.datatableView = value;
153
+ inputs.forEach((input) => {
154
+ input.checked = input.value === value;
155
+ });
156
+ if (persist) {
157
+ try {
158
+ this._window.localStorage.setItem(storageKey, value);
159
+ } catch {
160
+ // Storage may be unavailable (private browsing); the toggle still works for this session.
161
+ }
162
+ }
163
+ };
164
+ let stored = null;
165
+ try {
166
+ stored = this._window.localStorage.getItem(storageKey);
167
+ } catch {
168
+ stored = null;
169
+ }
170
+ if (stored === "table" || stored === "cards") {
171
+ setView(stored);
172
+ }
173
+ toggles.forEach((toggle) => {
174
+ this._listen(toggle, "change", (event) => {
175
+ const value = event.target.value;
176
+ if (value !== "table" && value !== "cards") {
177
+ return;
178
+ }
179
+ setView(value, { persist: true });
180
+ });
181
+ });
182
+ }
183
+
184
+ _readFacets(tr) {
185
+ const facets = {};
186
+ Array.from(tr.attributes).forEach((attr) => {
187
+ const match = /^data-datatable-facet-(.+)$/.exec(attr.name);
188
+ if (match) {
189
+ facets[match[1]] = attr.value;
190
+ }
191
+ });
192
+ return facets;
193
+ }
194
+
195
+ _readInitialFacetVisibility() {
196
+ this._element.querySelectorAll("[data-datatable-facet]").forEach((facetRoot) => {
197
+ const key = facetRoot.dataset.datatableFacet;
198
+ if (key) {
199
+ this._visibleFacets.set(key, !facetRoot.hidden);
200
+ }
201
+ });
202
+ this._element.querySelectorAll("[data-datatable-filter-group]").forEach((group) => {
203
+ const key = group.dataset.datatableFilterGroup;
204
+ if (key && !this._visibleFacets.has(key)) {
205
+ this._visibleFacets.set(key, false);
206
+ }
207
+ });
208
+ this._element.querySelectorAll("[data-datatable-filter-option-key]").forEach((option) => {
209
+ const key = option.dataset.datatableFilterOptionKey;
210
+ if (key && !this._visibleFacets.has(key)) {
211
+ this._visibleFacets.set(key, false);
212
+ }
213
+ });
214
+ }
215
+
216
+ _readInitialSort() {
217
+ const th = this._element.querySelector("th[data-datatable-initial-sort][data-datatable-column]");
218
+ const direction = th?.dataset.datatableInitialSort;
219
+ if (direction !== "asc" && direction !== "desc") {
220
+ return;
221
+ }
222
+ this._sortKey = th.dataset.datatableColumn || null;
223
+ this._sortDirection = this._sortKey ? direction : null;
224
+ }
225
+
226
+ _trigger(name, detail = {}) {
227
+ this._element.dispatchEvent(
228
+ new this._window.CustomEvent(`${name}.moo.datatable`, {
229
+ bubbles: true,
230
+ detail,
231
+ })
232
+ );
233
+ }
234
+
235
+ _sortValue(row, key) {
236
+ const cell = row.element.querySelector(`:scope > [data-datatable-column="${key}"]`);
237
+ if (!cell) {
238
+ return "";
239
+ }
240
+ return normalize(cell.dataset.datatableSortValue ?? cell.textContent);
241
+ }
242
+
243
+ _matchesFilters(row) {
244
+ if (this._searchTerm && !row.search.includes(this._searchTerm)) {
245
+ return false;
246
+ }
247
+ for (const [facetKey, selected] of this._facetSelections) {
248
+ if (selected.size === 0) {
249
+ continue;
250
+ }
251
+ if (!selected.has(row.facets[facetKey])) {
252
+ return false;
253
+ }
254
+ }
255
+ return true;
256
+ }
257
+
258
+ _visibleRows() {
259
+ const filtered = this._rows.filter((row) => this._matchesFilters(row));
260
+ if (this._sortKey) {
261
+ const direction = this._sortDirection === "desc" ? -1 : 1;
262
+ filtered.sort((a, b) => {
263
+ const aValue = this._sortValue(a, this._sortKey);
264
+ const bValue = this._sortValue(b, this._sortKey);
265
+ if (aValue === bValue) {
266
+ return a.index - b.index;
267
+ }
268
+ return aValue > bValue ? direction : -direction;
269
+ });
270
+ } else {
271
+ filtered.sort((a, b) => a.index - b.index);
272
+ }
273
+ return filtered;
274
+ }
275
+
276
+ _pageCount(rowCount) {
277
+ return Math.max(1, Math.ceil(rowCount / this._pageSize));
278
+ }
279
+
280
+ _render() {
281
+ const filtered = this._visibleRows();
282
+ const pageCount = this._pageCount(filtered.length);
283
+ this._currentPage = Math.min(Math.max(1, this._currentPage), pageCount);
284
+ const start = (this._currentPage - 1) * this._pageSize;
285
+ const pageRows = filtered.slice(start, start + this._pageSize);
286
+ const pageRowIds = new Set(pageRows.map((row) => row.element.id));
287
+
288
+ this._rows.forEach((row) => {
289
+ const isVisible = pageRowIds.has(row.element.id);
290
+ row.element.hidden = !isVisible;
291
+ if (row.cardElement) {
292
+ row.cardElement.hidden = !isVisible;
293
+ }
294
+ });
295
+
296
+ // appendChild() always detaches-and-reinserts, which blurs a focused
297
+ // descendant (e.g. a row checkbox) even when the node is only moved to
298
+ // the position it already occupies. Selecting/deselecting a row never
299
+ // changes row order, so skip the reorder entirely unless the DOM order
300
+ // actually disagrees with the desired page order.
301
+ const currentOrder = Array.from(this._tbody.children)
302
+ .filter((element) => pageRowIds.has(element.id))
303
+ .map((element) => element.id);
304
+ const desiredOrder = pageRows.map((row) => row.element.id);
305
+ const orderChanged =
306
+ currentOrder.length !== desiredOrder.length ||
307
+ currentOrder.some((id, index) => id !== desiredOrder[index]);
308
+ if (orderChanged) {
309
+ pageRows.forEach((row) => {
310
+ this._tbody.appendChild(row.element);
311
+ if (this._cards && row.cardElement) {
312
+ this._cards.appendChild(row.cardElement);
313
+ }
314
+ });
315
+ }
316
+
317
+ this._renderToolbarState();
318
+ this._renderSortHeaders();
319
+ this._renderSelection(pageRows);
320
+ this._renderFrameState(filtered.length);
321
+ this._renderEmptyState(filtered.length);
322
+ this._renderPagination(filtered.length, pageCount);
323
+ this._renderPageSize();
324
+ this._renderBulkActions();
325
+ }
326
+
327
+ _renderToolbarState() {
328
+ const hasSearch = Boolean(this._searchTerm);
329
+ const hasFacets = Array.from(this._facetSelections.values()).some((set) => set.size > 0);
330
+ const resetButton = this._element.querySelector("[data-datatable-reset]");
331
+ if (resetButton) {
332
+ resetButton.hidden = !(hasSearch || hasFacets);
333
+ }
334
+
335
+ this._element.querySelectorAll("[data-datatable-facet]").forEach((facetRoot) => {
336
+ const key = facetRoot.dataset.datatableFacet;
337
+ const selected = this._facetSelections.get(key) || new Set();
338
+ if (this._visibleFacets.has(key)) {
339
+ facetRoot.hidden = !this._visibleFacets.get(key);
340
+ }
341
+ const trigger = facetRoot.querySelector(".datatable-facet-trigger");
342
+ trigger?.classList.toggle("datatable-facet-trigger-active", selected.size > 0);
343
+ const summary = facetRoot.querySelector("[data-datatable-facet-summary]");
344
+ if (summary) {
345
+ summary.hidden = selected.size === 0;
346
+ summary.replaceChildren();
347
+ if (selected.size > 2) {
348
+ summary.appendChild(this._createFacetBadge(`${selected.size} selected`));
349
+ } else {
350
+ selected.forEach((value) => {
351
+ const option = facetRoot.querySelector(`[data-datatable-facet-option="${value}"]`);
352
+ const label = option?.querySelector("[data-datatable-facet-option-label]")?.textContent ?? value;
353
+ summary.appendChild(this._createFacetBadge(label));
354
+ });
355
+ }
356
+ }
357
+ facetRoot.querySelectorAll("[data-datatable-facet-option]").forEach((option) => {
358
+ const isSelected = selected.has(option.dataset.datatableFacetOption);
359
+ option.classList.toggle("active", isSelected);
360
+ option.setAttribute("aria-pressed", String(isSelected));
361
+ });
362
+ });
363
+
364
+ this._element.querySelectorAll("[data-datatable-filter-toggle]").forEach((toggle) => {
365
+ const key = toggle.dataset.datatableFilterToggle;
366
+ const visible = this._visibleFacets.get(key) ?? true;
367
+ toggle.classList.toggle("active", visible);
368
+ toggle.setAttribute("aria-pressed", visible ? "true" : "false");
369
+ });
370
+
371
+ this._element.querySelectorAll("[data-datatable-filter-option]").forEach((option) => {
372
+ const key = option.dataset.datatableFilterOptionKey;
373
+ const selected = this._facetSelections.get(key) || new Set();
374
+ const active = selected.has(option.dataset.datatableFilterOption);
375
+ option.classList.toggle("active", active);
376
+ option.setAttribute("aria-pressed", active ? "true" : "false");
377
+ });
378
+
379
+ this._element.querySelectorAll("[data-datatable-filter-group-summary]").forEach((summary) => {
380
+ const selected = this._facetSelections.get(summary.dataset.datatableFilterGroupSummary) || new Set();
381
+ summary.hidden = selected.size === 0;
382
+ summary.textContent = selected.size > 0 ? `${selected.size} selected` : "";
383
+ });
384
+ }
385
+
386
+ _createFacetBadge(text) {
387
+ const badge = this._document.createElement("span");
388
+ badge.className = "datatable-facet-badge";
389
+ badge.textContent = text;
390
+ return badge;
391
+ }
392
+
393
+ _renderSelection(pageRows) {
394
+ const selectAllControls = Array.from(this._element.querySelectorAll("[data-datatable-select-all]"));
395
+ pageRows.forEach((row) => {
396
+ [row.element, row.cardElement].forEach((container) => {
397
+ const checkbox = container?.querySelector("[data-datatable-select-row]");
398
+ if (checkbox) {
399
+ checkbox.checked = this._selectedIds.has(row.element.id);
400
+ }
401
+ container?.classList.toggle("datatable-row-selected", this._selectedIds.has(row.element.id));
402
+ });
403
+ });
404
+ const total = pageRows.length;
405
+ const checked = pageRows.filter((row) => this._selectedIds.has(row.element.id)).length;
406
+ selectAllControls.forEach((control) => {
407
+ control.checked = total > 0 && checked === total;
408
+ control.indeterminate = checked > 0 && checked < total;
409
+ });
410
+ }
411
+
412
+ _renderBulkActions() {
413
+ const bar = this._element.querySelector("[data-datatable-bulk-actions]");
414
+ if (!bar) {
415
+ return;
416
+ }
417
+ bar.hidden = this._selectedIds.size === 0;
418
+ const count = bar.querySelector("[data-datatable-bulk-count]");
419
+ if (count) {
420
+ count.textContent = String(this._selectedIds.size);
421
+ }
422
+ }
423
+
424
+ _renderFrameState(totalRows) {
425
+ const isEmpty = totalRows === 0;
426
+ this._element.querySelectorAll(".datatable-frame, .datatable-card-frame").forEach((frame) => {
427
+ frame.hidden = isEmpty;
428
+ });
429
+ }
430
+
431
+ _renderEmptyState(totalRows) {
432
+ const empty = this._element.querySelector("[data-datatable-empty]");
433
+ if (!empty) {
434
+ return;
435
+ }
436
+ const hasRows = this._rows.length > 0;
437
+ empty.hidden = totalRows !== 0;
438
+ empty.querySelector(".datatable-empty-title")?.replaceChildren(
439
+ this._document.createTextNode(hasRows ? "No matching results" : "No rows to display")
440
+ );
441
+ const copy = empty.querySelector(".datatable-empty-copy");
442
+ if (copy) {
443
+ copy.hidden = !hasRows;
444
+ }
445
+ const reset = empty.querySelector("[data-datatable-empty-reset]");
446
+ if (reset) {
447
+ reset.hidden = !hasRows;
448
+ }
449
+ }
450
+
451
+ _sortAriaValue(direction) {
452
+ if (direction === "asc") {
453
+ return "ascending";
454
+ }
455
+ if (direction === "desc") {
456
+ return "descending";
457
+ }
458
+ return "none";
459
+ }
460
+
461
+ _renderSortHeaders() {
462
+ this._element.querySelectorAll("th[data-datatable-column]").forEach((th) => {
463
+ const trigger = th.querySelector("[data-datatable-sort-key]");
464
+ if (!trigger) {
465
+ delete th.dataset.datatableSortState;
466
+ delete th.dataset.datatableSortDirection;
467
+ th.removeAttribute("aria-sort");
468
+ return;
469
+ }
470
+ const key = th.dataset.datatableColumn;
471
+ const direction = key && key === this._sortKey ? this._sortDirection : "none";
472
+ th.dataset.datatableSortState = direction || "none";
473
+ th.setAttribute("aria-sort", this._sortAriaValue(direction));
474
+ if (direction === "asc" || direction === "desc") {
475
+ th.dataset.datatableSortDirection = direction;
476
+ } else {
477
+ delete th.dataset.datatableSortDirection;
478
+ }
479
+ });
480
+ }
481
+
482
+ _renderPagination(totalRows, pageCount) {
483
+ const first = this._element.querySelector("[data-datatable-page-first]");
484
+ const prev = this._element.querySelector("[data-datatable-page-prev]");
485
+ const next = this._element.querySelector("[data-datatable-page-next]");
486
+ const last = this._element.querySelector("[data-datatable-page-last]");
487
+ const atStart = this._currentPage <= 1;
488
+ const atEnd = this._currentPage >= pageCount;
489
+ [first, prev].forEach((button) => this._setPageItemDisabled(button, atStart));
490
+ [next, last].forEach((button) => this._setPageItemDisabled(button, atEnd));
491
+
492
+ const summary = this._element.querySelector("[data-datatable-results-summary]");
493
+ if (summary) {
494
+ if (totalRows === 0) {
495
+ summary.textContent = "No results";
496
+ } else {
497
+ const start = (this._currentPage - 1) * this._pageSize + 1;
498
+ const end = Math.min(this._currentPage * this._pageSize, totalRows);
499
+ summary.textContent = `Showing ${start}-${end} of ${totalRows}`;
500
+ }
501
+ }
502
+
503
+ const template = this._element.querySelector("template[data-datatable-page-numbers]");
504
+ if (!template) {
505
+ return;
506
+ }
507
+ template.parentNode
508
+ .querySelectorAll("[data-datatable-page-number], [data-datatable-page-ellipsis]")
509
+ .forEach((node) => node.remove());
510
+
511
+ const pages = this._pageWindow(this._currentPage, pageCount);
512
+ const fragment = this._document.createDocumentFragment();
513
+ pages.forEach((page) => {
514
+ const li = this._document.createElement("li");
515
+ li.className = "page-item";
516
+ if (page === "…") {
517
+ li.dataset.datatablePageEllipsis = "";
518
+ li.innerHTML = '<span class="page-link">…</span>';
519
+ } else {
520
+ li.dataset.datatablePageNumber = String(page);
521
+ if (page === this._currentPage) {
522
+ li.classList.add("active");
523
+ li.setAttribute("aria-current", "page");
524
+ }
525
+ const button = this._document.createElement("button");
526
+ button.type = "button";
527
+ button.className = "page-link";
528
+ button.textContent = String(page);
529
+ button.setAttribute("aria-label", `Go to page ${page}`);
530
+ button.dataset.datatablePageGo = String(page);
531
+ li.appendChild(button);
532
+ }
533
+ fragment.appendChild(li);
534
+ });
535
+ template.parentNode.insertBefore(fragment, template);
536
+ }
537
+
538
+ _renderPageSize() {
539
+ const currentSize = String(this._pageSize);
540
+ const value = this._element.querySelector("[data-datatable-page-size-value]");
541
+ if (value) {
542
+ value.textContent = currentSize;
543
+ }
544
+ const select = this._element.querySelector("[data-datatable-page-size-select]");
545
+ if (select) {
546
+ select.value = currentSize;
547
+ }
548
+ this._element.querySelectorAll("[data-datatable-page-size-option]").forEach((option) => {
549
+ const active = option.dataset.datatablePageSizeOption === currentSize;
550
+ option.classList.toggle("active", active);
551
+ option.setAttribute("aria-pressed", active ? "true" : "false");
552
+ });
553
+ }
554
+
555
+ _setPageItemDisabled(button, disabled) {
556
+ if (!button) {
557
+ return;
558
+ }
559
+ button.disabled = disabled;
560
+ button.closest(".page-item")?.classList.toggle("disabled", disabled);
561
+ }
562
+
563
+ _pageWindow(current, total) {
564
+ if (total <= 7) {
565
+ return Array.from({ length: total }, (_, index) => index + 1);
566
+ }
567
+ const pages = new Set([1, total, current - 1, current, current + 1]);
568
+ const sorted = Array.from(pages)
569
+ .filter((page) => page >= 1 && page <= total)
570
+ .sort((a, b) => a - b);
571
+ const withEllipsis = [];
572
+ sorted.forEach((page, index) => {
573
+ if (index > 0 && page - sorted[index - 1] > 1) {
574
+ withEllipsis.push("…");
575
+ }
576
+ withEllipsis.push(page);
577
+ });
578
+ return withEllipsis;
579
+ }
580
+
581
+ _isColumnFixed(key) {
582
+ return this._element
583
+ .querySelector(`th[data-datatable-column="${key}"]`)
584
+ ?.hasAttribute("data-datatable-column-fixed") || false;
585
+ }
586
+
587
+ _setColumnVisible(key, visible, { syncViewToggle = true } = {}) {
588
+ if (!visible && this._isColumnFixed(key)) {
589
+ return;
590
+ }
591
+ this._element.querySelectorAll(`[data-datatable-column="${key}"], [data-datatable-detail-column="${key}"]`).forEach((cell) => {
592
+ cell.classList.toggle("datatable-col-hidden", !visible);
593
+ });
594
+ if (syncViewToggle) {
595
+ this._element.querySelectorAll(`[data-datatable-column-toggle="${key}"]`).forEach((toggle) => {
596
+ toggle.classList.toggle("active", visible);
597
+ toggle.setAttribute("aria-pressed", String(visible));
598
+ });
599
+ }
600
+ }
601
+
602
+ _setSort(key, direction) {
603
+ const validDirection = direction === "asc" || direction === "desc" ? direction : null;
604
+ this._sortKey = validDirection ? key : null;
605
+ this._sortDirection = validDirection;
606
+ this._render();
607
+ this._trigger("sort", { key: this._sortKey, direction: this._sortDirection });
608
+ }
609
+
610
+ _handleSortAction(event) {
611
+ const trigger = event.target.closest("[data-datatable-sort-action]");
612
+ if (!trigger) {
613
+ return;
614
+ }
615
+ const menu = trigger.closest(".dropdown-menu");
616
+ const key = menu
617
+ ?.closest(".datatable-sort")
618
+ ?.querySelector("[data-datatable-sort-key]")?.dataset.datatableSortKey;
619
+ if (!key) {
620
+ return;
621
+ }
622
+ const action = trigger.dataset.datatableSortAction;
623
+ if (action === "hide") {
624
+ this._setColumnVisible(key, false);
625
+ return;
626
+ }
627
+ this._setSort(key, action);
628
+ }
629
+
630
+ _handleFacetClick(event) {
631
+ const option = event.target.closest("[data-datatable-facet-option]");
632
+ const clear = event.target.closest("[data-datatable-facet-clear]");
633
+ if (!option && !clear) {
634
+ return;
635
+ }
636
+ const facetRoot = event.target.closest("[data-datatable-facet]");
637
+ const key = facetRoot?.dataset.datatableFacet;
638
+ if (!key) {
639
+ return;
640
+ }
641
+ const selected = this._facetSelections.get(key) || new Set();
642
+ if (clear) {
643
+ selected.clear();
644
+ } else {
645
+ const value = option.dataset.datatableFacetOption;
646
+ if (selected.has(value)) {
647
+ selected.delete(value);
648
+ } else {
649
+ selected.add(value);
650
+ }
651
+ }
652
+ this._facetSelections.set(key, selected);
653
+ if (this._filterMode === "picker" && selected.size === 0) {
654
+ this._visibleFacets.set(key, false);
655
+ }
656
+ this._currentPage = 1;
657
+ this._render();
658
+ this._trigger("filter");
659
+ }
660
+
661
+ _handleFacetSearch(event) {
662
+ const search = event.target.closest("[data-datatable-facet-search]");
663
+ if (!search) {
664
+ return;
665
+ }
666
+ const facetRoot = search.closest("[data-datatable-facet]");
667
+ const query = normalize(search.value);
668
+ facetRoot?.querySelectorAll("[data-datatable-facet-option]").forEach((option) => {
669
+ const label = normalize(option.querySelector("[data-datatable-facet-option-label]")?.textContent || option.textContent);
670
+ option.closest("li")?.classList.toggle("datatable-facet-search-hidden", query.length > 0 && !label.includes(query));
671
+ });
672
+ }
673
+
674
+ // Bootstrap's own dropdown keyboard navigation moves focus between
675
+ // .dropdown-item siblings, but it deliberately leaves focus alone while an
676
+ // <input> has it (arrow keys inside a text field move the caret, not a
677
+ // list). A facet's own search box needs an explicit bridge so ArrowDown
678
+ // hands off to the first result instead of doing nothing.
679
+ _handleFacetSearchKeydown(event) {
680
+ if (event.key !== "ArrowDown") {
681
+ return;
682
+ }
683
+ const search = event.target.closest("[data-datatable-facet-search]");
684
+ if (!search) {
685
+ return;
686
+ }
687
+ const facetRoot = search.closest("[data-datatable-facet]");
688
+ const options = Array.from(facetRoot?.querySelectorAll("[data-datatable-facet-option]") || []);
689
+ const firstVisible = options.find(
690
+ (option) => !option.closest("li")?.classList.contains("datatable-facet-search-hidden")
691
+ );
692
+ if (firstVisible) {
693
+ event.preventDefault();
694
+ firstVisible.focus();
695
+ }
696
+ }
697
+
698
+ _handleFilterOptionClick(event) {
699
+ const option = event.target.closest("[data-datatable-filter-option]");
700
+ if (!option) {
701
+ return;
702
+ }
703
+ const key = option.dataset.datatableFilterOptionKey;
704
+ const value = option.dataset.datatableFilterOption;
705
+ if (!key || !this._visibleFacets.has(key)) {
706
+ return;
707
+ }
708
+ this._visibleFacets.set(key, true);
709
+ const selected = this._facetSelections.get(key) || new Set();
710
+ if (selected.has(value)) {
711
+ selected.delete(value);
712
+ } else {
713
+ selected.add(value);
714
+ }
715
+ this._facetSelections.set(key, selected);
716
+ if (selected.size === 0) {
717
+ this._visibleFacets.set(key, false);
718
+ }
719
+ this._currentPage = 1;
720
+ this._render();
721
+ this._trigger("filter");
722
+ }
723
+
724
+ _showFilterPickerPanel(key = null) {
725
+ this._element.querySelectorAll("[data-datatable-filter-panel]").forEach((panel) => {
726
+ const isFacetList = panel.dataset.datatableFilterPanel === "facets";
727
+ const isRequestedOptions = panel.dataset.datatableFilterPanelKey === key;
728
+ panel.hidden = key ? !isRequestedOptions : !isFacetList;
729
+ });
730
+ this._element
731
+ .querySelector(".datatable-search-filter-menu")
732
+ ?.setAttribute("data-datatable-filter-menu-panel", key ? "options" : "facets");
733
+ }
734
+
735
+ _handleFilterPickerClick(event) {
736
+ const group = event.target.closest("[data-datatable-filter-group]");
737
+ if (group) {
738
+ const key = group.dataset.datatableFilterGroup;
739
+ this._showFilterPickerPanel(key);
740
+ const search = this._element.querySelector(`[data-datatable-filter-option-search="${key}"]`);
741
+ const option = this._element.querySelector(`[data-datatable-filter-option-key="${key}"]`);
742
+ (search || option)?.focus({ preventScroll: true });
743
+ return;
744
+ }
745
+ if (event.target.closest("[data-datatable-filter-back]")) {
746
+ this._showFilterPickerPanel();
747
+ }
748
+ }
749
+
750
+ _handleFilterOptionSearch(event) {
751
+ const search = event.target.closest("[data-datatable-filter-option-search]");
752
+ if (!search) {
753
+ return;
754
+ }
755
+ const key = search.dataset.datatableFilterOptionSearch;
756
+ const query = normalize(search.value);
757
+ this._element.querySelectorAll(`[data-datatable-filter-option-key="${key}"]`).forEach((option) => {
758
+ const label = normalize(option.querySelector("[data-datatable-filter-option-label]")?.textContent || option.textContent);
759
+ option.closest("li")?.classList.toggle("datatable-filter-option-search-hidden", query.length > 0 && !label.includes(query));
760
+ });
761
+ }
762
+
763
+ // Same bridge as _handleFacetSearchKeydown, for the filter-picker's
764
+ // drill-down option search box.
765
+ _handleFilterOptionSearchKeydown(event) {
766
+ if (event.key !== "ArrowDown") {
767
+ return;
768
+ }
769
+ const search = event.target.closest("[data-datatable-filter-option-search]");
770
+ if (!search) {
771
+ return;
772
+ }
773
+ const key = search.dataset.datatableFilterOptionSearch;
774
+ const options = Array.from(
775
+ this._element.querySelectorAll(`[data-datatable-filter-option-key="${key}"]`)
776
+ );
777
+ const firstVisible = options.find(
778
+ (option) => !option.closest("li")?.classList.contains("datatable-filter-option-search-hidden")
779
+ );
780
+ if (firstVisible) {
781
+ event.preventDefault();
782
+ firstVisible.focus();
783
+ }
784
+ }
785
+
786
+ _handleFilterToggleClick(event) {
787
+ const toggle = event.target.closest("[data-datatable-filter-toggle]");
788
+ if (!toggle) {
789
+ return;
790
+ }
791
+ const key = toggle.dataset.datatableFilterToggle;
792
+ if (!key || !this._visibleFacets.has(key)) {
793
+ return;
794
+ }
795
+ const nextVisible = !this._visibleFacets.get(key);
796
+ this._visibleFacets.set(key, nextVisible);
797
+ if (!nextVisible) {
798
+ this._facetSelections.get(key)?.clear();
799
+ this._currentPage = 1;
800
+ this._trigger("filter");
801
+ }
802
+ this._render();
803
+ }
804
+
805
+ _handleColumnToggleClick(event) {
806
+ const toggle = event.target.closest("[data-datatable-column-toggle]");
807
+ if (!toggle) {
808
+ return;
809
+ }
810
+ const key = toggle.dataset.datatableColumnToggle;
811
+ const visible = toggle.getAttribute("aria-pressed") === "true";
812
+ this._setColumnVisible(key, visible, { syncViewToggle: false });
813
+ }
814
+
815
+ _handleReset() {
816
+ const search = this._element.querySelector("[data-datatable-search]");
817
+ if (search) {
818
+ search.value = "";
819
+ }
820
+ this._searchTerm = "";
821
+ this._facetSelections.forEach((selected) => selected.clear());
822
+ if (this._filterMode === "picker") {
823
+ this._visibleFacets.forEach((value, key) => {
824
+ this._visibleFacets.set(key, false);
825
+ });
826
+ }
827
+ this._currentPage = 1;
828
+ this._render();
829
+ this._trigger("filter");
830
+ }
831
+
832
+ _handleSelectAll(event) {
833
+ const checked = event.target.checked;
834
+ const filtered = this._visibleRows();
835
+ const start = (this._currentPage - 1) * this._pageSize;
836
+ const pageRows = filtered.slice(start, start + this._pageSize);
837
+ pageRows.forEach((row) => {
838
+ if (checked) {
839
+ this._selectedIds.add(row.element.id);
840
+ } else {
841
+ this._selectedIds.delete(row.element.id);
842
+ }
843
+ });
844
+ this._render();
845
+ this._trigger("select");
846
+ }
847
+
848
+ _handleSelectRow(event) {
849
+ const checkbox = event.target.closest("[data-datatable-select-row]");
850
+ if (!checkbox) {
851
+ return;
852
+ }
853
+ const row = checkbox.closest("[data-datatable-row]");
854
+ const card = checkbox.closest("[data-datatable-card]");
855
+ const rowId = row?.id || card?.dataset.datatableCardFor;
856
+ if (!rowId) {
857
+ return;
858
+ }
859
+ if (checkbox.checked) {
860
+ this._selectedIds.add(rowId);
861
+ } else {
862
+ this._selectedIds.delete(rowId);
863
+ }
864
+ this._render();
865
+ this._trigger("select");
866
+ }
867
+
868
+ _handleBulkClick(event) {
869
+ if (event.target.closest("[data-datatable-bulk-clear]")) {
870
+ this._clearSelection();
871
+ return;
872
+ }
873
+ const updateTrigger = event.target.closest("[data-datatable-bulk-update]");
874
+ if (updateTrigger) {
875
+ const key = updateTrigger.dataset.datatableBulkUpdate;
876
+ const value = updateTrigger.dataset.datatableBulkUpdateValue;
877
+ this._applyBulkUpdate(key, value, updateTrigger);
878
+ return;
879
+ }
880
+ const actionTrigger = event.target.closest("[data-datatable-bulk-action]");
881
+ if (actionTrigger) {
882
+ const action = actionTrigger.dataset.datatableBulkAction;
883
+ if (action === "delete") {
884
+ this._applyBulkDelete();
885
+ return;
886
+ }
887
+ this._trigger(`bulk-${action}`, { ids: this.getSelectedIds() });
888
+ }
889
+ }
890
+
891
+ _clearSelection() {
892
+ this._hideBulkTooltips({ blur: true });
893
+ this._selectedIds.clear();
894
+ this._render();
895
+ this._trigger("select");
896
+ }
897
+
898
+ _applyBulkUpdate(key, value, trigger) {
899
+ const icon = trigger.querySelector("svg")?.cloneNode(true);
900
+ const label = trigger.querySelector("span")?.textContent?.trim() ?? value;
901
+ this._selectedIds.forEach((id) => {
902
+ const row = this._rows.find((candidate) => candidate.element.id === id);
903
+ if (!row) {
904
+ return;
905
+ }
906
+ row.element.setAttribute(`data-datatable-facet-${key}`, value);
907
+ row.facets[key] = value;
908
+ const cell = row.element.querySelector(`[data-datatable-column="${key}"]`);
909
+ if (!cell) {
910
+ return;
911
+ }
912
+ const labelTarget = cell.querySelector("[data-datatable-cell-label]");
913
+ if (labelTarget) {
914
+ labelTarget.textContent = label;
915
+ } else {
916
+ cell.textContent = label;
917
+ }
918
+ const currentIcon = cell.querySelector("svg");
919
+ if (currentIcon && icon) {
920
+ currentIcon.replaceWith(icon.cloneNode(true));
921
+ }
922
+ const cardValue = row.cardElement?.querySelector(`[data-datatable-detail-column="${key}"] .datatable-card-value`);
923
+ if (cardValue) {
924
+ cardValue.innerHTML = cell.innerHTML;
925
+ }
926
+ });
927
+ this._render();
928
+ this._trigger("bulk-update", { key, value, ids: this.getSelectedIds() });
929
+ }
930
+
931
+ _applyBulkDelete() {
932
+ const ids = this.getSelectedIds();
933
+ ids.forEach((id) => {
934
+ const index = this._rows.findIndex((row) => row.element.id === id);
935
+ if (index !== -1) {
936
+ this._rows[index].element.remove();
937
+ this._rows[index].cardElement?.remove();
938
+ this._rows.splice(index, 1);
939
+ }
940
+ });
941
+ this._selectedIds.clear();
942
+ this._render();
943
+ this._trigger("bulk-delete", { ids });
944
+ }
945
+
946
+ _handlePageClick(event) {
947
+ const goButton = event.target.closest("[data-datatable-page-go]");
948
+ if (goButton) {
949
+ this._currentPage = Number(goButton.dataset.datatablePageGo);
950
+ this._render();
951
+ return;
952
+ }
953
+ if (event.target.closest("[data-datatable-page-first]")) {
954
+ this._currentPage = 1;
955
+ this._render();
956
+ return;
957
+ }
958
+ if (event.target.closest("[data-datatable-page-prev]")) {
959
+ this._currentPage -= 1;
960
+ this._render();
961
+ return;
962
+ }
963
+ if (event.target.closest("[data-datatable-page-next]")) {
964
+ this._currentPage += 1;
965
+ this._render();
966
+ return;
967
+ }
968
+ if (event.target.closest("[data-datatable-page-last]")) {
969
+ this._currentPage = this._pageCount(this._visibleRows().length);
970
+ this._render();
971
+ }
972
+ }
973
+
974
+ _setPageSize(value) {
975
+ const nextPageSize = Number(value);
976
+ if (!Number.isFinite(nextPageSize) || nextPageSize <= 0) {
977
+ return;
978
+ }
979
+ this._pageSize = nextPageSize;
980
+ this._element.dataset.datatablePageSize = String(nextPageSize);
981
+ this._currentPage = 1;
982
+ this._render();
983
+ }
984
+
985
+ _bindEvents() {
986
+ const search = this._element.querySelector("[data-datatable-search]");
987
+ this._listen(search, "input", (event) => {
988
+ this._searchTerm = normalize(event.target.value);
989
+ this._currentPage = 1;
990
+ this._render();
991
+ this._trigger("filter");
992
+ });
993
+ this._listen(search, "keydown", (event) => {
994
+ if (event.key !== "Enter") {
995
+ return;
996
+ }
997
+ event.preventDefault();
998
+ const trigger = this._element.querySelector("[data-datatable-filter-menu-trigger]");
999
+ const Dropdown = this._bootstrap("Dropdown");
1000
+ if (trigger && Dropdown) {
1001
+ Dropdown.getOrCreateInstance(trigger).hide();
1002
+ }
1003
+ });
1004
+
1005
+ this._listen(this._element, "click", (event) => {
1006
+ this._handleSortAction(event);
1007
+ this._handleFilterPickerClick(event);
1008
+ this._handleFilterOptionClick(event);
1009
+ this._handleFilterToggleClick(event);
1010
+ this._handleFacetClick(event);
1011
+ this._handleColumnToggleClick(event);
1012
+ this._handlePageClick(event);
1013
+ this._handleBulkClick(event);
1014
+ if (event.target.closest("[data-datatable-empty-reset]")) {
1015
+ this._handleReset();
1016
+ }
1017
+ if (event.target.closest("[data-datatable-select-row]")) {
1018
+ this._handleSelectRow(event);
1019
+ }
1020
+ });
1021
+
1022
+ // Bound to the document, not this._element: a checkbox click does not
1023
+ // reliably move focus into the table (WebKit never focuses form
1024
+ // controls on click, and even where it does the bulk-actions bar itself
1025
+ // sits outside the table's DOM flow), so a listener scoped to the table
1026
+ // would miss Escape whenever focus is elsewhere on the page.
1027
+ this._listen(this._document, "keydown", (event) => {
1028
+ if (event.key === "Escape" && this._selectedIds.size > 0) {
1029
+ this._clearSelection();
1030
+ }
1031
+ });
1032
+
1033
+ // A hover-triggered tooltip does not hide itself when a click opens the
1034
+ // same button's dropdown (Bootstrap Tooltip only listens for
1035
+ // mouseleave/blur), so it would otherwise sit on top of the open menu.
1036
+ // hide() alone is not enough: opening the dropdown shifts layout under
1037
+ // the still-hovered cursor, which makes the browser refire mouseenter
1038
+ // and the tooltip re-show a moment later. disable()/enable() -- not
1039
+ // hide() -- is Bootstrap's own way to suppress that: it makes show() a
1040
+ // no-op for as long as the dropdown stays open, however it gets
1041
+ // re-triggered.
1042
+ this._listen(this._element, "show.bs.dropdown", (event) => {
1043
+ const tooltip = this._bootstrap("Tooltip")?.getInstance(event.target);
1044
+ tooltip?.hide();
1045
+ tooltip?.disable();
1046
+ });
1047
+ this._listen(this._element, "hidden.bs.dropdown", (event) => {
1048
+ this._bootstrap("Tooltip")?.getInstance(event.target)?.enable();
1049
+ if (event.target.matches("[data-datatable-filter-menu-trigger]")) {
1050
+ this._showFilterPickerPanel();
1051
+ }
1052
+ });
1053
+
1054
+ this._element.querySelectorAll("[data-datatable-filter-option-search]").forEach((searchInput) => {
1055
+ this._listen(searchInput, "input", (event) => this._handleFilterOptionSearch(event));
1056
+ this._listen(searchInput, "keydown", (event) => this._handleFilterOptionSearchKeydown(event));
1057
+ });
1058
+
1059
+ this._element.querySelectorAll("[data-datatable-facet-search]").forEach((searchInput) => {
1060
+ this._listen(searchInput, "input", (event) => this._handleFacetSearch(event));
1061
+ this._listen(searchInput, "keydown", (event) => this._handleFacetSearchKeydown(event));
1062
+ });
1063
+
1064
+ const resetButton = this._element.querySelector("[data-datatable-reset]");
1065
+ this._listen(resetButton, "click", () => this._handleReset());
1066
+
1067
+ this._element.querySelectorAll("[data-datatable-select-all]").forEach((selectAll) => {
1068
+ this._listen(selectAll, "change", (event) => this._handleSelectAll(event));
1069
+ });
1070
+
1071
+ const pageSizeSelect = this._element.querySelector("[data-datatable-page-size-select]");
1072
+ this._listen(pageSizeSelect, "change", (event) => this._setPageSize(event.target.value));
1073
+
1074
+ this._element.querySelectorAll("[data-datatable-page-size-option]").forEach((option) => {
1075
+ this._listen(option, "click", () => {
1076
+ this._setPageSize(option.dataset.datatablePageSizeOption);
1077
+ });
1078
+ });
1079
+ }
1080
+ }