infotable 1.4.0 → 1.5.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.
@@ -48,6 +48,8 @@ export default class extends Controller {
48
48
 
49
49
  // Listen for column visibility changes to update export link
50
50
  this.boundUpdateExportLink = this.updateExportLink.bind(this)
51
+ this.boundApplyColumns = this.applyColumnsFromDrawer.bind(this)
52
+ document.addEventListener("infotable:apply-columns", this.boundApplyColumns)
51
53
  document.addEventListener("infotable:columns-changed", this.boundUpdateExportLink)
52
54
  document.addEventListener("infotable:filters-changed", this.boundUpdateExportLink)
53
55
  }
@@ -55,6 +57,7 @@ export default class extends Controller {
55
57
  disconnect() {
56
58
  this.element.removeEventListener("turbo:frame-load", this.boundApplyOrderAndVisibility)
57
59
  document.removeEventListener("infotable:columns-changed", this.boundUpdateExportLink)
60
+ document.removeEventListener("infotable:apply-columns", this.boundApplyColumns)
58
61
  document.removeEventListener("infotable:filters-changed", this.boundUpdateExportLink)
59
62
  window.removeEventListener("resize", this.boundFitHeight)
60
63
  cancelAnimationFrame(this.fitFrame)
@@ -88,22 +91,16 @@ export default class extends Controller {
88
91
  this.applyColumnPinning()
89
92
  }
90
93
 
91
- // Handle loading states
92
- beforeFetchRequest(event) {
93
- this.showLoading()
94
- }
95
-
96
- afterFetchResponse(event) {
97
- this.hideLoading()
98
- }
99
-
100
94
  // Handle turbo frame load
101
95
  handleFrameLoad(event) {
102
96
  this.fitHeight()
103
97
  this.updateFilterCount(event.target)
104
98
  // rows matching the current filters, when the server knows it (not with countless pagination)
105
- const total = parseInt(event.target.querySelector("[data-total-count]")?.dataset.totalCount, 10)
106
- this.totalCount = Number.isFinite(total) ? total : null
99
+ // (a nested frame, e.g. the infinite-scroll "next page" one, carries no count: keep the last one)
100
+ if (event.target.id === `${this.nameValue}_content`) {
101
+ const total = parseInt(event.target.querySelector("[data-total-count]")?.dataset.totalCount, 10)
102
+ this.totalCount = Number.isFinite(total) ? total : null
103
+ }
107
104
  this.syncSelectionScope(event.target)
108
105
  if (this.urlStateValue && event.target.id === `${this.nameValue}_content`) this.syncUrl(event.target)
109
106
  }
@@ -179,7 +176,7 @@ export default class extends Controller {
179
176
  }
180
177
 
181
178
  // The first change on a fresh page replaces the entry, later ones add entries for Back
182
- const state = { turbo: { restorationIdentifier: crypto.randomUUID() } }
179
+ const state = { turbo: { restorationIdentifier: (crypto.randomUUID?.() ?? `infotable-${Date.now()}-${Math.random().toString(36).slice(2)}`) } }
183
180
  if (this.urlStateStarted) {
184
181
  window.history.pushState(state, "", next)
185
182
  } else {
@@ -240,7 +237,17 @@ export default class extends Controller {
240
237
  // Fires for rows added later (next page, infinite scroll) so they pick up the selection
241
238
  rowCheckboxTargetConnected(checkbox) {
242
239
  checkbox.checked = this.selected.has(checkbox.value)
243
- this.renderSelection()
240
+ this.scheduleRenderSelection()
241
+ }
242
+
243
+ // A page connects up to 500 checkboxes at once: render the selection once for all of them
244
+ scheduleRenderSelection() {
245
+ if (this.renderScheduled) return
246
+ this.renderScheduled = true
247
+ queueMicrotask(() => {
248
+ this.renderScheduled = false
249
+ this.renderSelection()
250
+ })
244
251
  }
245
252
 
246
253
  toggleRow(event) {
@@ -379,15 +386,6 @@ export default class extends Controller {
379
386
  form.remove()
380
387
  }
381
388
 
382
- showLoading() {
383
- // Add loading indicator (to be enhanced in later phases)
384
- this.element.style.opacity = "0.6"
385
- }
386
-
387
- hideLoading() {
388
- this.element.style.opacity = "1"
389
- }
390
-
391
389
  // Shift+click on a sortable header adds/toggles the column in the multi-sort list
392
390
  // (sort=name:asc,age:desc). A plain click keeps the link's single-column behaviour.
393
391
  sortClick(event) {
@@ -491,8 +489,18 @@ export default class extends Controller {
491
489
  }
492
490
  }
493
491
 
494
- // Apply column order from localStorage
495
- applyColumnOrder() {
492
+ // The columns drawer changed order, visibility or pinning (it stored them in localStorage)
493
+ applyColumnsFromDrawer(event) {
494
+ const { tableName, order } = event.detail
495
+ if (tableName !== this.nameValue) return
496
+
497
+ this.applyColumnOrder(order)
498
+ this.applyColumnVisibility()
499
+ this.applyColumnPinning()
500
+ }
501
+
502
+ // Apply column order: the given one, else the one in localStorage
503
+ applyColumnOrder(explicitOrder = null) {
496
504
  const tableName = this.nameValue
497
505
  const orderKey = `infotable_column_order_${tableName}`
498
506
 
@@ -520,11 +528,11 @@ export default class extends Controller {
520
528
  const defaultSchema = headers.map(h => h.dataset.columnName)
521
529
 
522
530
  // Load stored order from localStorage
523
- const storedOrder = this.loadColumnOrder(tableName)
531
+ const storedOrder = explicitOrder || this.loadColumnOrder(tableName)
524
532
  if (!storedOrder) return
525
533
 
526
534
  // Validate stored order against default schema
527
- if (!this.isSchemaValid(storedOrder, defaultSchema)) {
535
+ if (!explicitOrder && !this.isSchemaValid(storedOrder, defaultSchema)) {
528
536
  console.warn(`Schema mismatch for table ${tableName}, clearing stored order`)
529
537
  this.clearColumnOrder(tableName)
530
538
  return
@@ -628,7 +636,6 @@ export default class extends Controller {
628
636
  }
629
637
 
630
638
  const headers = table.querySelectorAll("thead th[data-column-name]")
631
- const rows = table.querySelectorAll("tbody tr")
632
639
 
633
640
  if (headers.length === 0) {
634
641
  console.warn(`No column headers found for table: ${tableName}`)
@@ -642,31 +649,18 @@ export default class extends Controller {
642
649
  visibility = JSON.parse(stored)
643
650
  }
644
651
 
652
+ const shown = new Map()
645
653
  headers.forEach((header) => {
646
654
  const columnName = header.dataset.columnName
647
- const defaultVisible = header.dataset.defaultVisible === "true"
648
-
649
- // Determine if column should be visible
650
- let shouldShow
651
- if (visibility && visibility.hasOwnProperty(columnName)) {
652
- // Use saved preference
653
- shouldShow = visibility[columnName]
654
- } else {
655
- // Use default visibility
656
- shouldShow = defaultVisible
657
- }
658
-
659
- // Apply visibility
655
+ const saved = visibility && Object.prototype.hasOwnProperty.call(visibility, columnName)
656
+ const shouldShow = saved ? visibility[columnName] : header.dataset.defaultVisible === "true"
657
+ shown.set(columnName, shouldShow)
660
658
  header.style.display = shouldShow ? "" : "none"
659
+ })
661
660
 
662
- // Hide/show corresponding cells in all rows
663
- rows.forEach(row => {
664
- const cells = row.querySelectorAll("td[data-column-name]")
665
- const cell = Array.from(cells).find(c => c.dataset.columnName === columnName)
666
- if (cell) {
667
- cell.style.display = shouldShow ? "" : "none"
668
- }
669
- })
661
+ // One pass over the cells: hide/show each by its column
662
+ table.querySelectorAll("tbody td[data-column-name]").forEach(cell => {
663
+ if (shown.has(cell.dataset.columnName)) cell.style.display = shown.get(cell.dataset.columnName) ? "" : "none"
670
664
  })
671
665
  } catch (e) {
672
666
  console.warn("Failed to apply column visibility", e)
@@ -781,34 +775,31 @@ export default class extends Controller {
781
775
  // The data-turbo="false" ensures Turbo doesn't intercept this
782
776
  }
783
777
 
784
- // Handle row click for clickable rows
778
+ // Row click (`row_link` and/or `row_click`). Fires a cancelable `infotable:row-click` on the row;
779
+ // when nothing calls preventDefault() the click falls back to following the row's link.
785
780
  handleRowClick(event) {
786
781
  const row = event.currentTarget
787
- const url = row.dataset.rowLink
788
-
789
- if (!url) return
790
782
 
791
- // Don't navigate if clicking on interactive elements
792
- const target = event.target
793
- const tagName = target.tagName.toLowerCase()
783
+ // Clicks on controls inside the row belong to those controls
784
+ if (event.target.closest("a, button, summary, details, label, input, select, textarea")) return
794
785
 
795
- // Ignore clicks on links, buttons, inputs, selects, etc.
796
- if (tagName === "a" || tagName === "button" || tagName === "input" ||
797
- tagName === "select" || tagName === "textarea") {
798
- return
799
- }
786
+ const url = row.dataset.rowLink || null
787
+ const data = { ...row.dataset }
788
+ delete data.rowLink
789
+ delete data.action
800
790
 
801
- // Ignore clicks on elements inside buttons or links
802
- if (target.closest("a") || target.closest("button")) {
803
- return
804
- }
791
+ const click = new CustomEvent("infotable:row-click", {
792
+ bubbles: true,
793
+ cancelable: true,
794
+ detail: { id: row.id || null, url, data, originalEvent: event }
795
+ })
796
+ if (!row.dispatchEvent(click) || !url) return
805
797
 
806
- // Navigate to the URL
807
- // Check if meta key (Cmd on Mac, Ctrl on Windows) is pressed for new tab
798
+ // Cmd on Mac, Ctrl elsewhere: new tab
808
799
  if (event.metaKey || event.ctrlKey) {
809
800
  window.open(url, "_blank")
810
801
  } else {
811
- window.location.href = url
802
+ window.Turbo ? window.Turbo.visit(url) : (window.location.href = url)
812
803
  }
813
804
  }
814
805
 
@@ -848,11 +839,20 @@ export default class extends Controller {
848
839
  const cells = table.querySelectorAll(`th[data-column-name="${escaped}"], td[data-column-name="${escaped}"]`)
849
840
 
850
841
  cells.forEach(cell => {
851
- if (cell.dataset.defaultWidth === undefined) cell.dataset.defaultWidth = cell.style.width
852
- const value = width === null ? cell.dataset.defaultWidth : `${width}px`
853
- cell.style.width = value
854
- cell.style.maxWidth = value
855
- cell.style.minWidth = width === null ? "" : value
842
+ // Remember how the server rendered the cell (action columns have width/min-width but no max-width)
843
+ if (cell.dataset.defaultWidth === undefined) {
844
+ cell.dataset.defaultWidth = cell.style.width
845
+ cell.dataset.defaultMaxWidth = cell.style.maxWidth
846
+ cell.dataset.defaultMinWidth = cell.style.minWidth
847
+ }
848
+
849
+ if (width === null) {
850
+ cell.style.width = cell.dataset.defaultWidth
851
+ cell.style.maxWidth = cell.dataset.defaultMaxWidth
852
+ cell.style.minWidth = cell.dataset.defaultMinWidth
853
+ } else {
854
+ cell.style.width = cell.style.maxWidth = cell.style.minWidth = `${width}px`
855
+ }
856
856
  })
857
857
  }
858
858
 
@@ -957,6 +957,12 @@ export default class extends Controller {
957
957
  }
958
958
 
959
959
  // Apply column pinning (sticky positioning) from localStorage
960
+ // Small pushpin before a pinned column's label, tilted toward the side it is pinned to
961
+ addPinIcon(header, side) {
962
+ const tilt = side === "left" ? "-rotate-45" : "rotate-45"
963
+ header.insertAdjacentHTML("afterbegin", `<svg data-pin-icon aria-hidden="true" class="inline-block w-3 h-3 mr-1 -mt-0.5 text-indigo-500 dark:text-indigo-400 ${tilt}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>`)
964
+ }
965
+
960
966
  applyColumnPinning() {
961
967
  const tableName = this.nameValue
962
968
  const pinnedKey = `infotable_column_pinned_${tableName}`
@@ -1002,6 +1008,7 @@ export default class extends Controller {
1002
1008
  // First pass: reset all columns
1003
1009
  headers.forEach(header => {
1004
1010
  header.style.position = ''
1011
+ header.style.top = ''
1005
1012
  header.style.left = ''
1006
1013
  header.style.right = ''
1007
1014
  header.style.zIndex = ''
@@ -1015,6 +1022,7 @@ export default class extends Controller {
1015
1022
  header.style.removeProperty('opacity')
1016
1023
  header.style.backgroundClip = ''
1017
1024
  header.style.boxShadow = ''
1025
+ header.querySelectorAll('[data-pin-icon]').forEach(icon => icon.remove())
1018
1026
  header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
1019
1027
  })
1020
1028
 
@@ -1035,7 +1043,7 @@ export default class extends Controller {
1035
1043
  cell.style.removeProperty('opacity')
1036
1044
  cell.style.backgroundClip = ''
1037
1045
  cell.style.boxShadow = ''
1038
- cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
1046
+ cell.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800', "after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]", "before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")
1039
1047
  })
1040
1048
  })
1041
1049
 
@@ -1069,13 +1077,6 @@ export default class extends Controller {
1069
1077
  top: 0px !important;
1070
1078
  left: ${leftOffset}px !important;
1071
1079
  z-index: 30 !important;
1072
- background: #f9fafb !important;
1073
- background-color: #f9fafb !important;
1074
- background-image: none !important;
1075
- background-position: initial !important;
1076
- background-size: initial !important;
1077
- background-repeat: initial !important;
1078
- background-attachment: initial !important;
1079
1080
  opacity: 1 !important;
1080
1081
  background-clip: padding-box !important;
1081
1082
  box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
@@ -1085,6 +1086,7 @@ export default class extends Controller {
1085
1086
  // Set all styles at once via setAttribute (most aggressive method)
1086
1087
  header.setAttribute('style', newStyles)
1087
1088
  header.classList.add('infotable-pinned-left', '!bg-gray-50', 'dark:!bg-gray-800')
1089
+ this.addPinIcon(header, 'left')
1088
1090
 
1089
1091
  // Mark last left-pinned column
1090
1092
  if (columnName === lastLeftPinned) {
@@ -1103,13 +1105,6 @@ export default class extends Controller {
1103
1105
  position: sticky !important;
1104
1106
  left: ${leftOffset}px !important;
1105
1107
  z-index: 15 !important;
1106
- background: #ffffff !important;
1107
- background-color: #ffffff !important;
1108
- background-image: none !important;
1109
- background-position: initial !important;
1110
- background-size: initial !important;
1111
- background-repeat: initial !important;
1112
- background-attachment: initial !important;
1113
1108
  opacity: 1 !important;
1114
1109
  background-clip: padding-box !important;
1115
1110
  box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
@@ -1117,7 +1112,7 @@ export default class extends Controller {
1117
1112
  `.replace(/\s+/g, ' ').trim()
1118
1113
 
1119
1114
  cell.setAttribute('style', newStyles)
1120
- cell.classList.add('infotable-pinned-left', '!bg-white', 'dark:!bg-gray-900')
1115
+ cell.classList.add('infotable-pinned-left', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800')
1121
1116
 
1122
1117
  if (columnName === lastLeftPinned) {
1123
1118
  cell.classList.add("after:content-['']", "after:absolute", "after:top-0", "after:right-0", "after:bottom-0", "after:w-5", "after:pointer-events-none", "after:bg-gradient-to-r", "after:from-transparent", "after:to-black/[0.04]", "after:z-[1]")
@@ -1136,13 +1131,6 @@ export default class extends Controller {
1136
1131
  top: 0px !important;
1137
1132
  right: ${rightOffset}px !important;
1138
1133
  z-index: 30 !important;
1139
- background: #f9fafb !important;
1140
- background-color: #f9fafb !important;
1141
- background-image: none !important;
1142
- background-position: initial !important;
1143
- background-size: initial !important;
1144
- background-repeat: initial !important;
1145
- background-attachment: initial !important;
1146
1134
  opacity: 1 !important;
1147
1135
  background-clip: padding-box !important;
1148
1136
  box-shadow: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
@@ -1151,6 +1139,7 @@ export default class extends Controller {
1151
1139
 
1152
1140
  header.setAttribute('style', newStyles)
1153
1141
  header.classList.add('infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
1142
+ this.addPinIcon(header, 'right')
1154
1143
 
1155
1144
  // Mark first right-pinned column
1156
1145
  if (columnName === firstRightPinned) {
@@ -1169,13 +1158,6 @@ export default class extends Controller {
1169
1158
  position: sticky !important;
1170
1159
  right: ${rightOffset}px !important;
1171
1160
  z-index: 15 !important;
1172
- background: #ffffff !important;
1173
- background-color: #ffffff !important;
1174
- background-image: none !important;
1175
- background-position: initial !important;
1176
- background-size: initial !important;
1177
- background-repeat: initial !important;
1178
- background-attachment: initial !important;
1179
1161
  opacity: 1 !important;
1180
1162
  background-clip: padding-box !important;
1181
1163
  box-shadow: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
@@ -1183,7 +1165,7 @@ export default class extends Controller {
1183
1165
  `.replace(/\s+/g, ' ').trim()
1184
1166
 
1185
1167
  cell.setAttribute('style', newStyles)
1186
- cell.classList.add('infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900')
1168
+ cell.classList.add('infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800')
1187
1169
 
1188
1170
  if (columnName === firstRightPinned) {
1189
1171
  cell.classList.add("before:content-['']", "before:absolute", "before:top-0", "before:left-0", "before:bottom-0", "before:w-5", "before:pointer-events-none", "before:bg-gradient-to-l", "before:from-transparent", "before:to-black/[0.04]", "before:z-[1]")