infotable 1.4.1 → 1.6.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +179 -11
  3. data/app/assets/javascripts/infotable/controllers/drawer_controller.js +74 -378
  4. data/app/assets/javascripts/infotable/controllers/table_controller.js +112 -120
  5. data/app/assets/javascripts/infotable/vendor/sortable.esm.js +3363 -0
  6. data/app/controllers/infotable/tables_controller.rb +16 -5
  7. data/app/helpers/infotable/tables_helper.rb +10 -48
  8. data/app/views/infotable/tables/_column_visibility.html.erb +4 -4
  9. data/app/views/infotable/tables/_end_indicator.html.erb +8 -0
  10. data/app/views/infotable/tables/_filter_drawer.html.erb +6 -6
  11. data/app/views/infotable/tables/_next_page_frame.html.erb +1 -9
  12. data/app/views/infotable/tables/_pagination.html.erb +4 -7
  13. data/app/views/infotable/tables/_table.html.erb +1 -1
  14. data/app/views/infotable/tables/_table_rows.html.erb +9 -2
  15. data/app/views/infotable/tables/filters/_boolean.html.erb +1 -1
  16. data/app/views/infotable/tables/filters/_range.html.erb +1 -1
  17. data/app/views/infotable/tables/filters/_select.html.erb +2 -2
  18. data/app/views/infotable/tables/filters/_text.html.erb +2 -2
  19. data/app/views/infotable/tables/show.html.erb +2 -2
  20. data/config/importmap.rb +2 -2
  21. data/config/locales/de.yml +126 -0
  22. data/config/locales/el.yml +126 -0
  23. data/config/locales/en.yml +18 -4
  24. data/config/locales/es.yml +126 -0
  25. data/config/locales/fr.yml +126 -0
  26. data/config/locales/it.yml +126 -0
  27. data/config/locales/pl.yml +18 -4
  28. data/exe/infotable-mcp +13 -0
  29. data/lib/generators/infotable/install/templates/initializer.rb +5 -3
  30. data/lib/generators/infotable/table/table_generator.rb +23 -1
  31. data/lib/generators/infotable/table/templates/table.rb.tt +8 -1
  32. data/lib/infotable/base.rb +62 -4
  33. data/lib/infotable/configuration.rb +1 -1
  34. data/lib/infotable/engine.rb +1 -8
  35. data/lib/infotable/filter_processor.rb +10 -1
  36. data/lib/infotable/formatters/boolean_formatter.rb +4 -4
  37. data/lib/infotable/formatters/currency_formatter.rb +4 -12
  38. data/lib/infotable/formatters/date_formatter.rb +3 -9
  39. data/lib/infotable/formatters/duration_formatter.rb +51 -0
  40. data/lib/infotable/formatters/filesize_formatter.rb +14 -0
  41. data/lib/infotable/formatters/image_formatter.rb +31 -0
  42. data/lib/infotable/formatters/number_formatter.rb +46 -0
  43. data/lib/infotable/formatters/tags_formatter.rb +44 -0
  44. data/lib/infotable/formatters/truncate_formatter.rb +19 -0
  45. data/lib/infotable/mcp/introspection.rb +94 -0
  46. data/lib/infotable/mcp/server.rb +26 -0
  47. data/lib/infotable/mcp/table_checker.rb +80 -0
  48. data/lib/infotable/mcp/table_writer.rb +66 -0
  49. data/lib/infotable/mcp/tools.rb +122 -0
  50. data/lib/infotable/mcp.rb +30 -0
  51. data/lib/infotable/table_resolver.rb +9 -0
  52. data/lib/infotable/version.rb +1 -1
  53. data/lib/infotable.rb +22 -0
  54. metadata +53 -4
@@ -1,5 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ // Innermost pinned column on each side: an inset 1px line. A border would stay behind when the
4
+ // cell sticks (collapsed borders belong to the table), a box-shadow moves with the cell
5
+ const LEFT_EDGE = 'inset -1px 0 0 0 rgba(128, 128, 128, 0.45)'
6
+ const RIGHT_EDGE = 'inset 1px 0 0 0 rgba(128, 128, 128, 0.45)'
7
+
3
8
  // Main table controller that coordinates table interactions
4
9
  export default class extends Controller {
5
10
  static values = {
@@ -48,6 +53,8 @@ export default class extends Controller {
48
53
 
49
54
  // Listen for column visibility changes to update export link
50
55
  this.boundUpdateExportLink = this.updateExportLink.bind(this)
56
+ this.boundApplyColumns = this.applyColumnsFromDrawer.bind(this)
57
+ document.addEventListener("infotable:apply-columns", this.boundApplyColumns)
51
58
  document.addEventListener("infotable:columns-changed", this.boundUpdateExportLink)
52
59
  document.addEventListener("infotable:filters-changed", this.boundUpdateExportLink)
53
60
  }
@@ -55,6 +62,7 @@ export default class extends Controller {
55
62
  disconnect() {
56
63
  this.element.removeEventListener("turbo:frame-load", this.boundApplyOrderAndVisibility)
57
64
  document.removeEventListener("infotable:columns-changed", this.boundUpdateExportLink)
65
+ document.removeEventListener("infotable:apply-columns", this.boundApplyColumns)
58
66
  document.removeEventListener("infotable:filters-changed", this.boundUpdateExportLink)
59
67
  window.removeEventListener("resize", this.boundFitHeight)
60
68
  cancelAnimationFrame(this.fitFrame)
@@ -88,22 +96,16 @@ export default class extends Controller {
88
96
  this.applyColumnPinning()
89
97
  }
90
98
 
91
- // Handle loading states
92
- beforeFetchRequest(event) {
93
- this.showLoading()
94
- }
95
-
96
- afterFetchResponse(event) {
97
- this.hideLoading()
98
- }
99
-
100
99
  // Handle turbo frame load
101
100
  handleFrameLoad(event) {
102
101
  this.fitHeight()
103
102
  this.updateFilterCount(event.target)
104
103
  // 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
104
+ // (a nested frame, e.g. the infinite-scroll "next page" one, carries no count: keep the last one)
105
+ if (event.target.id === `${this.nameValue}_content`) {
106
+ const total = parseInt(event.target.querySelector("[data-total-count]")?.dataset.totalCount, 10)
107
+ this.totalCount = Number.isFinite(total) ? total : null
108
+ }
107
109
  this.syncSelectionScope(event.target)
108
110
  if (this.urlStateValue && event.target.id === `${this.nameValue}_content`) this.syncUrl(event.target)
109
111
  }
@@ -179,7 +181,7 @@ export default class extends Controller {
179
181
  }
180
182
 
181
183
  // The first change on a fresh page replaces the entry, later ones add entries for Back
182
- const state = { turbo: { restorationIdentifier: crypto.randomUUID() } }
184
+ const state = { turbo: { restorationIdentifier: (crypto.randomUUID?.() ?? `infotable-${Date.now()}-${Math.random().toString(36).slice(2)}`) } }
183
185
  if (this.urlStateStarted) {
184
186
  window.history.pushState(state, "", next)
185
187
  } else {
@@ -240,7 +242,17 @@ export default class extends Controller {
240
242
  // Fires for rows added later (next page, infinite scroll) so they pick up the selection
241
243
  rowCheckboxTargetConnected(checkbox) {
242
244
  checkbox.checked = this.selected.has(checkbox.value)
243
- this.renderSelection()
245
+ this.scheduleRenderSelection()
246
+ }
247
+
248
+ // A page connects up to 500 checkboxes at once: render the selection once for all of them
249
+ scheduleRenderSelection() {
250
+ if (this.renderScheduled) return
251
+ this.renderScheduled = true
252
+ queueMicrotask(() => {
253
+ this.renderScheduled = false
254
+ this.renderSelection()
255
+ })
244
256
  }
245
257
 
246
258
  toggleRow(event) {
@@ -379,15 +391,6 @@ export default class extends Controller {
379
391
  form.remove()
380
392
  }
381
393
 
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
394
  // Shift+click on a sortable header adds/toggles the column in the multi-sort list
392
395
  // (sort=name:asc,age:desc). A plain click keeps the link's single-column behaviour.
393
396
  sortClick(event) {
@@ -491,8 +494,18 @@ export default class extends Controller {
491
494
  }
492
495
  }
493
496
 
494
- // Apply column order from localStorage
495
- applyColumnOrder() {
497
+ // The columns drawer changed order, visibility or pinning (it stored them in localStorage)
498
+ applyColumnsFromDrawer(event) {
499
+ const { tableName, order } = event.detail
500
+ if (tableName !== this.nameValue) return
501
+
502
+ this.applyColumnOrder(order)
503
+ this.applyColumnVisibility()
504
+ this.applyColumnPinning()
505
+ }
506
+
507
+ // Apply column order: the given one, else the one in localStorage
508
+ applyColumnOrder(explicitOrder = null) {
496
509
  const tableName = this.nameValue
497
510
  const orderKey = `infotable_column_order_${tableName}`
498
511
 
@@ -520,11 +533,11 @@ export default class extends Controller {
520
533
  const defaultSchema = headers.map(h => h.dataset.columnName)
521
534
 
522
535
  // Load stored order from localStorage
523
- const storedOrder = this.loadColumnOrder(tableName)
536
+ const storedOrder = explicitOrder || this.loadColumnOrder(tableName)
524
537
  if (!storedOrder) return
525
538
 
526
539
  // Validate stored order against default schema
527
- if (!this.isSchemaValid(storedOrder, defaultSchema)) {
540
+ if (!explicitOrder && !this.isSchemaValid(storedOrder, defaultSchema)) {
528
541
  console.warn(`Schema mismatch for table ${tableName}, clearing stored order`)
529
542
  this.clearColumnOrder(tableName)
530
543
  return
@@ -628,7 +641,6 @@ export default class extends Controller {
628
641
  }
629
642
 
630
643
  const headers = table.querySelectorAll("thead th[data-column-name]")
631
- const rows = table.querySelectorAll("tbody tr")
632
644
 
633
645
  if (headers.length === 0) {
634
646
  console.warn(`No column headers found for table: ${tableName}`)
@@ -642,31 +654,18 @@ export default class extends Controller {
642
654
  visibility = JSON.parse(stored)
643
655
  }
644
656
 
657
+ const shown = new Map()
645
658
  headers.forEach((header) => {
646
659
  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
660
+ const saved = visibility && Object.prototype.hasOwnProperty.call(visibility, columnName)
661
+ const shouldShow = saved ? visibility[columnName] : header.dataset.defaultVisible === "true"
662
+ shown.set(columnName, shouldShow)
660
663
  header.style.display = shouldShow ? "" : "none"
664
+ })
661
665
 
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
- })
666
+ // One pass over the cells: hide/show each by its column
667
+ table.querySelectorAll("tbody td[data-column-name]").forEach(cell => {
668
+ if (shown.has(cell.dataset.columnName)) cell.style.display = shown.get(cell.dataset.columnName) ? "" : "none"
670
669
  })
671
670
  } catch (e) {
672
671
  console.warn("Failed to apply column visibility", e)
@@ -781,34 +780,31 @@ export default class extends Controller {
781
780
  // The data-turbo="false" ensures Turbo doesn't intercept this
782
781
  }
783
782
 
784
- // Handle row click for clickable rows
783
+ // Row click (`row_link` and/or `row_click`). Fires a cancelable `infotable:row-click` on the row;
784
+ // when nothing calls preventDefault() the click falls back to following the row's link.
785
785
  handleRowClick(event) {
786
786
  const row = event.currentTarget
787
- const url = row.dataset.rowLink
788
787
 
789
- if (!url) return
788
+ // Clicks on controls inside the row belong to those controls
789
+ if (event.target.closest("a, button, summary, details, label, input, select, textarea")) return
790
790
 
791
- // Don't navigate if clicking on interactive elements
792
- const target = event.target
793
- const tagName = target.tagName.toLowerCase()
791
+ const url = row.dataset.rowLink || null
792
+ const data = { ...row.dataset }
793
+ delete data.rowLink
794
+ delete data.action
794
795
 
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
- }
796
+ const click = new CustomEvent("infotable:row-click", {
797
+ bubbles: true,
798
+ cancelable: true,
799
+ detail: { id: row.id || null, url, data, originalEvent: event }
800
+ })
801
+ if (!row.dispatchEvent(click) || !url) return
805
802
 
806
- // Navigate to the URL
807
- // Check if meta key (Cmd on Mac, Ctrl on Windows) is pressed for new tab
803
+ // Cmd on Mac, Ctrl elsewhere: new tab
808
804
  if (event.metaKey || event.ctrlKey) {
809
805
  window.open(url, "_blank")
810
806
  } else {
811
- window.location.href = url
807
+ window.Turbo ? window.Turbo.visit(url) : (window.location.href = url)
812
808
  }
813
809
  }
814
810
 
@@ -966,6 +962,12 @@ export default class extends Controller {
966
962
  }
967
963
 
968
964
  // Apply column pinning (sticky positioning) from localStorage
965
+ // Small pushpin before a pinned column's label, tilted toward the side it is pinned to
966
+ addPinIcon(header, side) {
967
+ const tilt = side === "left" ? "-rotate-45" : "rotate-45"
968
+ 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>`)
969
+ }
970
+
969
971
  applyColumnPinning() {
970
972
  const tableName = this.nameValue
971
973
  const pinnedKey = `infotable_column_pinned_${tableName}`
@@ -992,7 +994,7 @@ export default class extends Controller {
992
994
  return
993
995
  }
994
996
 
995
- const headers = Array.from(table.querySelectorAll("thead th[data-column-name]"))
997
+ let headers = Array.from(table.querySelectorAll("thead th[data-column-name]"))
996
998
 
997
999
  // Columns the user never pinned/unpinned fall back to the table's `pinned:` default
998
1000
  headers.forEach(header => {
@@ -1003,6 +1005,32 @@ export default class extends Controller {
1003
1005
  })
1004
1006
  const rows = table.querySelectorAll("tbody tr")
1005
1007
 
1008
+ // Pinned columns jump to their edge, keeping their relative order, so they sit side by side
1009
+ // with no gap; unpinning restores the base order (stored order, else the server's)
1010
+ const names = headers.map(h => h.dataset.columnName)
1011
+ headers.forEach((h, i) => { if (h.dataset.pinBase === undefined) h.dataset.pinBase = String(i) })
1012
+ const storedOrder = this.loadColumnOrder(tableName)
1013
+ const base = storedOrder && this.isSchemaValid(storedOrder, names)
1014
+ ? storedOrder
1015
+ : [...headers].sort((a, b) => a.dataset.pinBase - b.dataset.pinBase).map(h => h.dataset.columnName)
1016
+ const leftNames = base.filter(n => pinnedData[n] === 'left')
1017
+ const rightNames = base.filter(n => pinnedData[n] === 'right')
1018
+ const pinOrder = [...leftNames, ...base.filter(n => !leftNames.includes(n) && !rightNames.includes(n)), ...rightNames]
1019
+ // Re-appending a node drops its focus, so only touch rows that are out of order
1020
+ const inOrder = els => els.every((el, i) => el.dataset.columnName === pinOrder[i])
1021
+ const headerRow = headers[0]?.parentElement
1022
+ if (headerRow && !inOrder(headers)) {
1023
+ const byName = new Map(headers.map(h => [h.dataset.columnName, h]))
1024
+ pinOrder.forEach(n => byName.has(n) && headerRow.appendChild(byName.get(n)))
1025
+ headers = Array.from(table.querySelectorAll("thead th[data-column-name]"))
1026
+ }
1027
+ rows.forEach(row => {
1028
+ const cells = Array.from(row.querySelectorAll("td[data-column-name]"))
1029
+ if (inOrder(cells)) return
1030
+ const cellByName = new Map(cells.map(c => [c.dataset.columnName, c]))
1031
+ pinOrder.forEach(n => cellByName.has(n) && row.appendChild(cellByName.get(n)))
1032
+ })
1033
+
1006
1034
 
1007
1035
  // Calculate positions for pinned columns
1008
1036
  let leftOffset = 0
@@ -1011,6 +1039,7 @@ export default class extends Controller {
1011
1039
  // First pass: reset all columns
1012
1040
  headers.forEach(header => {
1013
1041
  header.style.position = ''
1042
+ header.style.top = ''
1014
1043
  header.style.left = ''
1015
1044
  header.style.right = ''
1016
1045
  header.style.zIndex = ''
@@ -1024,7 +1053,8 @@ export default class extends Controller {
1024
1053
  header.style.removeProperty('opacity')
1025
1054
  header.style.backgroundClip = ''
1026
1055
  header.style.boxShadow = ''
1027
- 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]")
1056
+ header.querySelectorAll('[data-pin-icon]').forEach(icon => icon.remove())
1057
+ header.classList.remove('infotable-pinned-left', 'infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
1028
1058
  })
1029
1059
 
1030
1060
  rows.forEach(row => {
@@ -1044,7 +1074,7 @@ export default class extends Controller {
1044
1074
  cell.style.removeProperty('opacity')
1045
1075
  cell.style.backgroundClip = ''
1046
1076
  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]")
1077
+ 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')
1048
1078
  })
1049
1079
  })
1050
1080
 
@@ -1063,8 +1093,10 @@ export default class extends Controller {
1063
1093
  }
1064
1094
  })
1065
1095
 
1066
- // Apply sticky positioning for pinned columns
1067
- headers.forEach(header => {
1096
+ // Apply sticky positioning for pinned columns (right ones from the outer edge inwards)
1097
+ const rightPinnedHeaders = headers.filter(h => pinnedData[h.dataset.columnName] === 'right')
1098
+ const applyOrder = [...headers.filter(h => pinnedData[h.dataset.columnName] === 'left'), ...rightPinnedHeaders.reverse()]
1099
+ applyOrder.forEach(header => {
1068
1100
  const columnName = header.dataset.columnName
1069
1101
  const pinned = pinnedData[columnName]
1070
1102
 
@@ -1078,27 +1110,17 @@ export default class extends Controller {
1078
1110
  top: 0px !important;
1079
1111
  left: ${leftOffset}px !important;
1080
1112
  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
1113
  opacity: 1 !important;
1089
1114
  background-clip: padding-box !important;
1090
- box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
1115
+ box-shadow: ${columnName === lastLeftPinned ? LEFT_EDGE : 'none'} !important;
1091
1116
  ${existingStyles}
1092
1117
  `.replace(/\s+/g, ' ').trim()
1093
1118
 
1094
1119
  // Set all styles at once via setAttribute (most aggressive method)
1095
1120
  header.setAttribute('style', newStyles)
1096
1121
  header.classList.add('infotable-pinned-left', '!bg-gray-50', 'dark:!bg-gray-800')
1122
+ this.addPinIcon(header, 'left')
1097
1123
 
1098
- // Mark last left-pinned column
1099
- if (columnName === lastLeftPinned) {
1100
- header.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]")
1101
- }
1102
1124
 
1103
1125
  // Apply to cells in all rows
1104
1126
  rows.forEach(row => {
@@ -1112,25 +1134,15 @@ export default class extends Controller {
1112
1134
  position: sticky !important;
1113
1135
  left: ${leftOffset}px !important;
1114
1136
  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
1137
  opacity: 1 !important;
1123
1138
  background-clip: padding-box !important;
1124
- box-shadow: 2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
1139
+ box-shadow: ${columnName === lastLeftPinned ? LEFT_EDGE : 'none'} !important;
1125
1140
  ${existingStyles}
1126
1141
  `.replace(/\s+/g, ' ').trim()
1127
1142
 
1128
1143
  cell.setAttribute('style', newStyles)
1129
- cell.classList.add('infotable-pinned-left', '!bg-white', 'dark:!bg-gray-900')
1144
+ cell.classList.add('infotable-pinned-left', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800')
1130
1145
 
1131
- if (columnName === lastLeftPinned) {
1132
- 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]")
1133
- }
1134
1146
  }
1135
1147
  })
1136
1148
 
@@ -1145,26 +1157,16 @@ export default class extends Controller {
1145
1157
  top: 0px !important;
1146
1158
  right: ${rightOffset}px !important;
1147
1159
  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
1160
  opacity: 1 !important;
1156
1161
  background-clip: padding-box !important;
1157
- box-shadow: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
1162
+ box-shadow: ${columnName === firstRightPinned ? RIGHT_EDGE : 'none'} !important;
1158
1163
  ${existingStyles}
1159
1164
  `.replace(/\s+/g, ' ').trim()
1160
1165
 
1161
1166
  header.setAttribute('style', newStyles)
1162
1167
  header.classList.add('infotable-pinned-right', '!bg-gray-50', 'dark:!bg-gray-800')
1168
+ this.addPinIcon(header, 'right')
1163
1169
 
1164
- // Mark first right-pinned column
1165
- if (columnName === firstRightPinned) {
1166
- header.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]")
1167
- }
1168
1170
 
1169
1171
  // Apply to cells in all rows
1170
1172
  rows.forEach(row => {
@@ -1178,25 +1180,15 @@ export default class extends Controller {
1178
1180
  position: sticky !important;
1179
1181
  right: ${rightOffset}px !important;
1180
1182
  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
1183
  opacity: 1 !important;
1189
1184
  background-clip: padding-box !important;
1190
- box-shadow: -2px 0 4px -2px rgba(0, 0, 0, 0.1) !important;
1185
+ box-shadow: ${columnName === firstRightPinned ? RIGHT_EDGE : 'none'} !important;
1191
1186
  ${existingStyles}
1192
1187
  `.replace(/\s+/g, ' ').trim()
1193
1188
 
1194
1189
  cell.setAttribute('style', newStyles)
1195
- cell.classList.add('infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900')
1190
+ cell.classList.add('infotable-pinned-right', '!bg-white', 'dark:!bg-gray-900', 'group-hover:!bg-gray-50', 'dark:group-hover:!bg-gray-800')
1196
1191
 
1197
- if (columnName === firstRightPinned) {
1198
- 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]")
1199
- }
1200
1192
  }
1201
1193
  })
1202
1194