infotable 1.4.1 → 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
782
 
789
- if (!url) return
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
790
785
 
791
- // Don't navigate if clicking on interactive elements
792
- const target = event.target
793
- const tagName = target.tagName.toLowerCase()
786
+ const url = row.dataset.rowLink || null
787
+ const data = { ...row.dataset }
788
+ delete data.rowLink
789
+ delete data.action
794
790
 
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
- }
800
-
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
 
@@ -966,6 +957,12 @@ export default class extends Controller {
966
957
  }
967
958
 
968
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
+
969
966
  applyColumnPinning() {
970
967
  const tableName = this.nameValue
971
968
  const pinnedKey = `infotable_column_pinned_${tableName}`
@@ -1011,6 +1008,7 @@ export default class extends Controller {
1011
1008
  // First pass: reset all columns
1012
1009
  headers.forEach(header => {
1013
1010
  header.style.position = ''
1011
+ header.style.top = ''
1014
1012
  header.style.left = ''
1015
1013
  header.style.right = ''
1016
1014
  header.style.zIndex = ''
@@ -1024,6 +1022,7 @@ export default class extends Controller {
1024
1022
  header.style.removeProperty('opacity')
1025
1023
  header.style.backgroundClip = ''
1026
1024
  header.style.boxShadow = ''
1025
+ header.querySelectorAll('[data-pin-icon]').forEach(icon => icon.remove())
1027
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]")
1028
1027
  })
1029
1028
 
@@ -1044,7 +1043,7 @@ export default class extends Controller {
1044
1043
  cell.style.removeProperty('opacity')
1045
1044
  cell.style.backgroundClip = ''
1046
1045
  cell.style.boxShadow = ''
1047
- 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]")
1048
1047
  })
1049
1048
  })
1050
1049
 
@@ -1078,13 +1077,6 @@ export default class extends Controller {
1078
1077
  top: 0px !important;
1079
1078
  left: ${leftOffset}px !important;
1080
1079
  z-index: 30 !important;
1081
- background: #f9fafb !important;
1082
- background-color: #f9fafb !important;
1083
- background-image: none !important;
1084
- background-position: initial !important;
1085
- background-size: initial !important;
1086
- background-repeat: initial !important;
1087
- background-attachment: initial !important;
1088
1080
  opacity: 1 !important;
1089
1081
  background-clip: padding-box !important;
1090
1082
  box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
@@ -1094,6 +1086,7 @@ export default class extends Controller {
1094
1086
  // Set all styles at once via setAttribute (most aggressive method)
1095
1087
  header.setAttribute('style', newStyles)
1096
1088
  header.classList.add('infotable-pinned-left', '!bg-gray-50', 'dark:!bg-gray-800')
1089
+ this.addPinIcon(header, 'left')
1097
1090
 
1098
1091
  // Mark last left-pinned column
1099
1092
  if (columnName === lastLeftPinned) {
@@ -1112,13 +1105,6 @@ export default class extends Controller {
1112
1105
  position: sticky !important;
1113
1106
  left: ${leftOffset}px !important;
1114
1107
  z-index: 15 !important;
1115
- background: #ffffff !important;
1116
- background-color: #ffffff !important;
1117
- background-image: none !important;
1118
- background-position: initial !important;
1119
- background-size: initial !important;
1120
- background-repeat: initial !important;
1121
- background-attachment: initial !important;
1122
1108
  opacity: 1 !important;
1123
1109
  background-clip: padding-box !important;
1124
1110
  box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
@@ -1126,7 +1112,7 @@ export default class extends Controller {
1126
1112
  `.replace(/\s+/g, ' ').trim()
1127
1113
 
1128
1114
  cell.setAttribute('style', newStyles)
1129
- 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')
1130
1116
 
1131
1117
  if (columnName === lastLeftPinned) {
1132
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]")
@@ -1145,13 +1131,6 @@ export default class extends Controller {
1145
1131
  top: 0px !important;
1146
1132
  right: ${rightOffset}px !important;
1147
1133
  z-index: 30 !important;
1148
- background: #f9fafb !important;
1149
- background-color: #f9fafb !important;
1150
- background-image: none !important;
1151
- background-position: initial !important;
1152
- background-size: initial !important;
1153
- background-repeat: initial !important;
1154
- background-attachment: initial !important;
1155
1134
  opacity: 1 !important;
1156
1135
  background-clip: padding-box !important;
1157
1136
  box-shadow: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
@@ -1160,6 +1139,7 @@ export default class extends Controller {
1160
1139
 
1161
1140
  header.setAttribute('style', newStyles)
1162
1141
  header.classList.add('infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
1142
+ this.addPinIcon(header, 'right')
1163
1143
 
1164
1144
  // Mark first right-pinned column
1165
1145
  if (columnName === firstRightPinned) {
@@ -1178,13 +1158,6 @@ export default class extends Controller {
1178
1158
  position: sticky !important;
1179
1159
  right: ${rightOffset}px !important;
1180
1160
  z-index: 15 !important;
1181
- background: #ffffff !important;
1182
- background-color: #ffffff !important;
1183
- background-image: none !important;
1184
- background-position: initial !important;
1185
- background-size: initial !important;
1186
- background-repeat: initial !important;
1187
- background-attachment: initial !important;
1188
1161
  opacity: 1 !important;
1189
1162
  background-clip: padding-box !important;
1190
1163
  box-shadow: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
@@ -1192,7 +1165,7 @@ export default class extends Controller {
1192
1165
  `.replace(/\s+/g, ' ').trim()
1193
1166
 
1194
1167
  cell.setAttribute('style', newStyles)
1195
- 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')
1196
1169
 
1197
1170
  if (columnName === firstRightPinned) {
1198
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]")