web-mojo 2.6.4 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/admin-models.cjs.js +1 -1
  3. package/dist/admin-models.es.js +1 -1
  4. package/dist/admin.cjs.js +1 -1
  5. package/dist/admin.es.js +1 -1
  6. package/dist/auth.cjs.js +1 -1
  7. package/dist/auth.es.js +1 -1
  8. package/dist/charts.cjs.js +1 -1
  9. package/dist/charts.es.js +1 -1
  10. package/dist/chunks/ChatView-B76p_4u8.js +2 -0
  11. package/dist/chunks/ChatView-B76p_4u8.js.map +1 -0
  12. package/dist/chunks/ChatView-CC4SaAGA.js +2 -0
  13. package/dist/chunks/ChatView-CC4SaAGA.js.map +1 -0
  14. package/dist/chunks/{ContextMenu-3_bOvSwa.js → ContextMenu-B6u-y5X8.js} +2 -2
  15. package/dist/chunks/ContextMenu-B6u-y5X8.js.map +1 -0
  16. package/dist/chunks/{ContextMenu-xjj8Quls.js → ContextMenu-DkIc9twn.js} +2 -2
  17. package/dist/chunks/ContextMenu-DkIc9twn.js.map +1 -0
  18. package/dist/chunks/{DataView-CdyrhKrD.js → DataView-CnbbvE5S.js} +2 -2
  19. package/dist/chunks/{DataView-CdyrhKrD.js.map → DataView-CnbbvE5S.js.map} +1 -1
  20. package/dist/chunks/{DataView-B7XoPBZd.js → DataView-CvXW1Ewe.js} +2 -2
  21. package/dist/chunks/{DataView-B7XoPBZd.js.map → DataView-CvXW1Ewe.js.map} +1 -1
  22. package/dist/chunks/FormView-BMjKplJy.js +2 -0
  23. package/dist/chunks/FormView-BMjKplJy.js.map +1 -0
  24. package/dist/chunks/FormView-geCsNsn1.js +2 -0
  25. package/dist/chunks/FormView-geCsNsn1.js.map +1 -0
  26. package/dist/chunks/ListView-B6cE3WKB.js +2 -0
  27. package/dist/chunks/ListView-B6cE3WKB.js.map +1 -0
  28. package/dist/chunks/ListView-DXfD53sU.js +2 -0
  29. package/dist/chunks/ListView-DXfD53sU.js.map +1 -0
  30. package/dist/chunks/{MetricsCountryMapView-9e2t5sfH.js → MetricsCountryMapView-BuFNN1Ey.js} +2 -2
  31. package/dist/chunks/{MetricsCountryMapView-9e2t5sfH.js.map → MetricsCountryMapView-BuFNN1Ey.js.map} +1 -1
  32. package/dist/chunks/{MetricsCountryMapView-ki6auFBe.js → MetricsCountryMapView-Dse3HsUv.js} +2 -2
  33. package/dist/chunks/{MetricsCountryMapView-ki6auFBe.js.map → MetricsCountryMapView-Dse3HsUv.js.map} +1 -1
  34. package/dist/chunks/{Modal-Bbn-54-6.js → Modal-BmXvplWi.js} +3 -3
  35. package/dist/chunks/Modal-BmXvplWi.js.map +1 -0
  36. package/dist/chunks/{Modal-BRmaTaWa.js → Modal-DRrfsnNP.js} +3 -3
  37. package/dist/chunks/Modal-DRrfsnNP.js.map +1 -0
  38. package/dist/chunks/Passkeys-D2LFSJpQ.js +2 -0
  39. package/dist/chunks/Passkeys-D2LFSJpQ.js.map +1 -0
  40. package/dist/chunks/Passkeys-vneS9ywK.js +2 -0
  41. package/dist/chunks/Passkeys-vneS9ywK.js.map +1 -0
  42. package/dist/chunks/{TokenManager-qlmZ7eW3.js → TokenManager-DBwv-cF4.js} +2 -2
  43. package/dist/chunks/{TokenManager-qlmZ7eW3.js.map → TokenManager-DBwv-cF4.js.map} +1 -1
  44. package/dist/chunks/{TokenManager-BkW1MQ1m.js → TokenManager-DXtiygmr.js} +2 -2
  45. package/dist/chunks/{TokenManager-BkW1MQ1m.js.map → TokenManager-DXtiygmr.js.map} +1 -1
  46. package/dist/chunks/User-DJHaSCL9.js +2 -0
  47. package/dist/chunks/User-DJHaSCL9.js.map +1 -0
  48. package/dist/chunks/User-ao-iRfPc.js +2 -0
  49. package/dist/chunks/User-ao-iRfPc.js.map +1 -0
  50. package/dist/chunks/{UserProfileView-Dbei45vn.js → UserProfileView-DRpHVJtE.js} +2 -2
  51. package/dist/chunks/{UserProfileView-Dbei45vn.js.map → UserProfileView-DRpHVJtE.js.map} +1 -1
  52. package/dist/chunks/{UserProfileView-OcqSynHt.js → UserProfileView-DtIBSw3O.js} +2 -2
  53. package/dist/chunks/{UserProfileView-OcqSynHt.js.map → UserProfileView-DtIBSw3O.js.map} +1 -1
  54. package/dist/chunks/{WebApp-CIhrSvuV.js → WebApp-7COAyECf.js} +2 -2
  55. package/dist/chunks/{WebApp-CIhrSvuV.js.map → WebApp-7COAyECf.js.map} +1 -1
  56. package/dist/chunks/{WebApp-DAviV-_c.js → WebApp-CSvy-GUm.js} +2 -2
  57. package/dist/chunks/{WebApp-DAviV-_c.js.map → WebApp-CSvy-GUm.js.map} +1 -1
  58. package/dist/chunks/{admin-models-BBmATRhV.js → admin-models-DHXtrq87.js} +2 -2
  59. package/dist/chunks/{admin-models-BBmATRhV.js.map → admin-models-DHXtrq87.js.map} +1 -1
  60. package/dist/chunks/{admin-models-C-SHgZGn.js → admin-models-vhiH86Tq.js} +2 -2
  61. package/dist/chunks/{admin-models-C-SHgZGn.js.map → admin-models-vhiH86Tq.js.map} +1 -1
  62. package/dist/chunks/{exportChart-2I2hxr7t.js → exportChart-B1pjkCdm.js} +2 -2
  63. package/dist/chunks/{exportChart-2I2hxr7t.js.map → exportChart-B1pjkCdm.js.map} +1 -1
  64. package/dist/chunks/{exportChart-B5optjiT.js → exportChart-Vq4_f06o.js} +2 -2
  65. package/dist/chunks/{exportChart-B5optjiT.js.map → exportChart-Vq4_f06o.js.map} +1 -1
  66. package/dist/chunks/{index-B543iMCu.js → index-Bj5AxsEG.js} +2 -2
  67. package/dist/chunks/{index-B543iMCu.js.map → index-Bj5AxsEG.js.map} +1 -1
  68. package/dist/chunks/{index-C6reyIyu.js → index-DrmdnYWO.js} +2 -2
  69. package/dist/chunks/{index-C6reyIyu.js.map → index-DrmdnYWO.js.map} +1 -1
  70. package/dist/chunks/{version-D_d_sDRM.js → version-CSb8B3nS.js} +2 -2
  71. package/dist/chunks/{version-D_d_sDRM.js.map → version-CSb8B3nS.js.map} +1 -1
  72. package/dist/chunks/{version-DWWDfVB9.js → version-_kAV0pF7.js} +2 -2
  73. package/dist/chunks/{version-DWWDfVB9.js.map → version-_kAV0pF7.js.map} +1 -1
  74. package/dist/docit.cjs.js +1 -1
  75. package/dist/docit.es.js +1 -1
  76. package/dist/index.cjs.js +1 -1
  77. package/dist/index.es.js +1 -1
  78. package/dist/lightbox.cjs.js +1 -1
  79. package/dist/lightbox.es.js +1 -1
  80. package/dist/map.cjs.js +1 -1
  81. package/dist/map.es.js +1 -1
  82. package/dist/timeline.cjs.js +1 -1
  83. package/dist/timeline.es.js +1 -1
  84. package/dist/user-profile.cjs.js +1 -1
  85. package/dist/user-profile.es.js +1 -1
  86. package/dist/web-mojo.lite.iife.js +1406 -43
  87. package/dist/web-mojo.lite.iife.js.map +1 -1
  88. package/dist/web-mojo.lite.iife.min.js +336 -95
  89. package/dist/web-mojo.lite.iife.min.js.map +1 -1
  90. package/package.json +1 -1
  91. package/dist/chunks/ChatView-C1Vc-P_O.js +0 -2
  92. package/dist/chunks/ChatView-C1Vc-P_O.js.map +0 -1
  93. package/dist/chunks/ChatView-Cv6kH3MO.js +0 -2
  94. package/dist/chunks/ChatView-Cv6kH3MO.js.map +0 -1
  95. package/dist/chunks/ContextMenu-3_bOvSwa.js.map +0 -1
  96. package/dist/chunks/ContextMenu-xjj8Quls.js.map +0 -1
  97. package/dist/chunks/FormView-BE4qZvNA.js +0 -2
  98. package/dist/chunks/FormView-BE4qZvNA.js.map +0 -1
  99. package/dist/chunks/FormView-BqR4x5nz.js +0 -2
  100. package/dist/chunks/FormView-BqR4x5nz.js.map +0 -1
  101. package/dist/chunks/ListView-BF442PdR.js +0 -2
  102. package/dist/chunks/ListView-BF442PdR.js.map +0 -1
  103. package/dist/chunks/ListView-DqfZmgXS.js +0 -2
  104. package/dist/chunks/ListView-DqfZmgXS.js.map +0 -1
  105. package/dist/chunks/Modal-BRmaTaWa.js.map +0 -1
  106. package/dist/chunks/Modal-Bbn-54-6.js.map +0 -1
  107. package/dist/chunks/Passkeys-B_bxdIVa.js +0 -2
  108. package/dist/chunks/Passkeys-B_bxdIVa.js.map +0 -1
  109. package/dist/chunks/Passkeys-LRaiCx3H.js +0 -2
  110. package/dist/chunks/Passkeys-LRaiCx3H.js.map +0 -1
  111. package/dist/chunks/User-B7jwnBx2.js +0 -2
  112. package/dist/chunks/User-B7jwnBx2.js.map +0 -1
  113. package/dist/chunks/User-Dbw_wZAz.js +0 -2
  114. package/dist/chunks/User-Dbw_wZAz.js.map +0 -1
@@ -4693,6 +4693,7 @@ var MOJO = (function(exports) {
4693
4693
  this.template = opts.template || opts.templateUrl || "";
4694
4694
  this.data = opts.data ?? {};
4695
4695
  this.isRendering = false;
4696
+ this._renderQueued = false;
4696
4697
  this.lastRenderTime = 0;
4697
4698
  this.mounted = false;
4698
4699
  this.debug = opts.debug ?? false;
@@ -4854,6 +4855,11 @@ var MOJO = (function(exports) {
4854
4855
  // ---------------------------------------------
4855
4856
  async render(allowMount = true, container = null) {
4856
4857
  const now = Date.now();
4858
+ if (this.isRendering) {
4859
+ this._renderQueued = true;
4860
+ this._renderQueuedArgs = { allowMount, container };
4861
+ return this;
4862
+ }
4857
4863
  if (!this.canRender()) {
4858
4864
  return this;
4859
4865
  }
@@ -4879,6 +4885,12 @@ var MOJO = (function(exports) {
4879
4885
  } finally {
4880
4886
  this.isRendering = false;
4881
4887
  }
4888
+ if (this._renderQueued) {
4889
+ this._renderQueued = false;
4890
+ const queued = this._renderQueuedArgs || { allowMount: true, container: null };
4891
+ this._renderQueuedArgs = null;
4892
+ await this.render(queued.allowMount, queued.container);
4893
+ }
4882
4894
  return this;
4883
4895
  }
4884
4896
  async _renderChildren() {
@@ -5262,9 +5274,7 @@ var MOJO = (function(exports) {
5262
5274
  */
5263
5275
  escapeHtml(str) {
5264
5276
  if (typeof str !== "string") return str;
5265
- const div = document.createElement("div");
5266
- div.textContent = str;
5267
- return div.innerHTML;
5277
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5268
5278
  }
5269
5279
  contains(el) {
5270
5280
  if (typeof el === "string") {
@@ -5654,19 +5664,19 @@ var MOJO = (function(exports) {
5654
5664
  if (menuItems.length === 0) {
5655
5665
  return this.closeButton ? '<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>' : "";
5656
5666
  }
5657
- const triggerIcon = this.contextMenu.icon || "bi-three-dots-vertical";
5658
- const buttonClass = this.contextMenu.buttonClass || "btn btn-link p-1 mojo-modal-context-menu-btn";
5667
+ const triggerIcon = this.escapeHtml(this.contextMenu.icon || "bi-three-dots-vertical");
5668
+ const buttonClass = this.escapeHtml(this.contextMenu.buttonClass || "btn btn-link p-1 mojo-modal-context-menu-btn");
5659
5669
  const menuItemsHtml = menuItems.map((item) => {
5660
5670
  if (item.type === "divider") {
5661
5671
  return '<li><hr class="dropdown-divider"></li>';
5662
5672
  }
5663
- const icon = item.icon ? `<i class="${item.icon} me-2"></i>` : "";
5664
- const label = item.label || "";
5673
+ const icon = item.icon ? `<i class="${this.escapeHtml(item.icon)} me-2"></i>` : "";
5674
+ const label = this.escapeHtml(item.label || "");
5665
5675
  if (item.href) {
5666
- return `<li><a class="dropdown-item" href="${item.href}"${item.target ? ` target="${item.target}"` : ""}>${icon}${label}</a></li>`;
5676
+ return `<li><a class="dropdown-item" href="${this.escapeHtml(item.href)}"${item.target ? ` target="${this.escapeHtml(item.target)}"` : ""}>${icon}${label}</a></li>`;
5667
5677
  } else if (item.action) {
5668
- const dataAttrs = Object.keys(item).filter((key) => key.startsWith("data-")).map((key) => `${key}="${item[key]}"`).join(" ");
5669
- return `<li><a class="dropdown-item" data-action="${item.action}" ${dataAttrs}>${icon}${label}</a></li>`;
5678
+ const dataAttrs = Object.keys(item).filter((key) => key.startsWith("data-")).map((key) => `${key}="${this.escapeHtml(item[key])}"`).join(" ");
5679
+ return `<li><a class="dropdown-item" data-action="${this.escapeHtml(item.action)}" ${dataAttrs}>${icon}${label}</a></li>`;
5670
5680
  }
5671
5681
  return "";
5672
5682
  }).join("");
@@ -14555,9 +14565,7 @@ var MOJO = (function(exports) {
14555
14565
  */
14556
14566
  escapeHtml(str) {
14557
14567
  if (str == null) return "";
14558
- const div = document.createElement("div");
14559
- div.textContent = String(str);
14560
- return div.innerHTML;
14568
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
14561
14569
  }
14562
14570
  /**
14563
14571
  * Render a tooltip icon for a field
@@ -15282,9 +15290,7 @@ var MOJO = (function(exports) {
15282
15290
  */
15283
15291
  escapeHtml(str) {
15284
15292
  if (str == null) return "";
15285
- const div = document.createElement("div");
15286
- div.textContent = String(str);
15287
- return div.innerHTML;
15293
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
15288
15294
  }
15289
15295
  /**
15290
15296
  * Get form value (for integration with forms)
@@ -20372,9 +20378,7 @@ var MOJO = (function(exports) {
20372
20378
  */
20373
20379
  escapeHtml(str) {
20374
20380
  if (str == null) return "";
20375
- const div = document.createElement("div");
20376
- div.textContent = String(str);
20377
- return div.innerHTML;
20381
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
20378
20382
  }
20379
20383
  /**
20380
20384
  * Escape regex special characters
@@ -23331,6 +23335,7 @@ var MOJO = (function(exports) {
23331
23335
  this._toolbarRightMounted = false;
23332
23336
  this.dayRangeFilter = this._normalizeDayRangeFilter(options.dayRangeFilter);
23333
23337
  this.dayRangeControl = null;
23338
+ this.filterPresets = this._normalizeFilterPresets(options.filterPresets);
23334
23339
  this.exportOptions = options.exportOptions || null;
23335
23340
  this.exportSource = options.exportSource || "remote";
23336
23341
  this.persistSelection = options.persistSelection === void 0 ? this.paginationMode === "more" : options.persistSelection === true;
@@ -23352,6 +23357,17 @@ var MOJO = (function(exports) {
23352
23357
  this.groupHeaderStyle = ListView.GROUP_HEADER_STYLES.includes(options.groupHeaderStyle) ? options.groupHeaderStyle : "banner";
23353
23358
  this.groupHeaderViews = /* @__PURE__ */ new Map();
23354
23359
  this._renderOrder = [];
23360
+ this.emptyState = this._normalizeEmptyState(options.emptyState);
23361
+ this.loadingStyle = options.loadingStyle === "spinner" || options.loadingStyle === "default" ? "spinner" : "skeleton";
23362
+ this.showResultCount = options.showResultCount === true;
23363
+ this._autoRefreshMs = this._normalizeAutoRefresh(options.autoRefresh);
23364
+ this._autoRefreshTimer = null;
23365
+ this._autoRefreshPaused = false;
23366
+ this._autoRefreshHandlers = null;
23367
+ this.autoRefreshIndicator = options.autoRefreshIndicator !== false;
23368
+ this._autoRefreshPulseTimer = null;
23369
+ this.persistState = options.persistState === true;
23370
+ this.persistKey = options.persistKey || null;
23355
23371
  this.loadingMore = false;
23356
23372
  if (this._isToolbarEnabled() || this.paginationMode !== "none") {
23357
23373
  this.template = this.buildListTemplate();
@@ -23363,7 +23379,12 @@ var MOJO = (function(exports) {
23363
23379
  // Lifecycle
23364
23380
  // ============================================================
23365
23381
  async onInit() {
23382
+ const queryKeys = this.persistState && this.options.collection && this.options.collection.params ? Object.keys(this.options.collection.params) : [];
23366
23383
  this._initCollection(this.options.collection || this.options.Collection);
23384
+ if (this.persistState) {
23385
+ this._restorePersistedState(queryKeys);
23386
+ this.on("params-changed", () => this._savePersistedState());
23387
+ }
23367
23388
  if (this.dayRangeFilter) {
23368
23389
  this._seedDayRangeParams();
23369
23390
  this.dayRangeControl = new SegmentControl({
@@ -23381,10 +23402,17 @@ var MOJO = (function(exports) {
23381
23402
  if (this.collection && (this.options.fetchOnMount || !this.collection.lastFetchTime)) {
23382
23403
  this.collection.fetch();
23383
23404
  }
23405
+ this._startAutoRefresh();
23406
+ }
23407
+ async onBeforeUnmount() {
23408
+ await super.onBeforeUnmount();
23409
+ this._stopAutoRefresh();
23384
23410
  }
23385
23411
  async onBeforeRender() {
23386
23412
  this.searchValue = this.getActiveFilters().search || "";
23387
23413
  this.hasMore = this._computeHasMore();
23414
+ this.skeletonHtml = this.loadingStyle === "skeleton" && this.loading ? this._buildSkeletonHtml() : "";
23415
+ this.emptyStateHtml = this.emptyState && this.isEmpty && !this.loading ? this._buildEmptyStateHtml() : "";
23388
23416
  }
23389
23417
  async onAfterRender() {
23390
23418
  await super.onAfterRender();
@@ -23414,6 +23442,7 @@ var MOJO = (function(exports) {
23414
23442
  }
23415
23443
  if (this._isToolbarEnabled()) {
23416
23444
  this.updateFilterPills();
23445
+ this.renderFilterPresets();
23417
23446
  this.setupSearchClearListener();
23418
23447
  }
23419
23448
  }
@@ -23428,20 +23457,21 @@ var MOJO = (function(exports) {
23428
23457
  */
23429
23458
  _defaultBareTemplate() {
23430
23459
  return `
23460
+ ${this._hasFeedbackFeature() ? this._buildFeedbackStyles() : ""}
23431
23461
  <div class="list-view-container">
23432
23462
  {{#loading}}
23433
- <div class="list-loading">
23463
+ ${this._loadingContent(`<div class="list-loading">
23434
23464
  <div class="spinner-border spinner-border-sm" role="status">
23435
23465
  <span class="visually-hidden">Loading...</span>
23436
23466
  </div>
23437
23467
  Loading...
23438
- </div>
23468
+ </div>`)}
23439
23469
  {{/loading}}
23440
23470
  {{^loading}}
23441
23471
  {{#isEmpty}}
23442
- <div class="list-empty">
23472
+ ${this._emptyContent(`<div class="list-empty">
23443
23473
  {{emptyMessage}}
23444
- </div>
23474
+ </div>`)}
23445
23475
  {{/isEmpty}}
23446
23476
  {{^isEmpty}}
23447
23477
  <div class="list-items" data-container="items"></div>
@@ -23465,21 +23495,22 @@ var MOJO = (function(exports) {
23465
23495
  const pagination = this.paginationMode === "pages" ? this.buildPaginationTemplate() : "";
23466
23496
  return `
23467
23497
  <div class="list-view-wrapper">
23498
+ ${this._hasFeedbackFeature() ? this._buildFeedbackStyles() : ""}
23468
23499
  ${toolbar}
23469
23500
  <div class="list-view-container">
23470
23501
  {{#loading}}
23471
- <div class="list-loading text-center py-4">
23502
+ ${this._loadingContent(`<div class="list-loading text-center py-4">
23472
23503
  <div class="spinner-border spinner-border-sm" role="status">
23473
23504
  <span class="visually-hidden">Loading...</span>
23474
23505
  </div>
23475
23506
  Loading...
23476
- </div>
23507
+ </div>`)}
23477
23508
  {{/loading}}
23478
23509
  {{^loading}}
23479
23510
  {{#isEmpty}}
23480
- <div class="list-empty text-center py-4 text-muted">
23511
+ ${this._emptyContent(`<div class="list-empty text-center py-4 text-muted">
23481
23512
  {{emptyMessage}}
23482
- </div>
23513
+ </div>`)}
23483
23514
  {{/isEmpty}}
23484
23515
  {{^isEmpty}}
23485
23516
  <div class="list-items" data-container="items"></div>
@@ -23496,7 +23527,16 @@ var MOJO = (function(exports) {
23496
23527
  * @private
23497
23528
  */
23498
23529
  _isToolbarEnabled() {
23499
- return !!(this.title || this.eyebrow || this.searchable || this.filterable && this._hasAnyFilters() || this.sortOptions && this.sortOptions.length > 0 || this.toolbarRight || this.dayRangeFilter || this.toolbarButtons && this.toolbarButtons.length > 0 || this.options.showAdd || this.options.showExport);
23530
+ return !!(this.title || this.eyebrow || this.searchable || this.filterable && this._hasAnyFilters() || this.sortOptions && this.sortOptions.length > 0 || this.toolbarRight || this.dayRangeFilter || this.filterPresets && this.filterPresets.length > 0 || this.toolbarButtons && this.toolbarButtons.length > 0 || this._autoRefreshIndicatorEnabled() || this.options.showAdd || this.options.showExport);
23531
+ }
23532
+ /**
23533
+ * @returns {boolean} true when the auto-refresh toolbar indicator should
23534
+ * render — auto-refresh is active and it wasn't opted out. Gates both the
23535
+ * toolbar slot and its style block (off → zero markup, byte-identical).
23536
+ * @private
23537
+ */
23538
+ _autoRefreshIndicatorEnabled() {
23539
+ return this._autoRefreshMs > 0 && this.autoRefreshIndicator !== false;
23500
23540
  }
23501
23541
  _hasAnyFilters() {
23502
23542
  return this.filters && Object.keys(this.filters).length > 0 || this.additionalFilters && this.additionalFilters.length > 0;
@@ -23511,22 +23551,35 @@ var MOJO = (function(exports) {
23511
23551
  const rightSlot = this.toolbarRight ? `<div data-container="toolbar-right"></div>` : "";
23512
23552
  const dayRangeSlot = this.dayRangeFilter ? `<div data-container="toolbar-day-range"></div>` : "";
23513
23553
  const sortDropdown = this.sortOptions && this.sortOptions.length > 0 ? this.buildSortDropdownTemplate() : "";
23554
+ const hasPresets = this.filterPresets && this.filterPresets.length > 0;
23555
+ const presetsInline = hasPresets && this._presetsInline();
23556
+ const presetInlineSlot = presetsInline ? `<div data-container="filter-presets" class="preset-segment-slot"></div>` : "";
23557
+ const presetRowSlot = hasPresets && !presetsInline ? `<div data-container="filter-presets" class="preset-scroll mt-3"></div>` : "";
23558
+ const presetStyle = hasPresets ? this._buildPresetStyle() : "";
23559
+ const showIndicator = this._autoRefreshIndicatorEnabled();
23560
+ const indicatorSlot = showIndicator ? this._buildAutoRefreshIndicatorSlot() : "";
23561
+ const indicatorStyle = showIndicator ? this._buildAutoRefreshIndicatorStyle() : "";
23514
23562
  const rightGroup = `
23515
23563
  <div class="d-flex align-items-center gap-2 flex-wrap ${titleBlock ? "ms-auto" : ""}">
23516
23564
  ${this.buildActionButtonsTemplate()}
23565
+ ${indicatorSlot}
23517
23566
  ${sortDropdown}
23518
23567
  ${this.filterable ? this.buildFilterDropdownTemplate() : ""}
23519
23568
  ${this.searchable && this.searchPlacement === "toolbar" ? this.buildSearchTemplate() : ""}
23569
+ ${presetInlineSlot}
23520
23570
  ${dayRangeSlot}
23521
23571
  ${rightSlot}
23522
23572
  </div>
23523
23573
  `;
23524
23574
  return `
23525
23575
  <div class="table-action-buttons mb-3">
23576
+ ${presetStyle}
23577
+ ${indicatorStyle}
23526
23578
  <div class="d-flex align-items-center gap-3 flex-wrap">
23527
23579
  ${titleBlock}
23528
23580
  ${rightGroup}
23529
23581
  </div>
23582
+ ${presetRowSlot}
23530
23583
  <div data-container="filter-pills"></div>
23531
23584
  </div>
23532
23585
  `;
@@ -23747,7 +23800,10 @@ var MOJO = (function(exports) {
23747
23800
  `;
23748
23801
  }
23749
23802
  buildActivePills() {
23750
- if (this.hideActivePills) return "";
23803
+ const countHtml = this.showResultCount ? this._buildResultCountSummary() : "";
23804
+ if (this.hideActivePills) {
23805
+ return countHtml ? this._wrapPillRow(countHtml) : "";
23806
+ }
23751
23807
  const activeFilters = this.getActiveFilters();
23752
23808
  const hasSearch = activeFilters.search && activeFilters.search.toString().trim() !== "";
23753
23809
  let filterEntries = Object.entries(activeFilters).filter(
@@ -23756,7 +23812,9 @@ var MOJO = (function(exports) {
23756
23812
  if (this.hideActivePillNames && this.hideActivePillNames.length > 0) {
23757
23813
  filterEntries = filterEntries.filter(([key]) => !this.hideActivePillNames.includes(key));
23758
23814
  }
23759
- if (filterEntries.length === 0 && !hasSearch) return "";
23815
+ if (filterEntries.length === 0 && !hasSearch) {
23816
+ return countHtml ? this._wrapPillRow(countHtml) : "";
23817
+ }
23760
23818
  const pills = filterEntries.map(([paramKey, value]) => {
23761
23819
  const { field } = parseFilterKey(paramKey);
23762
23820
  const label = this.getFilterLabel(field);
@@ -23795,13 +23853,30 @@ var MOJO = (function(exports) {
23795
23853
  <div class="row mt-2">
23796
23854
  <div class="col-12">
23797
23855
  <div class="d-flex flex-wrap align-items-center">
23798
- ${pills}
23856
+ ${countHtml}${pills}
23799
23857
  ${clearAllButton}
23800
23858
  </div>
23801
23859
  </div>
23802
23860
  </div>
23803
23861
  `;
23804
23862
  }
23863
+ /**
23864
+ * Wrap arbitrary inner markup in the standard active-pills row shell. Used
23865
+ * for the count-only case (WM-033) where the result-count summary renders
23866
+ * without any filter pills.
23867
+ * @private
23868
+ */
23869
+ _wrapPillRow(inner) {
23870
+ return `
23871
+ <div class="row mt-2">
23872
+ <div class="col-12">
23873
+ <div class="d-flex flex-wrap align-items-center">
23874
+ ${inner}
23875
+ </div>
23876
+ </div>
23877
+ </div>
23878
+ `;
23879
+ }
23805
23880
  buildPaginationTemplate() {
23806
23881
  return `
23807
23882
  <div class="table-status-bar mt-3">
@@ -23848,6 +23923,215 @@ var MOJO = (function(exports) {
23848
23923
  `;
23849
23924
  }
23850
23925
  // ============================================================
23926
+ // Feedback states (WM-033) — rich empty, skeletons, result count
23927
+ // ============================================================
23928
+ /**
23929
+ * Normalize the `emptyState:` option into `{ icon, title, message, action }`
23930
+ * or null when absent. `action` is `{ label, action, icon }` where `action`
23931
+ * is a kebab-case `data-action` name (routed like any toolbar action, e.g.
23932
+ * `'add'` → onActionAdd). Returns null for a non-object so the feature stays
23933
+ * inert and `emptyMessage` remains the fallback.
23934
+ * @private
23935
+ */
23936
+ _normalizeEmptyState(raw) {
23937
+ if (!raw || typeof raw !== "object") return null;
23938
+ let action = null;
23939
+ if (raw.action && typeof raw.action === "object" && raw.action.action) {
23940
+ action = {
23941
+ label: raw.action.label || "Action",
23942
+ action: raw.action.action,
23943
+ icon: raw.action.icon || null
23944
+ };
23945
+ }
23946
+ return {
23947
+ icon: raw.icon || "inbox",
23948
+ title: raw.title || null,
23949
+ message: raw.message || null,
23950
+ action
23951
+ };
23952
+ }
23953
+ /**
23954
+ * True when any of the three opt-in feedback features is enabled. Gates the
23955
+ * one-time `<style>` block emission so opt-out pages carry zero extra CSS.
23956
+ * @private
23957
+ */
23958
+ _hasFeedbackFeature() {
23959
+ return !!this.emptyState || this.loadingStyle === "skeleton" || this.showResultCount;
23960
+ }
23961
+ /**
23962
+ * Loading-block content for the body template. Returns the unescaped
23963
+ * skeleton slot when `loadingStyle: 'skeleton'`, otherwise the caller's
23964
+ * default spinner markup verbatim (byte-identical opt-out).
23965
+ * @private
23966
+ */
23967
+ _loadingContent(defaultMarkup) {
23968
+ return this.loadingStyle === "skeleton" ? "{{{skeletonHtml}}}" : defaultMarkup;
23969
+ }
23970
+ /**
23971
+ * Empty-block content for the body template. Returns the unescaped rich
23972
+ * empty-state slot when `emptyState` is configured, otherwise the caller's
23973
+ * default `emptyMessage` markup verbatim (byte-identical opt-out).
23974
+ * @private
23975
+ */
23976
+ _emptyContent(defaultMarkup) {
23977
+ return this.emptyState ? "{{{emptyStateHtml}}}" : defaultMarkup;
23978
+ }
23979
+ /**
23980
+ * Skeleton row count: `collection.params.size` capped at 8, defaulting to 5
23981
+ * when size isn't set. Shared by the ListView (card) and TableView (row)
23982
+ * skeleton builders.
23983
+ * @private
23984
+ */
23985
+ _skeletonRowCount() {
23986
+ const size = this.collection?.params?.size;
23987
+ const n = typeof size === "number" && size > 0 ? size : 5;
23988
+ return Math.min(n, 8);
23989
+ }
23990
+ /**
23991
+ * ListView skeleton — stacked card silhouettes. TableView overrides this to
23992
+ * emit a column-matched `<table>`. Called from onBeforeRender only while
23993
+ * loading + `loadingStyle: 'skeleton'`.
23994
+ * @private
23995
+ */
23996
+ _buildSkeletonHtml() {
23997
+ const rows = this._skeletonRowCount();
23998
+ let cards = "";
23999
+ for (let i = 0; i < rows; i++) {
24000
+ cards += '<div class="list-skeleton-card"><span class="skeleton-line w-40"></span><span class="skeleton-line w-90"></span><span class="skeleton-line w-60"></span></div>';
24001
+ }
24002
+ return `<div class="list-skeleton" aria-hidden="true">${cards}</div>`;
24003
+ }
24004
+ /**
24005
+ * Build the rich empty-state panel. Branches on `getActiveFilters()`:
24006
+ * non-empty → the filtered variant ("No results match your filters" +
24007
+ * Clear filters button reusing onActionClearAllFilters); empty → the
24008
+ * configured truly-empty content with its optional CTA. Search counts as
24009
+ * an active filter. Returns '' when `emptyState` isn't configured.
24010
+ * @private
24011
+ */
24012
+ _buildEmptyStateHtml() {
24013
+ const es = this.emptyState;
24014
+ if (!es) return "";
24015
+ const activeCount = Object.keys(this.getActiveFilters()).length;
24016
+ const isFiltered = activeCount > 0;
24017
+ let icon;
24018
+ let title;
24019
+ let message;
24020
+ let buttonHtml;
24021
+ if (isFiltered) {
24022
+ icon = "funnel";
24023
+ title = "No results match your filters";
24024
+ const plural = activeCount === 1 ? "filter" : "filters";
24025
+ message = `No items match the ${activeCount} active ${plural}. Try widening or removing a filter.`;
24026
+ buttonHtml = '<button class="btn btn-outline-primary" data-action="clear-all-filters"><i class="bi bi-x-circle me-1"></i>Clear filters</button>';
24027
+ } else {
24028
+ icon = es.icon || "inbox";
24029
+ title = es.title || this.emptyMessage;
24030
+ message = es.message || "";
24031
+ if (es.action) {
24032
+ const iconHtml = es.action.icon ? `<i class="bi ${this.escapeHtml(es.action.icon)} me-1"></i>` : "";
24033
+ buttonHtml = `<button class="btn btn-primary" data-action="${this.escapeHtml(es.action.action)}">${iconHtml}${this.escapeHtml(es.action.label)}</button>`;
24034
+ } else {
24035
+ buttonHtml = "";
24036
+ }
24037
+ }
24038
+ const titleHtml = title ? `<div class="empty-state-title h6">${this.escapeHtml(title)}</div>` : "";
24039
+ const messageHtml = message ? `<p class="empty-state-message">${this.escapeHtml(message)}</p>` : "";
24040
+ return `
24041
+ <div class="table-empty-state">
24042
+ <span class="empty-state-icon"><i class="bi bi-${this.escapeHtml(icon)}"></i></span>
24043
+ ${titleHtml}
24044
+ ${messageHtml}
24045
+ ${buttonHtml}
24046
+ </div>
24047
+ `;
24048
+ }
24049
+ /**
24050
+ * Build the "Showing N of M" result-count summary from `collection.meta`
24051
+ * (`count`) and the current loaded length. Appends a "· filtered" marker
24052
+ * when any filter is active. Returns '' when `showResultCount` is off or the
24053
+ * total count isn't known yet. Rendered inside the filter-pills row by
24054
+ * `buildActivePills`.
24055
+ * @private
24056
+ */
24057
+ _buildResultCountSummary() {
24058
+ if (!this.showResultCount || !this.collection) return "";
24059
+ const meta = this.collection.meta || {};
24060
+ const total = typeof meta.count === "number" ? meta.count : null;
24061
+ if (total === null) return "";
24062
+ const shown = this.collection.length();
24063
+ const isFiltered = Object.keys(this.getActiveFilters()).length > 0;
24064
+ const fmt = (n) => Number(n).toLocaleString();
24065
+ const filteredSuffix = isFiltered ? ' <span class="count-filtered">· filtered</span>' : "";
24066
+ return `<span class="result-count-summary me-2 mb-1">Showing <strong>${fmt(shown)}</strong> of <strong>${fmt(total)}</strong>${filteredSuffix}</span>`;
24067
+ }
24068
+ /**
24069
+ * One-time `<style>` block for the three feedback features (skeleton
24070
+ * shimmer, empty-state chip, result-count summary). Token-based so both
24071
+ * themes track automatically; the shimmer's moving band is a component tint
24072
+ * (black-on-light) with its rgba(255,255,255) dark companion, and the
24073
+ * animation is disabled under prefers-reduced-motion. Emitted once per
24074
+ * template, only when `_hasFeedbackFeature()`.
24075
+ * @private
24076
+ */
24077
+ _buildFeedbackStyles() {
24078
+ return `
24079
+ <style>
24080
+ .table-empty-state { text-align: center; padding: 3rem 1.5rem; }
24081
+ .table-empty-state .empty-state-icon {
24082
+ display: inline-flex; align-items: center; justify-content: center;
24083
+ width: 4rem; height: 4rem; border-radius: 50%;
24084
+ margin-bottom: 1rem; font-size: 1.75rem;
24085
+ background: var(--bs-secondary-bg);
24086
+ color: var(--bs-secondary-color);
24087
+ border: 1px solid var(--bs-border-color);
24088
+ }
24089
+ .table-empty-state .empty-state-title {
24090
+ font-weight: 600; color: var(--bs-emphasis-color); margin-bottom: 0.35rem;
24091
+ }
24092
+ .table-empty-state .empty-state-message {
24093
+ color: var(--bs-secondary-color); max-width: 26rem; margin: 0 auto 1.25rem;
24094
+ }
24095
+
24096
+ .skeleton-line {
24097
+ display: block; height: 0.85rem; border-radius: 0.35rem;
24098
+ background-color: var(--bs-secondary-bg);
24099
+ background-image: linear-gradient(90deg,
24100
+ rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.06) 50%, rgba(0, 0, 0, 0) 100%);
24101
+ background-size: 200% 100%; background-repeat: no-repeat;
24102
+ animation: mojo-skeleton-shimmer 1.4s ease-in-out infinite;
24103
+ }
24104
+ [data-bs-theme="dark"] .skeleton-line {
24105
+ background-image: linear-gradient(90deg,
24106
+ rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.07) 50%, rgba(255, 255, 255, 0) 100%);
24107
+ }
24108
+ .skeleton-line.w-90 { width: 90%; }
24109
+ .skeleton-line.w-75 { width: 75%; }
24110
+ .skeleton-line.w-60 { width: 60%; }
24111
+ .skeleton-line.w-40 { width: 40%; }
24112
+ .skeleton-line.w-25 { width: 25%; }
24113
+ .skeleton-line.skeleton-pill { height: 1.35rem; width: 4rem; border-radius: 999px; }
24114
+ .list-skeleton-card {
24115
+ display: flex; flex-direction: column; gap: 0.5rem;
24116
+ padding: 0.85rem 1rem; margin-bottom: 0.5rem;
24117
+ border: 1px solid var(--bs-border-color-translucent);
24118
+ border-radius: 0.5rem;
24119
+ }
24120
+ @keyframes mojo-skeleton-shimmer {
24121
+ 0% { background-position: 150% 0; }
24122
+ 100% { background-position: -50% 0; }
24123
+ }
24124
+ @media (prefers-reduced-motion: reduce) {
24125
+ .skeleton-line { animation: none; }
24126
+ }
24127
+
24128
+ .result-count-summary { font-size: 0.8rem; color: var(--bs-secondary-color); }
24129
+ .result-count-summary strong { color: var(--bs-emphasis-color); font-weight: 600; }
24130
+ .result-count-summary .count-filtered { color: var(--bs-primary); }
24131
+ </style>
24132
+ `;
24133
+ }
24134
+ // ============================================================
23851
24135
  // Collection wiring (existing + persistSelection-aware)
23852
24136
  // ============================================================
23853
24137
  _initCollection(collectionOrClass) {
@@ -23942,6 +24226,325 @@ var MOJO = (function(exports) {
23942
24226
  if (!silent) this._onDayRangeChange({ value, previous });
23943
24227
  return true;
23944
24228
  }
24229
+ // ============================================================
24230
+ // Filter presets (compact joined btn-group segment)
24231
+ //
24232
+ // Author-defined "common queries" bundled behind a toolbar segment.
24233
+ // Mutual-exclusion + toggle-off only; active state is derived from
24234
+ // strict param matching against getActiveFilters(). The `'@me'` token
24235
+ // in a preset's params resolves to the active user's id at apply AND
24236
+ // match time (skipped when no active user).
24237
+ // ============================================================
24238
+ /**
24239
+ * Normalize the `filterPresets:` option into a clean array of
24240
+ * `{ key, label, icon, params, description }`. Drops entries without a
24241
+ * `key`, warns on (and drops) duplicate keys. Returns `[]` when the
24242
+ * option is absent / empty so the whole feature stays inert.
24243
+ * @private
24244
+ */
24245
+ _normalizeFilterPresets(raw) {
24246
+ if (!Array.isArray(raw) || raw.length === 0) return [];
24247
+ const seen = /* @__PURE__ */ new Set();
24248
+ const presets = [];
24249
+ raw.forEach((p) => {
24250
+ if (!p || !p.key) return;
24251
+ if (seen.has(p.key)) {
24252
+ console.warn(`ListView: duplicate filterPresets key '${p.key}' — ignoring the later one.`);
24253
+ return;
24254
+ }
24255
+ seen.add(p.key);
24256
+ presets.push({
24257
+ key: p.key,
24258
+ label: p.label || p.key,
24259
+ icon: p.icon || null,
24260
+ params: p.params && typeof p.params === "object" ? p.params : {},
24261
+ description: p.description || null
24262
+ });
24263
+ });
24264
+ return presets;
24265
+ }
24266
+ /**
24267
+ * Placement branch: ≤4 presets render inline (sibling of the day-range
24268
+ * segment); 5+ get their own scrollable row above the filter pills.
24269
+ * @private
24270
+ */
24271
+ _presetsInline() {
24272
+ return this.filterPresets.length <= 4;
24273
+ }
24274
+ _getPreset(key) {
24275
+ return this.filterPresets.find((p) => p.key === key) || null;
24276
+ }
24277
+ /**
24278
+ * Resolve a preset's raw params into concrete filter values. The `'@me'`
24279
+ * token resolves to the active user's id; when no active user can be
24280
+ * resolved that param is skipped (both at apply and match time so the
24281
+ * derived active state stays consistent).
24282
+ * @private
24283
+ */
24284
+ _resolvePresetParams(preset) {
24285
+ const out = {};
24286
+ const raw = preset?.params || {};
24287
+ Object.keys(raw).forEach((key) => {
24288
+ const value = raw[key];
24289
+ if (value === "@me") {
24290
+ const uid = this.getApp()?.activeUser?.id;
24291
+ if (uid === void 0 || uid === null) return;
24292
+ out[key] = uid;
24293
+ } else {
24294
+ out[key] = value;
24295
+ }
24296
+ });
24297
+ return out;
24298
+ }
24299
+ /**
24300
+ * A preset is active when every one of its resolved params strictly
24301
+ * matches the current active filters. Matching goes through
24302
+ * getActiveFilters() (not raw collection.params) so `__in` collapsing is
24303
+ * honored. A preset whose params all resolve away (e.g. only `'@me'`
24304
+ * with no active user) never matches.
24305
+ * @private
24306
+ */
24307
+ _presetMatches(preset) {
24308
+ const resolved = this._resolvePresetParams(preset);
24309
+ const keys = Object.keys(resolved);
24310
+ if (keys.length === 0) return false;
24311
+ const active = this.getActiveFilters();
24312
+ return keys.every((key) => {
24313
+ const value = resolved[key];
24314
+ if (Array.isArray(value)) {
24315
+ if (value.length === 0) return true;
24316
+ const { field } = parseFilterKey(key);
24317
+ if (value.length === 1) {
24318
+ return active[field] !== void 0 && String(active[field]) === String(value[0]);
24319
+ }
24320
+ const inVal = active[`${field}__in`];
24321
+ return inVal !== void 0 && String(inVal) === value.join(",");
24322
+ }
24323
+ return active[key] !== void 0 && String(active[key]) === String(value);
24324
+ });
24325
+ }
24326
+ /**
24327
+ * Null out every resolved param key belonging to a preset (used by
24328
+ * toggle-off and by mutual-exclusion when switching presets).
24329
+ * @private
24330
+ */
24331
+ _clearPresetParams(preset) {
24332
+ const resolved = this._resolvePresetParams(preset);
24333
+ Object.keys(resolved).forEach((key) => this.setFilter(key, null));
24334
+ }
24335
+ /**
24336
+ * Get the currently-active preset's full config object, or null. Active
24337
+ * state is derived — no stored "active preset" field to drift.
24338
+ *
24339
+ * Most-specific-match-wins: when several presets match at once (e.g. one
24340
+ * preset's params are a strict subset of another's — Errors `{level__gte:4}`
24341
+ * ⊂ Auth `{path__icontains:'/auth', level__gte:4}`), the one with the MOST
24342
+ * resolved param keys is returned so the correct (narrower) chip highlights
24343
+ * and mutual-exclusion/toggle logic reads it. Ties resolve to the first
24344
+ * matching preset in array order.
24345
+ * @returns {object|null}
24346
+ */
24347
+ getActivePreset() {
24348
+ if (!this.filterPresets || this.filterPresets.length === 0) return null;
24349
+ let best = null;
24350
+ let bestCount = -1;
24351
+ for (const preset of this.filterPresets) {
24352
+ if (!this._presetMatches(preset)) continue;
24353
+ const count = Object.keys(this._resolvePresetParams(preset)).length;
24354
+ if (count > bestCount) {
24355
+ best = preset;
24356
+ bestCount = count;
24357
+ }
24358
+ }
24359
+ return best;
24360
+ }
24361
+ /**
24362
+ * Apply a preset by key. Toggles off if it's already the active preset;
24363
+ * otherwise clears the previously-active preset's params (mutual
24364
+ * exclusion), applies the new bundle, refetches, and repaints.
24365
+ * @param {string} key
24366
+ * @returns {Promise<boolean>} false when the key is unknown.
24367
+ */
24368
+ async applyPreset(key) {
24369
+ const preset = this._getPreset(key);
24370
+ if (!preset) return false;
24371
+ const active = this.getActivePreset();
24372
+ if (active && active.key === preset.key) {
24373
+ await this.clearPreset();
24374
+ return true;
24375
+ }
24376
+ if (active) this._clearPresetParams(active);
24377
+ const resolved = this._resolvePresetParams(preset);
24378
+ Object.keys(resolved).forEach((k) => this.setFilter(k, resolved[k]));
24379
+ await this.applyFilters();
24380
+ this.renderFilterPresets();
24381
+ this.emit("preset:change", { key: preset.key, params: resolved });
24382
+ return true;
24383
+ }
24384
+ /**
24385
+ * Clear the active preset (removes its params). No-op when nothing is
24386
+ * active, but still repaints and emits `preset:change` with null.
24387
+ * @returns {Promise<void>}
24388
+ */
24389
+ async clearPreset() {
24390
+ const active = this.getActivePreset();
24391
+ if (active) {
24392
+ this._clearPresetParams(active);
24393
+ await this.applyFilters();
24394
+ }
24395
+ this.renderFilterPresets();
24396
+ this.emit("preset:change", null);
24397
+ }
24398
+ /**
24399
+ * Toolbar action — apply/toggle a preset from its segment button.
24400
+ */
24401
+ async onActionApplyPreset(event, element) {
24402
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
24403
+ const key = element?.getAttribute("data-preset-key");
24404
+ if (!key) return;
24405
+ await this.applyPreset(key);
24406
+ }
24407
+ /**
24408
+ * Fill the `filter-presets` container with the joined segment, painting
24409
+ * the derived-active button as `btn-primary`. innerHTML re-render pattern
24410
+ * mirroring `updateFilterPills`. No-op when presets aren't configured or
24411
+ * the container isn't in the DOM yet.
24412
+ */
24413
+ renderFilterPresets() {
24414
+ if (!this.filterPresets || this.filterPresets.length === 0) return;
24415
+ const container = this.element?.querySelector('[data-container="filter-presets"]');
24416
+ if (!container) return;
24417
+ container.innerHTML = this._buildPresetSegment();
24418
+ }
24419
+ /**
24420
+ * Build the joined `btn-group btn-sm preset-segment` markup with the
24421
+ * active button styled `btn-primary` (derived from param matching).
24422
+ * @private
24423
+ */
24424
+ _buildPresetSegment() {
24425
+ const active = this.getActivePreset();
24426
+ const activeKey = active ? active.key : null;
24427
+ const buttons = this.filterPresets.map((preset) => {
24428
+ const isActive = preset.key === activeKey;
24429
+ const cls = isActive ? "btn btn-primary" : "btn btn-outline-secondary";
24430
+ const icon = preset.icon ? `<i class="bi ${this.escapeHtml(preset.icon)}"></i> ` : "";
24431
+ const titleAttr = preset.description ? ` title="${this.escapeHtml(preset.description)}"` : "";
24432
+ return `<button type="button"
24433
+ class="${cls}"
24434
+ data-action="apply-preset"
24435
+ data-preset-key="${this.escapeHtml(preset.key)}"
24436
+ aria-pressed="${isActive ? "true" : "false"}"${titleAttr}>${icon}${this.escapeHtml(preset.label)}</button>`;
24437
+ }).join("");
24438
+ return `<div class="btn-group btn-group-sm preset-segment" role="group" aria-label="Filter presets">${buttons}</div>`;
24439
+ }
24440
+ /**
24441
+ * Inline `<style>` for the preset segment. Token-based (auto-tracks both
24442
+ * themes); the single scrollbar-thumb literal carries its dark companion.
24443
+ * Emitted once from the toolbar template (only when presets exist).
24444
+ * @private
24445
+ */
24446
+ _buildPresetStyle() {
24447
+ return `
24448
+ <style>
24449
+ .preset-segment .btn {
24450
+ --bs-btn-padding-y: 0.25rem;
24451
+ --bs-btn-padding-x: 0.6rem;
24452
+ font-size: 0.8125rem;
24453
+ font-weight: 500;
24454
+ }
24455
+ .preset-segment .btn .bi { font-size: 0.8rem; }
24456
+
24457
+ /* Touch: bump the tap target only on coarse pointers — desktop stays compact. */
24458
+ @media (pointer: coarse) {
24459
+ .preset-segment .btn {
24460
+ --bs-btn-padding-y: 0.5rem;
24461
+ --bs-btn-padding-x: 0.85rem;
24462
+ }
24463
+ }
24464
+
24465
+ /* 5+ presets: own row, scroll horizontally instead of wrapping. */
24466
+ .preset-scroll {
24467
+ overflow-x: auto;
24468
+ -webkit-overflow-scrolling: touch;
24469
+ scrollbar-width: thin;
24470
+ padding-bottom: 0.2rem;
24471
+ }
24472
+ .preset-scroll .btn-group { flex-wrap: nowrap; }
24473
+ .preset-scroll .btn { white-space: nowrap; }
24474
+ .preset-scroll::-webkit-scrollbar { height: 6px; }
24475
+ .preset-scroll::-webkit-scrollbar-thumb {
24476
+ background: rgba(0, 0, 0, 0.15);
24477
+ border-radius: 999px;
24478
+ }
24479
+ [data-bs-theme="dark"] .preset-scroll::-webkit-scrollbar-thumb {
24480
+ background: rgba(255, 255, 255, 0.12);
24481
+ }
24482
+ </style>
24483
+ `;
24484
+ }
24485
+ /**
24486
+ * Compact, quiet auto-refresh indicator for the toolbar right-group: a
24487
+ * muted `bi-arrow-repeat` icon with no button chrome and no text label. The
24488
+ * icon is `aria-hidden`; a visually-hidden span carries the text
24489
+ * alternative for assistive tech. `_pulseAutoRefreshIndicator()` adds the
24490
+ * `is-refreshing` class on each successful tick to fire a brief spin.
24491
+ * @private
24492
+ */
24493
+ _buildAutoRefreshIndicatorSlot() {
24494
+ const secs = Math.round(this._autoRefreshMs / 1e3);
24495
+ const label = `Auto-refresh: ${secs}s`;
24496
+ return `<span class="mojo-autorefresh-indicator" data-autorefresh-indicator title="${this.escapeHtml(label)}">
24497
+ <i class="bi bi-arrow-repeat" aria-hidden="true"></i>
24498
+ <span class="visually-hidden">${this.escapeHtml(label)}</span>
24499
+ </span>`;
24500
+ }
24501
+ /**
24502
+ * Inline `<style>` for the auto-refresh indicator. Muted via
24503
+ * `--bs-secondary-color` (auto-tracks both themes — no literals). The spin
24504
+ * animation only plays while `is-refreshing` is set and is suppressed under
24505
+ * `prefers-reduced-motion`. Emitted once, only when the indicator renders.
24506
+ * @private
24507
+ */
24508
+ _buildAutoRefreshIndicatorStyle() {
24509
+ return `
24510
+ <style>
24511
+ .mojo-autorefresh-indicator {
24512
+ display: inline-flex;
24513
+ align-items: center;
24514
+ color: var(--bs-secondary-color);
24515
+ font-size: 0.9rem;
24516
+ line-height: 1;
24517
+ }
24518
+ .mojo-autorefresh-indicator .bi { display: block; }
24519
+ .mojo-autorefresh-indicator.is-refreshing .bi {
24520
+ animation: mojo-autorefresh-spin 0.6s ease;
24521
+ }
24522
+ @keyframes mojo-autorefresh-spin {
24523
+ from { transform: rotate(0deg); }
24524
+ to { transform: rotate(360deg); }
24525
+ }
24526
+ @media (prefers-reduced-motion: reduce) {
24527
+ .mojo-autorefresh-indicator.is-refreshing .bi { animation: none; }
24528
+ }
24529
+ </style>
24530
+ `;
24531
+ }
24532
+ /**
24533
+ * Flash the toolbar auto-refresh indicator: add the `is-refreshing` class
24534
+ * (fires the CSS spin) and strip it after ~600ms. Coalesces rapid ticks by
24535
+ * resetting its own timer; a no-op when the indicator isn't in the DOM.
24536
+ * @private
24537
+ */
24538
+ _pulseAutoRefreshIndicator() {
24539
+ const el = this.element?.querySelector("[data-autorefresh-indicator]");
24540
+ if (!el) return;
24541
+ el.classList.add("is-refreshing");
24542
+ if (this._autoRefreshPulseTimer) clearTimeout(this._autoRefreshPulseTimer);
24543
+ this._autoRefreshPulseTimer = setTimeout(() => {
24544
+ el.classList.remove("is-refreshing");
24545
+ this._autoRefreshPulseTimer = null;
24546
+ }, 600);
24547
+ }
23945
24548
  setCollection(collection) {
23946
24549
  if (this.collection === collection) return this;
23947
24550
  if (this.collection) {
@@ -25183,9 +25786,304 @@ var MOJO = (function(exports) {
25183
25786
  return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
25184
25787
  }
25185
25788
  // ============================================================
25789
+ // Auto-refresh (WM-034) — opt-in interval refetch with smart pause
25790
+ // ============================================================
25791
+ /**
25792
+ * Normalize the `autoRefresh:` option into an interval in milliseconds.
25793
+ * Accepts a positive number of seconds, clamped to a 5s floor to prevent
25794
+ * hammering the API. Anything else (absent / non-number / ≤0) → 0, which
25795
+ * disables the feature entirely (no timer, no listeners).
25796
+ * @private
25797
+ */
25798
+ _normalizeAutoRefresh(raw) {
25799
+ if (typeof raw !== "number" || !isFinite(raw) || raw <= 0) return 0;
25800
+ return Math.max(5, raw) * 1e3;
25801
+ }
25802
+ /**
25803
+ * Start the auto-refresh interval + focus/visibility listeners. No-op when
25804
+ * `autoRefresh` wasn't configured, and idempotent — a second call while a
25805
+ * timer is already live returns immediately, so cached-page re-entries
25806
+ * (which re-fire onAfterMount) never stack a second interval.
25807
+ * @private
25808
+ */
25809
+ _startAutoRefresh() {
25810
+ if (!this._autoRefreshMs) return;
25811
+ if (this._autoRefreshTimer) return;
25812
+ this._autoRefreshPaused = this._autoRefreshDocHidden();
25813
+ this._bindAutoRefreshListeners();
25814
+ this._autoRefreshTimer = setInterval(() => this._autoRefreshTick(), this._autoRefreshMs);
25815
+ }
25816
+ /**
25817
+ * Fully tear down the timer and its focus/visibility listeners. Safe to
25818
+ * call when nothing is running.
25819
+ * @private
25820
+ */
25821
+ _stopAutoRefresh() {
25822
+ if (this._autoRefreshTimer) {
25823
+ clearInterval(this._autoRefreshTimer);
25824
+ this._autoRefreshTimer = null;
25825
+ }
25826
+ if (this._autoRefreshPulseTimer) {
25827
+ clearTimeout(this._autoRefreshPulseTimer);
25828
+ this._autoRefreshPulseTimer = null;
25829
+ }
25830
+ this._unbindAutoRefreshListeners();
25831
+ this._autoRefreshPaused = false;
25832
+ }
25833
+ /**
25834
+ * Bind window focus/blur + document visibilitychange listeners so the
25835
+ * timer can pause while the tab is backgrounded and resume (with an
25836
+ * immediate refetch) when the user returns. Idempotent.
25837
+ * @private
25838
+ */
25839
+ _bindAutoRefreshListeners() {
25840
+ if (this._autoRefreshHandlers) return;
25841
+ const onBlur = () => {
25842
+ this._autoRefreshPaused = true;
25843
+ };
25844
+ const onFocus = () => {
25845
+ this._autoRefreshPaused = false;
25846
+ this._autoRefreshResume();
25847
+ };
25848
+ const onVisibility = () => {
25849
+ const hidden = this._autoRefreshDocHidden();
25850
+ this._autoRefreshPaused = hidden;
25851
+ if (!hidden) this._autoRefreshResume();
25852
+ };
25853
+ this._autoRefreshHandlers = { onBlur, onFocus, onVisibility };
25854
+ if (typeof window !== "undefined") {
25855
+ window.addEventListener("blur", onBlur);
25856
+ window.addEventListener("focus", onFocus);
25857
+ }
25858
+ if (typeof document !== "undefined") {
25859
+ document.addEventListener("visibilitychange", onVisibility);
25860
+ }
25861
+ }
25862
+ /** @private */
25863
+ _unbindAutoRefreshListeners() {
25864
+ const h = this._autoRefreshHandlers;
25865
+ if (!h) return;
25866
+ if (typeof window !== "undefined") {
25867
+ window.removeEventListener("blur", h.onBlur);
25868
+ window.removeEventListener("focus", h.onFocus);
25869
+ }
25870
+ if (typeof document !== "undefined") {
25871
+ document.removeEventListener("visibilitychange", h.onVisibility);
25872
+ }
25873
+ this._autoRefreshHandlers = null;
25874
+ }
25875
+ /** @private */
25876
+ _autoRefreshDocHidden() {
25877
+ return typeof document !== "undefined" && (document.visibilityState === "hidden" || document.hidden === true);
25878
+ }
25879
+ /**
25880
+ * Regaining focus / visibility fires an immediate guarded refetch, then the
25881
+ * existing interval carries on at its normal cadence. No-op when no timer
25882
+ * is live (so a stray focus event after teardown can't refetch).
25883
+ * @private
25884
+ */
25885
+ _autoRefreshResume() {
25886
+ if (!this._autoRefreshTimer) return;
25887
+ this._autoRefreshTick();
25888
+ }
25889
+ /**
25890
+ * One interval tick. Self-terminates if the view is no longer mounted
25891
+ * (covers a cached Page that exited without an unmount hook reaching this
25892
+ * child). Otherwise skips silently while any pause condition holds, then
25893
+ * issues a silent `collection.fetch()` that preserves current params —
25894
+ * Collection's own dedup/cancel machinery handles overlap.
25895
+ * @private
25896
+ */
25897
+ _autoRefreshTick() {
25898
+ if (!this.isMounted()) {
25899
+ this._stopAutoRefresh();
25900
+ return;
25901
+ }
25902
+ if (this._autoRefreshShouldSkip()) return;
25903
+ if (!this.collection || !this.collection.restEnabled) return;
25904
+ Promise.resolve(this.collection.fetch()).then(() => this._pulseAutoRefreshIndicator()).catch((err) => {
25905
+ console.warn("ListView autoRefresh: fetch failed", err);
25906
+ });
25907
+ }
25908
+ /**
25909
+ * Pause predicate evaluated at tick time. Base ListView pauses while the
25910
+ * tab is hidden/blurred or a selection is active. TableView overrides this
25911
+ * to also pause during an inline cell edit or an open row context menu.
25912
+ * @protected
25913
+ * @returns {boolean} true → skip this tick (do not clear the timer).
25914
+ */
25915
+ _autoRefreshShouldSkip() {
25916
+ if (this._autoRefreshPaused) return true;
25917
+ if (this._autoRefreshDocHidden()) return true;
25918
+ if (this.selectedItems && this.selectedItems.size > 0) return true;
25919
+ return false;
25920
+ }
25921
+ // ============================================================
25922
+ // View persistence (WM-035) — opt-in per-table saved view
25923
+ //
25924
+ // Persisted blob shape (versioned): `{ v: 1, sort?, size?, dayRange?,
25925
+ // filters?: {…raw params…}, hidden?: [columnKey] }`. `filters` is the raw
25926
+ // `collection.params` set MINUS start/size/sort and the day-range field
25927
+ // param (which is re-derived from `dayRange`), so `field__in` collapsed keys
25928
+ // and `dr_*` daterange triplets round-trip verbatim (preset matching depends
25929
+ // on it). `hidden` is written by TableView's column chooser.
25930
+ // ============================================================
25931
+ /**
25932
+ * The localStorage key for this view's saved state. Uses the explicit
25933
+ * `persistKey` when supplied, else a stable identity of page route +
25934
+ * collection endpoint (`<pathname>::<endpoint>`), namespaced under
25935
+ * `mojo:tableview:`.
25936
+ * @private
25937
+ */
25938
+ _persistStorageKey() {
25939
+ const id = this.persistKey || this._defaultPersistKey();
25940
+ return `mojo:tableview:${id}`;
25941
+ }
25942
+ /** @private */
25943
+ _defaultPersistKey() {
25944
+ const route = typeof window !== "undefined" && window.location && window.location.pathname || "";
25945
+ const endpoint = this.collection && this.collection.endpoint || "";
25946
+ return `${route}::${endpoint}`;
25947
+ }
25948
+ /**
25949
+ * Resolve the localStorage backing object, or null when it's unavailable
25950
+ * (SSR / privacy mode / access throws). All storage access funnels through
25951
+ * here so the feature degrades to a no-op rather than throwing.
25952
+ * @private
25953
+ */
25954
+ _persistStorage() {
25955
+ try {
25956
+ const ls = typeof globalThis !== "undefined" ? globalThis.localStorage : null;
25957
+ if (ls && typeof ls.getItem === "function") return ls;
25958
+ } catch (e) {
25959
+ }
25960
+ return null;
25961
+ }
25962
+ /**
25963
+ * Read + validate the saved blob. Discards (and clears) anything that isn't
25964
+ * this schema version or won't parse, returning null.
25965
+ * @private
25966
+ */
25967
+ _readPersistedState() {
25968
+ const store = this._persistStorage();
25969
+ if (!store) return null;
25970
+ let raw;
25971
+ try {
25972
+ raw = store.getItem(this._persistStorageKey());
25973
+ } catch (e) {
25974
+ return null;
25975
+ }
25976
+ if (!raw) return null;
25977
+ let parsed;
25978
+ try {
25979
+ parsed = JSON.parse(raw);
25980
+ } catch (e) {
25981
+ this._clearPersistedStorage();
25982
+ return null;
25983
+ }
25984
+ if (!parsed || typeof parsed !== "object" || parsed.v !== 1) {
25985
+ this._clearPersistedStorage();
25986
+ return null;
25987
+ }
25988
+ return parsed;
25989
+ }
25990
+ /** @private */
25991
+ _writePersistedState(state) {
25992
+ const store = this._persistStorage();
25993
+ if (!store) return;
25994
+ try {
25995
+ store.setItem(this._persistStorageKey(), JSON.stringify(state));
25996
+ } catch (e) {
25997
+ }
25998
+ }
25999
+ /** @private */
26000
+ _clearPersistedStorage() {
26001
+ const store = this._persistStorage();
26002
+ if (!store) return;
26003
+ try {
26004
+ store.removeItem(this._persistStorageKey());
26005
+ } catch (e) {
26006
+ }
26007
+ }
26008
+ /**
26009
+ * Rehydrate the collection params from the saved blob. Fills only slots the
26010
+ * current query didn't already set (URL > saved), so a shared link always
26011
+ * wins. The day-range value is restored onto `this.dayRangeFilter` so the
26012
+ * subsequent seed/control build uses it. Column visibility is delegated to
26013
+ * the TableView hook. No-op unless persistState is on.
26014
+ * @private
26015
+ */
26016
+ _restorePersistedState(queryKeys = []) {
26017
+ if (!this.persistState || !this.collection) return;
26018
+ const saved = this._readPersistedState();
26019
+ if (!saved) return;
26020
+ const params = this.collection.params || (this.collection.params = {});
26021
+ const fromQuery = new Set(queryKeys);
26022
+ fromQuery.delete("start");
26023
+ fromQuery.delete("size");
26024
+ if (saved.sort !== void 0 && !fromQuery.has("sort")) params.sort = saved.sort;
26025
+ if (saved.size !== void 0) params.size = saved.size;
26026
+ if (saved.dayRange !== void 0 && this.dayRangeFilter) {
26027
+ const field = this.dayRangeFilter.field || "created";
26028
+ if (!fromQuery.has(`${field}__gte`)) {
26029
+ this.dayRangeFilter.value = saved.dayRange;
26030
+ }
26031
+ }
26032
+ if (saved.filters && typeof saved.filters === "object") {
26033
+ Object.keys(saved.filters).forEach((key) => {
26034
+ if (!fromQuery.has(key)) params[key] = saved.filters[key];
26035
+ });
26036
+ }
26037
+ if (typeof this._restoreHiddenColumns === "function") {
26038
+ this._restoreHiddenColumns(saved.hidden);
26039
+ }
26040
+ }
26041
+ /**
26042
+ * Serialize the current view state to localStorage. Called on every
26043
+ * `params-changed` (and by TableView on a column toggle). No-op unless
26044
+ * persistState is on.
26045
+ * @private
26046
+ */
26047
+ _savePersistedState() {
26048
+ if (!this.persistState) return;
26049
+ const params = this.collection && this.collection.params || {};
26050
+ const dayRangeGteKey = this.dayRangeFilter ? `${this.dayRangeFilter.field || "created"}__gte` : null;
26051
+ const filters = {};
26052
+ Object.keys(params).forEach((key) => {
26053
+ if (key === "start" || key === "size" || key === "sort") return;
26054
+ if (dayRangeGteKey && key === dayRangeGteKey) return;
26055
+ const value = params[key];
26056
+ if (value === void 0 || value === null || value === "") return;
26057
+ filters[key] = value;
26058
+ });
26059
+ const state = { v: 1 };
26060
+ if (params.sort !== void 0) state.sort = params.sort;
26061
+ if (params.size !== void 0) state.size = params.size;
26062
+ if (this.dayRangeControl && typeof this.dayRangeControl.getValue === "function") {
26063
+ state.dayRange = this.dayRangeControl.getValue();
26064
+ } else if (this.dayRangeFilter) {
26065
+ state.dayRange = this.dayRangeFilter.value;
26066
+ }
26067
+ if (Object.keys(filters).length > 0) state.filters = filters;
26068
+ if (typeof this._getHiddenColumnKeys === "function") {
26069
+ const hidden = this._getHiddenColumnKeys();
26070
+ if (hidden && hidden.length > 0) state.hidden = hidden;
26071
+ }
26072
+ this._writePersistedState(state);
26073
+ }
26074
+ /**
26075
+ * Public escape hatch — forget this table's saved view (both the view state
26076
+ * and, via the TableView hook, any hidden columns), reverting to defaults.
26077
+ */
26078
+ clearPersistedState() {
26079
+ this._clearPersistedStorage();
26080
+ if (typeof this._onClearPersistedState === "function") this._onClearPersistedState();
26081
+ }
26082
+ // ============================================================
25186
26083
  // Cleanup
25187
26084
  // ============================================================
25188
26085
  async destroy() {
26086
+ this._stopAutoRefresh();
25189
26087
  if (this.collection) {
25190
26088
  this.collection.off("add", this._onModelsAdded, this);
25191
26089
  this.collection.off("remove", this._onModelsRemoved, this);
@@ -25255,11 +26153,31 @@ var MOJO = (function(exports) {
25255
26153
  }
25256
26154
  return "";
25257
26155
  }
26156
+ /**
26157
+ * Whether this row's TableView has an `rowExpand` callback configured.
26158
+ * Guarded (feature-detect the method) so plain/stub tableViews — and the
26159
+ * "no rowExpand option" case — render byte-identical markup with no chevron
26160
+ * column.
26161
+ */
26162
+ _isRowExpandEnabled() {
26163
+ return !!(this.tableView && typeof this.tableView.isRowExpandEnabled === "function" && this.tableView.isRowExpandEnabled());
26164
+ }
25258
26165
  /**
25259
26166
  * Build the row template with table cells
25260
26167
  */
25261
26168
  buildRowTemplate() {
25262
26169
  let template = "";
26170
+ if (this._isRowExpandEnabled()) {
26171
+ template += `
26172
+ <td class="col-expand mojo-expand-cell">
26173
+ <button type="button" class="mojo-expand-toggle"
26174
+ data-action="toggle-expand"
26175
+ aria-label="Toggle details" aria-expanded="false">
26176
+ <i class="bi bi-chevron-right"></i>
26177
+ </button>
26178
+ </td>
26179
+ `;
26180
+ }
25263
26181
  if (this.tableView && this.tableView.isSelectable()) {
25264
26182
  template += `
25265
26183
  <td style="padding: 0;">
@@ -25510,6 +26428,24 @@ var MOJO = (function(exports) {
25510
26428
  if (id) {
25511
26429
  this.element.setAttribute("data-id", id);
25512
26430
  }
26431
+ if (this._isRowExpandEnabled()) {
26432
+ const expanded = !!(this.tableView.expandedRows && this.tableView.expandedRows.has(id));
26433
+ this.element.classList.toggle("expanded", expanded);
26434
+ const toggle = this.element.querySelector(".mojo-expand-toggle");
26435
+ if (toggle) toggle.setAttribute("aria-expanded", expanded ? "true" : "false");
26436
+ }
26437
+ }
26438
+ /**
26439
+ * Toggle the inline detail row for this model. Delegates to the TableView,
26440
+ * which owns the detail-row DOM (it spans every column, so it lives outside
26441
+ * this single-`<tr>` view). `stopPropagation` prevents the click from also
26442
+ * triggering row-level actions or the context menu.
26443
+ */
26444
+ async onActionToggleExpand(event, _element) {
26445
+ event.stopPropagation();
26446
+ if (this.tableView && typeof this.tableView.toggleRowExpand === "function") {
26447
+ await this.tableView.toggleRowExpand(this.model, this);
26448
+ }
25513
26449
  }
25514
26450
  /**
25515
26451
  * Handle edit cell action
@@ -25898,6 +26834,12 @@ var MOJO = (function(exports) {
25898
26834
  this.actions = options.actions || null;
25899
26835
  this.contextMenu = options.contextMenu || options.rowContextMenu || null;
25900
26836
  this.batchActions = options.batchActions || null;
26837
+ this.rowExpand = typeof options.rowExpand === "function" ? options.rowExpand : null;
26838
+ this.rowExpandMultiple = options.rowExpandMultiple === true;
26839
+ this.expandedRows = /* @__PURE__ */ new Set();
26840
+ this.expandChildViews = /* @__PURE__ */ new Map();
26841
+ this.columnChooser = options.columnChooser === true;
26842
+ this._hiddenColumns = /* @__PURE__ */ new Set();
25901
26843
  this.searchable = options.searchable !== false;
25902
26844
  this.sortable = options.sortable !== false;
25903
26845
  this.filterable = options.filterable !== false;
@@ -25971,6 +26913,210 @@ var MOJO = (function(exports) {
25971
26913
  }
25972
26914
  });
25973
26915
  }
26916
+ // ============================================================
26917
+ // Column chooser / visibility (WM-035)
26918
+ // ============================================================
26919
+ /**
26920
+ * Whether a column may be hidden via the chooser. Columns opt OUT with
26921
+ * `hideable: false` (e.g. an id or actions column) — everything else is
26922
+ * hideable by default.
26923
+ * @private
26924
+ */
26925
+ _isColumnHideable(column) {
26926
+ return column && column.hideable !== false;
26927
+ }
26928
+ /**
26929
+ * Whether a column is currently hidden. A locked (`hideable: false`) column
26930
+ * is never hidden regardless of any stale persisted key.
26931
+ * @private
26932
+ */
26933
+ _isColumnHidden(column) {
26934
+ if (!this.columnChooser) return false;
26935
+ if (!this._isColumnHideable(column)) return false;
26936
+ return this._hiddenColumns.has(column.key);
26937
+ }
26938
+ /**
26939
+ * The columns to actually render. Returns the caller's `columns` array
26940
+ * verbatim (same reference) when the chooser is off or nothing is hidden, so
26941
+ * the opt-out path is byte-identical. Never mutates `this.columns`.
26942
+ * @private
26943
+ */
26944
+ _getVisibleColumns() {
26945
+ if (!this.columnChooser || this._hiddenColumns.size === 0) return this.columns;
26946
+ return this.columns.filter((column) => !this._isColumnHidden(column));
26947
+ }
26948
+ /**
26949
+ * Hidden column keys for persistence (only genuinely-hideable ones). Read by
26950
+ * ListView's `_savePersistedState`.
26951
+ * @private
26952
+ */
26953
+ _getHiddenColumnKeys() {
26954
+ if (!this.columnChooser) return [];
26955
+ return this.columns.filter((column) => this._isColumnHideable(column) && this._hiddenColumns.has(column.key)).map((column) => column.key);
26956
+ }
26957
+ /**
26958
+ * Restore hidden columns from a persisted blob (called by ListView during
26959
+ * `_restorePersistedState`, before the first render). Rebuilds the baked
26960
+ * template + any already-built rows so the restored visibility takes effect.
26961
+ * @private
26962
+ */
26963
+ _restoreHiddenColumns(hidden) {
26964
+ if (!this.columnChooser || !Array.isArray(hidden) || hidden.length === 0) return;
26965
+ hidden.forEach((key) => {
26966
+ const column = this.columns.find((c) => c.key === key);
26967
+ if (column && this._isColumnHideable(column)) this._hiddenColumns.add(key);
26968
+ });
26969
+ if (this._hiddenColumns.size === 0) return;
26970
+ this.template = this.buildTableTemplate();
26971
+ this._templateCache = null;
26972
+ if (this.itemViews.size > 0) this._buildItems();
26973
+ }
26974
+ /**
26975
+ * ListView `clearPersistedState()` hook — reset visibility to defaults (all
26976
+ * columns shown) and repaint.
26977
+ * @private
26978
+ */
26979
+ _onClearPersistedState() {
26980
+ if (this._hiddenColumns.size === 0) return;
26981
+ this._hiddenColumns.clear();
26982
+ this._rebuildForColumnChange();
26983
+ }
26984
+ /**
26985
+ * Re-bake the table template (header/footer reflect the visible columns) and
26986
+ * rebuild the row views (TableRow re-generates its cells from the visible
26987
+ * column set), then re-render once. Returns the render promise so callers can
26988
+ * await a settled DOM. Used after any visibility change.
26989
+ * @private
26990
+ */
26991
+ _rebuildForColumnChange() {
26992
+ this.template = this.buildTableTemplate();
26993
+ this._templateCache = null;
26994
+ this._clearItems();
26995
+ if (this.collection && !this.collection.isEmpty()) {
26996
+ this.isEmpty = false;
26997
+ this.collection.forEach((model, index) => this._createItemView(model, index));
26998
+ this._applyPersistedSelections();
26999
+ this._buildGroupHeaders();
27000
+ } else {
27001
+ this.isEmpty = true;
27002
+ }
27003
+ return this.render();
27004
+ }
27005
+ /**
27006
+ * Toggle a data-action="toggle-column" checkbox. Ignores locked columns.
27007
+ * Persists the new hidden set when persistState is on.
27008
+ */
27009
+ async onActionToggleColumn(_event, element) {
27010
+ const key = element.getAttribute("data-column-key");
27011
+ const column = this.columns.find((c) => c.key === key);
27012
+ if (!column || !this._isColumnHideable(column)) return;
27013
+ if (this._hiddenColumns.has(key)) {
27014
+ this._hiddenColumns.delete(key);
27015
+ } else {
27016
+ this._hiddenColumns.add(key);
27017
+ }
27018
+ await this._rebuildForColumnChange();
27019
+ if (this.persistState) this._savePersistedState();
27020
+ this.emit("columns:change", { hidden: this._getHiddenColumnKeys() });
27021
+ }
27022
+ /**
27023
+ * "Reset to defaults" chooser entry — show every column again AND clear the
27024
+ * saved view (both column + view state share one storage entry).
27025
+ */
27026
+ async onActionColumnChooserReset(event, _element) {
27027
+ if (event && typeof event.preventDefault === "function") event.preventDefault();
27028
+ const hadHidden = this._hiddenColumns.size > 0;
27029
+ this._hiddenColumns.clear();
27030
+ this._clearPersistedStorage();
27031
+ if (hadHidden) await this._rebuildForColumnChange();
27032
+ this.emit("columns:reset");
27033
+ }
27034
+ /**
27035
+ * The icon-only "Columns" toolbar dropdown. Locked (`hideable: false`)
27036
+ * columns render as disabled, lock-marked rows; hideable columns are
27037
+ * whole-row-clickable checkboxes. The persistence footer only shows when
27038
+ * persistState is on. Returns '' when the chooser is off.
27039
+ * @private
27040
+ */
27041
+ _buildColumnChooserTemplate() {
27042
+ if (!this.columnChooser) return "";
27043
+ const items = this.columns.map((column) => {
27044
+ const label = this.escapeHtml(column.label || column.title || column.key);
27045
+ if (!this._isColumnHideable(column)) {
27046
+ return `
27047
+ <label class="column-chooser-item is-locked" title="Always shown">
27048
+ <input class="form-check-input" type="checkbox" checked disabled>
27049
+ <span class="form-check-label">${label}</span>
27050
+ <i class="bi bi-lock-fill column-chooser-lock" aria-hidden="true"></i>
27051
+ </label>`;
27052
+ }
27053
+ const checked = this._isColumnHidden(column) ? "" : "checked";
27054
+ return `
27055
+ <label class="column-chooser-item">
27056
+ <input class="form-check-input" type="checkbox" ${checked}
27057
+ data-action="toggle-column" data-column-key="${this.escapeHtml(column.key)}">
27058
+ <span class="form-check-label">${label}</span>
27059
+ </label>`;
27060
+ }).join("");
27061
+ const persistHint = this.persistState ? '<div class="column-chooser-persist"><i class="bi bi-check2-circle"></i>Saved for this table</div>' : "";
27062
+ return `
27063
+ <div class="dropdown">
27064
+ <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
27065
+ data-bs-toggle="dropdown" aria-expanded="false" title="Choose columns">
27066
+ <i class="bi bi-layout-three-columns"></i><span class="d-none d-xxl-inline ms-1">Columns</span>
27067
+ </button>
27068
+ <div class="dropdown-menu dropdown-menu-end column-chooser-menu" role="menu">
27069
+ <h6 class="dropdown-header">Show columns</h6>
27070
+ ${items}
27071
+ <li><hr class="dropdown-divider"></li>
27072
+ <a class="dropdown-item d-flex align-items-center" href="#" data-action="column-chooser-reset">
27073
+ <i class="bi bi-arrow-counterclockwise me-2"></i>Reset to defaults
27074
+ </a>
27075
+ ${persistHint}
27076
+ </div>
27077
+ </div>
27078
+ `;
27079
+ }
27080
+ /**
27081
+ * Scoped styles for the column-chooser dropdown. Token-first per
27082
+ * `.claude/rules/theming.md`; the one black-at-4% hover carries its
27083
+ * rgba(255,255,255) dark companion. Emitted once, only when the chooser is
27084
+ * enabled (opt-out path carries zero CSS).
27085
+ * @private
27086
+ */
27087
+ _buildColumnChooserStyles() {
27088
+ return `
27089
+ <style>
27090
+ .column-chooser-menu { min-width: 268px; }
27091
+ .column-chooser-menu .dropdown-header {
27092
+ color: var(--bs-secondary-color);
27093
+ text-transform: uppercase;
27094
+ letter-spacing: 0.05em;
27095
+ font-size: 0.7rem;
27096
+ font-weight: 600;
27097
+ }
27098
+ .column-chooser-item {
27099
+ display: flex; align-items: center; gap: 0.5rem;
27100
+ margin: 0; padding: 0.35rem 1rem; cursor: pointer;
27101
+ }
27102
+ .column-chooser-item .form-check-input { margin-top: 0; flex: 0 0 auto; }
27103
+ .column-chooser-item .form-check-label { cursor: pointer; flex: 1 1 auto; color: var(--bs-body-color); }
27104
+ .column-chooser-item:hover { background: rgba(0, 0, 0, 0.04); }
27105
+ .column-chooser-item.is-locked { cursor: default; opacity: 0.72; }
27106
+ .column-chooser-item.is-locked:hover { background: transparent; }
27107
+ .column-chooser-item.is-locked .form-check-label { color: var(--bs-secondary-color); }
27108
+ .column-chooser-lock { color: var(--bs-secondary-color); font-size: 0.8rem; }
27109
+ .column-chooser-persist {
27110
+ padding: 0.4rem 1rem 0.15rem;
27111
+ color: var(--bs-secondary-color);
27112
+ font-size: 0.72rem;
27113
+ display: flex; align-items: center; gap: 0.35rem;
27114
+ }
27115
+
27116
+ [data-bs-theme="dark"] .column-chooser-item:hover { background: var(--bs-secondary-bg); }
27117
+ </style>
27118
+ `;
27119
+ }
25974
27120
  /**
25975
27121
  * Get responsive CSS classes for column visibility
25976
27122
  * @param {string|object} visibility - Bootstrap breakpoint or config object
@@ -26125,6 +27271,170 @@ var MOJO = (function(exports) {
26125
27271
  isSelectable() {
26126
27272
  return this.batchActions && this.batchActions.length > 0 && this.selectionMode === "multiple";
26127
27273
  }
27274
+ /**
27275
+ * Whether expandable detail rows are enabled (a `rowExpand` callback was
27276
+ * provided). Read by TableRow to decide whether to render the chevron cell.
27277
+ */
27278
+ isRowExpandEnabled() {
27279
+ return typeof this.rowExpand === "function";
27280
+ }
27281
+ /**
27282
+ * Colspan for the full-width detail row: chevron col (+1 when enabled) +
27283
+ * selection col + data cols + actions col. Mirrors the group-header colspan
27284
+ * math (see `_groupHeaderViewOptions`).
27285
+ */
27286
+ _getRowExpandColspan() {
27287
+ const dataCols = this._getVisibleColumns().length || 0;
27288
+ const selectCol = this.isSelectable() ? 1 : 0;
27289
+ const actionsCol = this.actions || this.contextMenu ? 1 : 0;
27290
+ const expandCol = this.isRowExpandEnabled() ? 1 : 0;
27291
+ return Math.max(1, expandCol + selectCol + dataCols + actionsCol);
27292
+ }
27293
+ /**
27294
+ * Toggle the inline detail row for `model`. Called by TableRow's chevron
27295
+ * handler. Single-open by default (`rowExpandMultiple: true` allows several).
27296
+ */
27297
+ async toggleRowExpand(model, rowView) {
27298
+ if (!this.isRowExpandEnabled() || !model) return;
27299
+ const id = model.id;
27300
+ const isOpen = this.expandedRows.has(id);
27301
+ if (isOpen) {
27302
+ this._collapseRow(id);
27303
+ } else {
27304
+ if (!this.rowExpandMultiple) {
27305
+ Array.from(this.expandedRows).forEach((otherId) => this._collapseRow(otherId));
27306
+ }
27307
+ this.expandedRows.add(id);
27308
+ const rv = rowView || this.itemViews.get(id);
27309
+ if (rv && rv.element) {
27310
+ rv.element.classList.add("expanded");
27311
+ const toggle = rv.element.querySelector(".mojo-expand-toggle");
27312
+ if (toggle) toggle.setAttribute("aria-expanded", "true");
27313
+ }
27314
+ await this._renderExpandedRow(id);
27315
+ }
27316
+ this.emit("row:expand:toggle", { model, expanded: this.expandedRows.has(id) });
27317
+ }
27318
+ /**
27319
+ * Collapse a single expanded row: drop it from state, remove its detail
27320
+ * `<tr>` from the DOM, destroy any View payload, and reset the chevron.
27321
+ * @private
27322
+ */
27323
+ _collapseRow(id) {
27324
+ this.expandedRows.delete(id);
27325
+ const detail = this.element?.querySelector(`tr.mojo-detail-row[data-detail-for="${id}"]`);
27326
+ if (detail) detail.remove();
27327
+ const child = this.expandChildViews.get(id);
27328
+ if (child) {
27329
+ this.removeChild(child);
27330
+ this.expandChildViews.delete(id);
27331
+ }
27332
+ const rv = this.itemViews.get(id);
27333
+ if (rv && rv.element) {
27334
+ rv.element.classList.remove("expanded");
27335
+ const toggle = rv.element.querySelector(".mojo-expand-toggle");
27336
+ if (toggle) toggle.setAttribute("aria-expanded", "false");
27337
+ }
27338
+ }
27339
+ /**
27340
+ * Build and insert the detail `<tr>` for an expanded model directly after
27341
+ * its data row. String payloads template into the panel; View payloads are
27342
+ * mounted via `addChild()` + an explicit `render()` (the Dynamic Children
27343
+ * pattern for children added after the parent has already rendered).
27344
+ * @private
27345
+ */
27346
+ async _renderExpandedRow(id) {
27347
+ const rowView = this.itemViews.get(id);
27348
+ if (!rowView || !rowView.element || !rowView.element.parentNode) return;
27349
+ const existing = this.element?.querySelector(`tr.mojo-detail-row[data-detail-for="${id}"]`);
27350
+ if (existing) existing.remove();
27351
+ const model = rowView.model;
27352
+ let content;
27353
+ try {
27354
+ content = this.rowExpand(model);
27355
+ } catch (error) {
27356
+ console.error("TableView rowExpand() threw for model", id, error);
27357
+ return;
27358
+ }
27359
+ const colspan = this._getRowExpandColspan();
27360
+ const detailRow = document.createElement("tr");
27361
+ detailRow.className = "mojo-detail-row";
27362
+ detailRow.setAttribute("data-detail-for", id);
27363
+ const isView = content && typeof content === "object" && typeof content.render === "function";
27364
+ if (isView) {
27365
+ const containerId = `row-expand-${id}`;
27366
+ detailRow.innerHTML = `<td colspan="${colspan}"><div class="mojo-detail-panel"><div data-container="${containerId}"></div></div></td>`;
27367
+ rowView.element.after(detailRow);
27368
+ this.addChild(content, { containerId });
27369
+ this.expandChildViews.set(id, content);
27370
+ await content.render();
27371
+ } else {
27372
+ const html = content == null ? "" : String(content);
27373
+ detailRow.innerHTML = `<td colspan="${colspan}"><div class="mojo-detail-panel">${html}</div></td>`;
27374
+ rowView.element.after(detailRow);
27375
+ }
27376
+ }
27377
+ /**
27378
+ * Re-materialize every expanded detail row after a full re-render (which
27379
+ * wipes `innerHTML`, detaching detail rows and any View payload elements).
27380
+ * Rows whose model is no longer present — e.g. after a page change — are
27381
+ * collapsed, satisfying "page change collapses all".
27382
+ * @private
27383
+ */
27384
+ async _renderExpandedRows() {
27385
+ if (!this.isRowExpandEnabled()) return;
27386
+ Array.from(this.expandedRows).forEach((id) => {
27387
+ if (!this.itemViews.has(id)) this._collapseRow(id);
27388
+ });
27389
+ if (this.expandedRows.size === 0) return;
27390
+ this.expandChildViews.forEach((child) => this.removeChild(child));
27391
+ this.expandChildViews.clear();
27392
+ await Promise.all(Array.from(this.expandedRows, (id) => this._renderExpandedRow(id)));
27393
+ }
27394
+ /**
27395
+ * Scoped inline styles for the chevron cell + detail panel. Emitted only
27396
+ * when `rowExpand` is enabled so the opt-out path has zero markup/CSS delta.
27397
+ * Light defaults first; the literal-tint dark companions are clustered at
27398
+ * the bottom (per .claude/rules/theming.md).
27399
+ * @private
27400
+ */
27401
+ _buildRowExpandStyles() {
27402
+ return `
27403
+ <style>
27404
+ .table-view-component .col-expand { width: 2.4rem; padding: 0 !important; }
27405
+ .table-view-component .mojo-expand-toggle {
27406
+ display: flex; align-items: center; justify-content: center;
27407
+ width: 100%; height: 100%; padding: 0.4rem 0;
27408
+ cursor: pointer; color: var(--bs-secondary-color);
27409
+ background: transparent; border: 0;
27410
+ }
27411
+ .table-view-component .mojo-expand-toggle:hover { color: var(--bs-emphasis-color); }
27412
+ .table-view-component .mojo-expand-toggle .bi {
27413
+ transition: transform 0.18s ease; font-size: 0.95rem; line-height: 1;
27414
+ }
27415
+ .table-view-component .table-row.expanded .mojo-expand-toggle .bi { transform: rotate(90deg); }
27416
+ .table-view-component .table-row.expanded .mojo-expand-toggle { color: var(--bs-primary); }
27417
+
27418
+ .table-view-component tr.mojo-detail-row > td {
27419
+ padding: 0 !important;
27420
+ border-bottom: 1px solid var(--bs-border-color) !important;
27421
+ background: rgba(0, 0, 0, 0.02);
27422
+ }
27423
+ .table-view-component .mojo-detail-panel {
27424
+ margin: 0.6rem 0.85rem 0.9rem 3rem;
27425
+ background: var(--bs-tertiary-bg);
27426
+ border: 1px solid var(--bs-border-color);
27427
+ border-left: 3px solid var(--bs-primary);
27428
+ border-radius: 0.5rem;
27429
+ padding: 0.95rem 1.1rem;
27430
+ }
27431
+
27432
+ [data-bs-theme="dark"] .table-view-component tr.mojo-detail-row > td {
27433
+ background: rgba(255, 255, 255, 0.015);
27434
+ }
27435
+ </style>
27436
+ `;
27437
+ }
26128
27438
  /**
26129
27439
  * Build the complete table template
26130
27440
  */
@@ -26138,22 +27448,25 @@ var MOJO = (function(exports) {
26138
27448
  })();
26139
27449
  return `
26140
27450
  <div class="mojo-table-wrapper">
27451
+ ${this.isRowExpandEnabled() ? this._buildRowExpandStyles() : ""}
27452
+ ${this.columnChooser ? this._buildColumnChooserStyles() : ""}
27453
+ ${this._hasFeedbackFeature() ? this._buildFeedbackStyles() : ""}
26141
27454
  ${this.buildToolbarTemplate()}
26142
27455
  ${batchPanelTop}
26143
27456
  <div class="table-container"${fontSize}>
26144
27457
  {{#loading}}
26145
- <div class="mojo-table-loading d-flex justify-content-center align-items-center py-5">
27458
+ ${this._loadingContent(`<div class="mojo-table-loading d-flex justify-content-center align-items-center py-5">
26146
27459
  <div class="spinner-border" role="status">
26147
27460
  <span class="visually-hidden">Loading...</span>
26148
27461
  </div>
26149
- </div>
27462
+ </div>`)}
26150
27463
  {{/loading}}
26151
27464
  {{^loading}}
26152
27465
  {{#isEmpty}}
26153
- <div class="table-empty text-center py-5">
27466
+ ${this._emptyContent(`<div class="table-empty text-center py-5">
26154
27467
  <i class="bi bi-inbox fa-2x mb-2 text-muted"></i>
26155
27468
  <p class="text-muted">{{emptyMessage}}</p>
26156
- </div>
27469
+ </div>`)}
26157
27470
  {{/isEmpty}}
26158
27471
  {{^isEmpty}}
26159
27472
  <table class="${this.buildTableClasses()}">
@@ -26169,6 +27482,31 @@ var MOJO = (function(exports) {
26169
27482
  </div>
26170
27483
  `;
26171
27484
  }
27485
+ /**
27486
+ * TableView skeleton (WM-033) — a full `<table>` whose header matches the
27487
+ * real column layout and whose body is N shimmer rows (N from
27488
+ * `_skeletonRowCount()`). Cell widths echo the real columns via cycled
27489
+ * `skeleton-line w-*` classes; leading (expand / selection) and trailing
27490
+ * (actions) cells mirror the row structure so the silhouette lines up.
27491
+ * Overrides the ListView card-silhouette version.
27492
+ * @private
27493
+ */
27494
+ _buildSkeletonHtml() {
27495
+ const rowCount = this._skeletonRowCount();
27496
+ const widths = ["w-90", "w-75", "w-60", "w-40", "w-25"];
27497
+ const lead = (this.isRowExpandEnabled() ? '<td class="col-expand"></td>' : "") + (this.isSelectable() ? "<td></td>" : "");
27498
+ const trail = this.actions || this.contextMenu ? '<td class="text-end"><span class="skeleton-line w-40 ms-auto"></span></td>' : "";
27499
+ const cells = this._getVisibleColumns().map((column, i) => {
27500
+ const alignClass = this.getAlignClass(column.align);
27501
+ const w = widths[i % widths.length];
27502
+ return `<td class="${alignClass}"><span class="skeleton-line ${w}"></span></td>`;
27503
+ }).join("");
27504
+ let body = "";
27505
+ for (let i = 0; i < rowCount; i++) {
27506
+ body += `<tr class="mojo-skeleton-row" aria-hidden="true">${lead}${cells}${trail}</tr>`;
27507
+ }
27508
+ return `<table class="${this.buildTableClasses()}">${this.buildTableHeaderTemplate()}<tbody>${body}</tbody></table>`;
27509
+ }
26172
27510
  /**
26173
27511
  * Build table CSS classes
26174
27512
  */
@@ -26208,6 +27546,9 @@ var MOJO = (function(exports) {
26208
27546
  baseButtons = fullscreenBtn + baseButtons;
26209
27547
  }
26210
27548
  }
27549
+ if (this.columnChooser) {
27550
+ baseButtons += this._buildColumnChooserTemplate();
27551
+ }
26211
27552
  return baseButtons;
26212
27553
  }
26213
27554
  /**
@@ -26215,6 +27556,9 @@ var MOJO = (function(exports) {
26215
27556
  */
26216
27557
  buildTableHeaderTemplate() {
26217
27558
  let headerCells = "";
27559
+ if (this.isRowExpandEnabled()) {
27560
+ headerCells += '<th class="col-expand mojo-expand-cell"></th>';
27561
+ }
26218
27562
  if (this.isSelectable()) {
26219
27563
  headerCells += `
26220
27564
  <th style="width: 40px; padding: 0;">
@@ -26226,7 +27570,7 @@ var MOJO = (function(exports) {
26226
27570
  </th>
26227
27571
  `;
26228
27572
  }
26229
- this.columns.forEach((column) => {
27573
+ this._getVisibleColumns().forEach((column) => {
26230
27574
  const { fieldKey } = this.parseColumnKey(column.key);
26231
27575
  const sortable = this.sortable && column.sortable !== false;
26232
27576
  const currentSort = this.getSortBy() === fieldKey ? this.getSortDirection() : null;
@@ -26285,15 +27629,17 @@ var MOJO = (function(exports) {
26285
27629
  */
26286
27630
  buildTableFooterTemplate() {
26287
27631
  let footerCells = "";
27632
+ if (this.isRowExpandEnabled()) {
27633
+ footerCells += "<td></td>";
27634
+ }
26288
27635
  if (this.isSelectable()) {
26289
27636
  footerCells += "<td></td>";
26290
27637
  }
26291
- let totalColumnIndex = 0;
26292
- this.columns.forEach((column, index) => {
27638
+ this._getVisibleColumns().forEach((column, index) => {
26293
27639
  const responsiveClasses = this.getResponsiveClasses(column.visibility);
26294
27640
  const alignClass = this.getAlignClass(column.align);
26295
27641
  if (column.footer_total) {
26296
- const safeKey = `col_${totalColumnIndex}`;
27642
+ const safeKey = `col_${this.footerTotalColumns.indexOf(column)}`;
26297
27643
  const formatter = this.parseColumnKey(column.key).formatter || column.formatter;
26298
27644
  let cellContent;
26299
27645
  if (formatter && typeof formatter === "string") {
@@ -26302,7 +27648,6 @@ var MOJO = (function(exports) {
26302
27648
  cellContent = `{{footerTotals.${safeKey}.value}}`;
26303
27649
  }
26304
27650
  footerCells += `<td class="table-footer-total ${responsiveClasses} ${alignClass}" data-total-column="${safeKey}">${cellContent}</td>`;
26305
- totalColumnIndex++;
26306
27651
  } else if (index === 0) {
26307
27652
  footerCells += `<td class="table-footer-label ${responsiveClasses} ${alignClass}"><strong>Totals</strong></td>`;
26308
27653
  } else {
@@ -26406,7 +27751,7 @@ var MOJO = (function(exports) {
26406
27751
  listView: this,
26407
27752
  tableView: this,
26408
27753
  template: this.itemTemplate,
26409
- columns: this.columns,
27754
+ columns: this._getVisibleColumns(),
26410
27755
  actions: this.actions,
26411
27756
  contextMenu: this.contextMenu,
26412
27757
  batchActions: this.batchActions,
@@ -26437,6 +27782,7 @@ var MOJO = (function(exports) {
26437
27782
  await super.onAfterRender();
26438
27783
  if (this.hasFooterTotals) this.updateFooterTotals();
26439
27784
  this.updateSortIcons();
27785
+ await this._renderExpandedRows();
26440
27786
  }
26441
27787
  // -------- Cell-editing events (table-only) --------
26442
27788
  _onCellEdit(event) {
@@ -26448,6 +27794,22 @@ var MOJO = (function(exports) {
26448
27794
  _onCellCancel(event) {
26449
27795
  this.emit("cell:cancel", event);
26450
27796
  }
27797
+ /**
27798
+ * WM-034 — extend ListView's auto-refresh pause predicate with the two
27799
+ * table-only cases: an in-progress inline cell edit (any TableRow with a
27800
+ * non-empty `editingCells` set) or an open row context menu / dropdown
27801
+ * (cheap `.dropdown-menu.show` DOM check). A silent refetch mid-edit would
27802
+ * discard the editor; mid-menu it would close the menu under the user.
27803
+ * @protected
27804
+ */
27805
+ _autoRefreshShouldSkip() {
27806
+ if (super._autoRefreshShouldSkip()) return true;
27807
+ for (const row of this.itemViews.values()) {
27808
+ if (row && row.editingCells && row.editingCells.size > 0) return true;
27809
+ }
27810
+ if (this.element && this.element.querySelector(".dropdown-menu.show")) return true;
27811
+ return false;
27812
+ }
26451
27813
  // ============================================================
26452
27814
  // Grouped rows (TableView-aware overrides)
26453
27815
  //
@@ -26473,13 +27835,14 @@ var MOJO = (function(exports) {
26473
27835
  * @protected
26474
27836
  */
26475
27837
  _groupHeaderViewOptions(_model, _key, _index) {
26476
- const dataCols = this.columns?.length || 0;
27838
+ const dataCols = this._getVisibleColumns().length || 0;
26477
27839
  const selectCol = this.isSelectable() ? 1 : 0;
26478
27840
  const actionsCol = this.actions || this.contextMenu ? 1 : 0;
27841
+ const expandCol = this.isRowExpandEnabled() ? 1 : 0;
26479
27842
  return {
26480
27843
  tagName: "tr",
26481
27844
  className: `list-group-header-row list-group-header-row--${this.groupHeaderStyle}`,
26482
- colspan: Math.max(1, dataCols + selectCol + actionsCol)
27845
+ colspan: Math.max(1, expandCol + dataCols + selectCol + actionsCol)
26483
27846
  };
26484
27847
  }
26485
27848
  // ============================================================