infotable 1.2.0 → 1.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 171498a1e00eac672d386484ac90f7c98b51a7b05f627dd7da55c9ed6d384733
4
- data.tar.gz: 3f6e330f094aec5c4d05ea11c46fe51cc0491bc6ebe80c88be42f0289c0f0716
3
+ metadata.gz: 61eaa702c985b223c91d6da00bf84515ba067ad2a7d8dd3b6fde0c5e9eac7603
4
+ data.tar.gz: edda1595d51b8ae913cecd87315a01ebf2f8788df68578ff3b45086b04c942d6
5
5
  SHA512:
6
- metadata.gz: e5bc91b7d123b62cf9db4a4814a98b389ac12d3082a913da6f6d6593cf07f545e5ece6373d408407a3503fb1697d977a523b48cff194ae488bac8c76bb7e5322
7
- data.tar.gz: 502b8de5cd278b4f5c3ebfb184984eabb82e1260fcb048c236fb0e799f9f41ea1bedede69cc811101a4626b39b915634603c96a2b61c983905f8747096bf1908
6
+ metadata.gz: 7e612ced33027a865095124cad1c1f2798abaab7238cb4c073da90625a7adbfa1068f5ca216e2e753766085ad5e43b6d1723255773ebc9e10dc3acfa63150634
7
+ data.tar.gz: 73c8657018df5e58536b75b27e5624ef4868c5a2fb29f72d87a7a258a8cb813c51184188d1b2b6bc9de576cf94605eab850cee545bf76e71eaf53198b0fe2a32
data/README.md CHANGED
@@ -372,6 +372,21 @@ Filters, search and sort survive pagination, per-page changes and CSV export.
372
372
 
373
373
  Search, filters, sort, page and page size are mirrored in the page's address bar, one namespace per table (`/users?users_table[search]=ann&users_table[page]=2`). Links can be shared and bookmarked, a reload keeps the table as it was, and the Back button steps through the states. Turn it off globally with `config.url_state = false` or per table with `url_state false` (e.g. tables inside modals or tabs).
374
374
 
375
+ #### Column widths, dark mode, small screens
376
+
377
+ - Every header has a drag handle on its right edge (keyboard: focus it, Left/Right, Shift for bigger steps; double-click resets). Widths are remembered in the browser. `resizable_columns false` removes the handles.
378
+ - Dark mode follows `prefers-color-scheme`. If your app switches with a `.dark` class instead, add `@custom-variant dark (&:where(.dark, .dark *));` to your Tailwind CSS.
379
+ - On narrow screens the toolbar stacks and the table scrolls sideways inside its own box.
380
+
381
+ #### CSV export columns
382
+
383
+ The export contains the columns the user sees, in the order they see them; action columns are never exported. Pin it down per table:
384
+
385
+ ```ruby
386
+ export_columns :name, :email, :created_at # default list, and the only columns that can be exported
387
+ column :internal_note, exportable: false # never exported
388
+ ```
389
+
375
390
  #### Large tables
376
391
 
377
392
  ```ruby
@@ -402,6 +417,11 @@ checkbox_column
402
417
  # Override global height settings for this table
403
418
  table_min_height "500px"
404
419
  table_max_height "700px"
420
+
421
+ # Or size the table to the window: it grows until its bottom (with the pagination) sits
422
+ # 16px above the bottom of the viewport, and follows window resizes. table_min_height is the floor.
423
+ table_height :fill
424
+ table_height :fill, offset: 32 # keep 32px free below
405
425
  ```
406
426
 
407
427
  #### Infinite Scroll
@@ -4,7 +4,7 @@ import Sortable from "sortablejs"
4
4
  // Drawer controller for the filter drawer and column visibility
5
5
  // Handles opening/closing the right-side drawer with smooth animations
6
6
  export default class extends Controller {
7
- static targets = ["drawer", "content", "tabButton", "tabContent", "panelTitle", "columnBadge", "columnList"]
7
+ static targets = ["rail", "drawer", "content", "tabButton", "tabContent", "panelTitle", "columnBadge", "columnList"]
8
8
  static values = {
9
9
  open: { type: Boolean, default: false },
10
10
  currentTab: { type: String, default: "filters" }
@@ -146,12 +146,40 @@ export default class extends Controller {
146
146
 
147
147
 
148
148
 
149
+ // Rail buttons: open the tab, or collapse the drawer when that tab is already showing
150
+ toggleFilters(event) {
151
+ this.toggleTab(event, "filters")
152
+ }
153
+
154
+ toggleColumns(event) {
155
+ this.toggleTab(event, "columns")
156
+ }
157
+
158
+ // Click on the rail itself (outside its buttons): toggle the drawer on its default tab
159
+ toggleRail(event) {
160
+ if (event.target.closest("button, a")) return
161
+
162
+ const hasFiltersTab = this.tabContentTargets.some(content => content.dataset.tab === "filters")
163
+ this.toggleTab(event, hasFiltersTab ? "filters" : "columns")
164
+ }
165
+
166
+ toggleTab(event, tab) {
167
+ event?.preventDefault()
168
+ if (this.openValue && this.currentTabValue === tab) {
169
+ this.openValue = false
170
+ } else {
171
+ this.currentTabValue = tab
172
+ this.openValue = true
173
+ }
174
+ }
175
+
149
176
  showDrawer() {
150
177
  // Slide in drawer from its side using inline transform
151
178
  this.previouslyFocused = document.activeElement
152
179
  this.drawerTarget.inert = false
153
180
  this.drawerTarget.removeAttribute("aria-hidden")
154
181
  this.drawerTarget.style.transform = "translateX(0)"
182
+ this.shiftRail(true)
155
183
  this.drawerTarget.querySelector("input:not([type=hidden]), select, button")?.focus({ preventScroll: true })
156
184
  }
157
185
 
@@ -163,6 +191,15 @@ export default class extends Controller {
163
191
 
164
192
  // Slide out drawer to its side (data-drawer-side) using inline transform
165
193
  this.drawerTarget.style.transform = this.drawerTarget.dataset.drawerSide === "left" ? "translateX(-100%)" : "translateX(100%)"
194
+ this.shiftRail(false)
195
+ }
196
+
197
+ // `toolbar :rail`: the rail slides left so it stays visible next to a right-hand drawer
198
+ shiftRail(open) {
199
+ if (!this.hasRailTarget || this.drawerTarget.dataset.drawerSide === "left") return
200
+
201
+ this.railTarget.style.transform = open ? `translateX(-${this.drawerTarget.offsetWidth}px)` : ""
202
+ this.railTarget.classList.toggle("shadow-lg", open)
166
203
  }
167
204
 
168
205
  closeOnEscape(event) {
@@ -187,6 +224,11 @@ export default class extends Controller {
187
224
  return
188
225
  }
189
226
 
227
+ // The rail's buttons toggle the drawer themselves
228
+ if (this.hasRailTarget && this.railTarget.contains(event.target)) {
229
+ return
230
+ }
231
+
190
232
  // Don't close if clicking on buttons that open the drawer
191
233
  const clickedElement = event.target.closest('button[data-action*="infotable--drawer#open"]')
192
234
  if (clickedElement) {
@@ -5,6 +5,7 @@ export default class extends Controller {
5
5
  static values = {
6
6
  name: String,
7
7
  urlState: Boolean,
8
+ resizable: { type: Boolean, default: true },
8
9
  idsUrl: String
9
10
  }
10
11
 
@@ -24,6 +25,9 @@ export default class extends Controller {
24
25
  // Apply column visibility from localStorage when table loads
25
26
  this.applyColumnVisibility()
26
27
 
28
+ // Remembered column widths and the drag handles
29
+ this.applyColumnWidths()
30
+
27
31
  // Apply column pinning (sticky positioning)
28
32
  this.applyColumnPinning()
29
33
 
@@ -33,6 +37,11 @@ export default class extends Controller {
33
37
  // Fill the search box and the filter drawer from the page URL (the server already loaded the frame with it)
34
38
  if (this.urlStateValue) this.applyUrlStateToControls()
35
39
 
40
+ // `table_height :fill`: size the rows area to the window, now and on resize
41
+ this.boundFitHeight = this.fitHeight.bind(this)
42
+ window.addEventListener("resize", this.boundFitHeight)
43
+ this.fitHeight()
44
+
36
45
  // Listen for turbo frame loads to reapply order and visibility
37
46
  this.boundApplyOrderAndVisibility = this.applyOrderAndVisibility.bind(this)
38
47
  this.element.addEventListener("turbo:frame-load", this.boundApplyOrderAndVisibility)
@@ -47,13 +56,35 @@ export default class extends Controller {
47
56
  this.element.removeEventListener("turbo:frame-load", this.boundApplyOrderAndVisibility)
48
57
  document.removeEventListener("infotable:columns-changed", this.boundUpdateExportLink)
49
58
  document.removeEventListener("infotable:filters-changed", this.boundUpdateExportLink)
59
+ window.removeEventListener("resize", this.boundFitHeight)
60
+ cancelAnimationFrame(this.fitFrame)
50
61
  clearTimeout(this.searchTimeout)
51
62
  }
52
63
 
64
+ // Cap the rows area (`[data-fill-height]`) so that it, plus whatever follows it in the frame
65
+ // (pagination), ends `data-fill-offset` px above the bottom of the window. Measured from the
66
+ // top of the page, so it does not depend on the current scroll position. min-height still wins.
67
+ fitHeight() {
68
+ cancelAnimationFrame(this.fitFrame)
69
+ this.fitFrame = requestAnimationFrame(() => {
70
+ this.element.querySelectorAll("[data-fill-height]").forEach(area => {
71
+ let below = 0
72
+ for (let sibling = area.nextElementSibling; sibling; sibling = sibling.nextElementSibling) {
73
+ below += sibling.offsetHeight
74
+ }
75
+
76
+ const top = area.getBoundingClientRect().top + window.scrollY
77
+ const offset = parseInt(area.dataset.fillOffset, 10) || 0
78
+ area.style.maxHeight = `${Math.max(window.innerHeight - top - below - offset, 0)}px`
79
+ })
80
+ })
81
+ }
82
+
53
83
  // Apply both order and visibility (used for turbo frame loads)
54
84
  applyOrderAndVisibility() {
55
85
  this.applyColumnOrder()
56
86
  this.applyColumnVisibility()
87
+ this.applyColumnWidths()
57
88
  this.applyColumnPinning()
58
89
  }
59
90
 
@@ -68,6 +99,7 @@ export default class extends Controller {
68
99
 
69
100
  // Handle turbo frame load
70
101
  handleFrameLoad(event) {
102
+ this.fitHeight()
71
103
  this.updateFilterCount(event.target)
72
104
  // rows matching the current filters, when the server knows it (not with countless pagination)
73
105
  const total = parseInt(event.target.querySelector("[data-total-count]")?.dataset.totalCount, 10)
@@ -780,6 +812,150 @@ export default class extends Controller {
780
812
  }
781
813
  }
782
814
 
815
+ // ---- Column widths ----------------------------------------------------------
816
+ // Every header gets a drag handle on its right edge (pointer drag, or arrow keys while it is
817
+ // focused). The width is written to the header and each cell of the column and remembered in
818
+ // localStorage as { columnName: px }. Double-click on a handle goes back to the table's width.
819
+
820
+ static MIN_COLUMN_WIDTH = 60
821
+ static MAX_COLUMN_WIDTH = 800
822
+
823
+ loadColumnWidths() {
824
+ try {
825
+ const stored = JSON.parse(localStorage.getItem(`infotable_column_widths_${this.nameValue}`) || "{}")
826
+ return stored && typeof stored === "object" ? stored : {}
827
+ } catch (e) {
828
+ return {}
829
+ }
830
+ }
831
+
832
+ saveColumnWidths(widths) {
833
+ try {
834
+ const key = `infotable_column_widths_${this.nameValue}`
835
+ Object.keys(widths).length ? localStorage.setItem(key, JSON.stringify(widths)) : localStorage.removeItem(key)
836
+ } catch (e) {
837
+ // storage unavailable (private window): the width still applies until the next load
838
+ }
839
+ }
840
+
841
+ columnTable() {
842
+ return this.element.querySelector("table") || document.getElementById(`${this.nameValue}_content`)?.querySelector("table")
843
+ }
844
+
845
+ // Writes one column's width to its header and cells. null restores the width the table was rendered with.
846
+ setColumnWidth(table, columnName, width) {
847
+ const escaped = CSS.escape(columnName)
848
+ const cells = table.querySelectorAll(`th[data-column-name="${escaped}"], td[data-column-name="${escaped}"]`)
849
+
850
+ 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
856
+ })
857
+ }
858
+
859
+ applyColumnWidths() {
860
+ const table = this.columnTable()
861
+ if (!table) return
862
+
863
+ const widths = this.loadColumnWidths()
864
+ Object.entries(widths).forEach(([name, width]) => {
865
+ if (Number.isFinite(width)) this.setColumnWidth(table, name, width)
866
+ })
867
+
868
+ if (this.resizableValue) this.addResizeHandles(table)
869
+ }
870
+
871
+ addResizeHandles(table) {
872
+ table.querySelectorAll("thead th[data-column-name]").forEach(header => {
873
+ if (header.querySelector("[data-resize-handle]")) return
874
+
875
+ const handle = document.createElement("div")
876
+ handle.setAttribute("data-resize-handle", "")
877
+ handle.setAttribute("role", "separator")
878
+ handle.setAttribute("aria-orientation", "vertical")
879
+ handle.setAttribute("aria-label", `${header.textContent.trim()}: ${this.resizeLabel()}`)
880
+ handle.tabIndex = 0
881
+ handle.className = "absolute top-0 bottom-0 right-0 w-2 cursor-col-resize touch-none select-none hover:bg-indigo-400/40 focus:bg-indigo-400/60 focus:outline-none"
882
+
883
+ handle.addEventListener("pointerdown", event => this.startResize(event, header, handle))
884
+ handle.addEventListener("keydown", event => this.resizeWithKeys(event, header))
885
+ handle.addEventListener("dblclick", event => { event.stopPropagation(); this.resetColumnWidth(header) })
886
+ handle.addEventListener("click", event => { event.preventDefault(); event.stopPropagation() })
887
+
888
+ header.appendChild(handle)
889
+ })
890
+ }
891
+
892
+ resizeLabel() {
893
+ return this.element.dataset.resizeLabel || "resize column"
894
+ }
895
+
896
+ startResize(event, header, handle) {
897
+ if (event.button !== undefined && event.button !== 0) return
898
+ event.preventDefault()
899
+ event.stopPropagation()
900
+
901
+ const table = header.closest("table")
902
+ const name = header.dataset.columnName
903
+ const startX = event.clientX
904
+ const startWidth = header.getBoundingClientRect().width
905
+ let width = startWidth
906
+
907
+ handle.setPointerCapture?.(event.pointerId)
908
+
909
+ const move = moveEvent => {
910
+ width = this.clampColumnWidth(startWidth + (moveEvent.clientX - startX))
911
+ this.setColumnWidth(table, name, width)
912
+ }
913
+ const stop = () => {
914
+ handle.removeEventListener("pointermove", move)
915
+ handle.removeEventListener("pointerup", stop)
916
+ handle.removeEventListener("pointercancel", stop)
917
+ this.commitColumnWidth(name, width)
918
+ }
919
+
920
+ handle.addEventListener("pointermove", move)
921
+ handle.addEventListener("pointerup", stop)
922
+ handle.addEventListener("pointercancel", stop)
923
+ }
924
+
925
+ resizeWithKeys(event, header) {
926
+ const step = event.shiftKey ? 50 : 10
927
+ const delta = { ArrowLeft: -step, ArrowRight: step }[event.key]
928
+ if (delta === undefined) return
929
+
930
+ event.preventDefault()
931
+ event.stopPropagation()
932
+ const width = this.clampColumnWidth(header.getBoundingClientRect().width + delta)
933
+ this.setColumnWidth(header.closest("table"), header.dataset.columnName, width)
934
+ this.commitColumnWidth(header.dataset.columnName, width)
935
+ }
936
+
937
+ resetColumnWidth(header) {
938
+ const name = header.dataset.columnName
939
+ this.setColumnWidth(header.closest("table"), name, null)
940
+ const widths = this.loadColumnWidths()
941
+ delete widths[name]
942
+ this.saveColumnWidths(widths)
943
+ this.applyColumnPinning()
944
+ this.updateExportLink()
945
+ }
946
+
947
+ clampColumnWidth(width) {
948
+ return Math.round(Math.min(this.constructor.MAX_COLUMN_WIDTH, Math.max(this.constructor.MIN_COLUMN_WIDTH, width)))
949
+ }
950
+
951
+ commitColumnWidth(name, width) {
952
+ const widths = this.loadColumnWidths()
953
+ widths[name] = width
954
+ this.saveColumnWidths(widths)
955
+ // pinned columns are positioned from the widths of the columns before them
956
+ this.applyColumnPinning()
957
+ }
958
+
783
959
  // Apply column pinning (sticky positioning) from localStorage
784
960
  applyColumnPinning() {
785
961
  const tableName = this.nameValue
@@ -258,7 +258,7 @@ module Infotable
258
258
 
259
259
  {
260
260
  style: "#{side}: 0; width: #{width}; max-width: 90%; transform: translateX(#{hidden});",
261
- class: "absolute top-0 bottom-0 bg-white dark:bg-gray-900 #{side == 'left' ? 'border-r rounded-l-xl' : 'border-l rounded-r-xl'} border-indigo-200 dark:border-indigo-800 shadow-2xl transition-all duration-300 ease-in-out z-50",
261
+ class: "absolute top-0 bottom-0 bg-white dark:bg-gray-900 #{side == 'left' ? 'border-r rounded-r-xl' : 'border-l rounded-l-xl'} border-gray-200 dark:border-gray-700 shadow-xl transition-all duration-300 ease-in-out z-50",
262
262
  role: "dialog",
263
263
  inert: true, # closed drawer is off-screen: keep it out of the tab order (JS toggles it)
264
264
  aria: { label: I18n.t("infotable.drawer.label"), hidden: true },
@@ -1,6 +1,6 @@
1
1
  <%# Chips for the filters and search narrowing the table; each one removes itself. Locals: chips, table_name %>
2
2
  <% if chips.any? %>
3
- <div class="flex flex-wrap items-center gap-2 border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 px-6 py-2" data-active-filters>
3
+ <div class="flex flex-wrap items-center gap-2 border-b border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 sm:px-6 py-2" data-active-filters>
4
4
  <% chips.each do |chip| %>
5
5
  <% remove_action = chip[:search] ? "click->infotable--table#clearSearch" : "click->infotable--drawer#clearSingleFilter" %>
6
6
  <% chip_label = chip[:search] ? chip[:text] : "#{chip[:label]}: #{chip[:text]}" %>
@@ -6,7 +6,7 @@
6
6
  end %>
7
7
  <div class="flex flex-col h-full">
8
8
  <%# Drawer Content - Scrollable %>
9
- <div class="flex-1 overflow-y-auto px-4 py-4">
9
+ <div class="flex-1 overflow-y-auto px-4 py-4 bg-gray-50 dark:bg-gray-950">
10
10
  <form id="filter-form-<%= table_name %>"
11
11
  action="<%= infotable_table_data_path(table_name: table_name) %>"
12
12
  method="get"
@@ -29,8 +29,8 @@
29
29
 
30
30
  <%# Render filter for each filterable column %>
31
31
  <% table_class.filterable_columns.each do |column| %>
32
- <div class="group p-4 bg-white dark:bg-gray-900 rounded-lg border border-gray-100 dark:border-gray-800 hover:border-indigo-200 dark:hover:border-indigo-700 transition-all duration-150">
33
- <label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
32
+ <div class="group p-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm hover:border-indigo-300 dark:hover:border-indigo-700 transition-all duration-150">
33
+ <label class="block text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2">
34
34
  <%= column[:label] %>
35
35
  </label>
36
36
 
@@ -2,10 +2,10 @@
2
2
  <% button = "relative flex items-center justify-center w-10 h-10 rounded-lg text-gray-500 dark:text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors" %>
3
3
  <% icon = "w-5 h-5" %>
4
4
  <% filter_count = initial_state[:filters].respond_to?(:keys) ? initial_state[:filters].keys.size : 0 %>
5
- <aside class="flex flex-col items-center shrink-0 w-14 py-3 gap-1 border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900" aria-label="<%= t('infotable.drawer.label') %>">
5
+ <aside data-infotable--drawer-target="rail" data-action="click->infotable--drawer#toggleRail" class="cursor-pointer relative z-40 flex flex-col items-center shrink-0 w-14 py-3 gap-1 transition-all duration-300 ease-in-out border-l border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900" aria-label="<%= t('infotable.drawer.label') %>">
6
6
  <% if table_class.filterable_columns.any? && table_class.filters_button_visible? %>
7
7
  <button type="button"
8
- data-action="click->infotable--drawer#openFilters"
8
+ data-action="click->infotable--drawer#toggleFilters"
9
9
  title="<%= t('infotable.table.buttons.filters') %>"
10
10
  aria-label="<%= t('infotable.table.buttons.filters') %>"
11
11
  class="<%= button %>">
@@ -18,7 +18,7 @@
18
18
  <% end %>
19
19
 
20
20
  <button type="button"
21
- data-action="click->infotable--drawer#openColumns"
21
+ data-action="click->infotable--drawer#toggleColumns"
22
22
  title="<%= t('infotable.table.buttons.columns') %>"
23
23
  aria-label="<%= t('infotable.table.buttons.columns') %>"
24
24
  class="<%= button %>">
@@ -34,20 +34,20 @@
34
34
  export_params[:search] = initial_state[:search] if initial_state[:search].present?
35
35
  export_url = Infotable::Engine.routes.url_helpers.table_export_path(export_params.compact)
36
36
  %>
37
- <div class="w-full relative overflow-hidden dark:[color-scheme:dark]" data-controller="infotable--table infotable--drawer" data-infotable--table-name-value="<%= table_name %>" data-infotable--table-url-state-value="<%= table_class.url_state? %>"<% if table_class.checkbox_enabled? %> data-infotable--table-ids-url-value="<%= Infotable::Engine.routes.url_helpers.table_ids_path(table_name: table_name) %>"<% end %>>
37
+ <div class="w-full relative overflow-hidden dark:[color-scheme:dark]" data-controller="infotable--table infotable--drawer" data-infotable--table-name-value="<%= table_name %>" data-infotable--table-url-state-value="<%= table_class.url_state? %>" data-infotable--table-resizable-value="<%= table_class.resizable_columns? %>" data-resize-label="<%= t('infotable.table.resize_column') %>"<% if table_class.checkbox_enabled? %> data-infotable--table-ids-url-value="<%= Infotable::Engine.routes.url_helpers.table_ids_path(table_name: table_name) %>"<% end %>>
38
38
  <div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 relative<%= " flex" if rail %>" style="overflow: hidden;">
39
39
  <div class="<%= rail ? "flex-1 min-w-0" : "w-full" %>">
40
40
  <%# Header section with controls (hidden with `toolbar :rail`) %>
41
41
  <% unless rail %>
42
- <div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
43
- <div class="flex justify-between items-center">
42
+ <div class="px-4 sm:px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
43
+ <div class="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
44
44
  <div>
45
45
  <h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100"><%= title %></h2>
46
46
  <% if subtitle.present? %>
47
47
  <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= subtitle %></p>
48
48
  <% end %>
49
49
  </div>
50
- <div class="flex gap-3">
50
+ <div class="flex flex-wrap items-center gap-3">
51
51
  <%# Global search over `searchable: true` columns %>
52
52
  <% if table_class.searchable_columns.any? %>
53
53
  <input type="search"
@@ -56,7 +56,7 @@
56
56
  placeholder="<%= t('infotable.search.placeholder') %>"
57
57
  aria-label="<%= t('infotable.search.label') %>"
58
58
  data-action="input->infotable--table#search"
59
- class="w-56 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500">
59
+ class="w-full sm:w-56 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500">
60
60
  <% end %>
61
61
  <%# Export CSV button %>
62
62
  <a href="<%= export_url %>"
@@ -89,7 +89,7 @@
89
89
 
90
90
  <%# Batch action bar: shown while rows are selected %>
91
91
  <% if table_class.batch_action_list.any? %>
92
- <div class="hidden items-center justify-between gap-3 px-6 py-3 border-b border-indigo-100 dark:border-indigo-900 bg-indigo-50 dark:bg-indigo-950"
92
+ <div class="hidden flex-wrap items-center justify-between gap-3 px-4 sm:px-6 py-3 border-b border-indigo-100 dark:border-indigo-900 bg-indigo-50 dark:bg-indigo-950"
93
93
  data-infotable--table-target="batchBar">
94
94
  <div class="flex items-center gap-3 text-sm text-indigo-900 dark:text-indigo-100">
95
95
  <span data-infotable--table-target="batchCount"
@@ -4,6 +4,7 @@
4
4
  <% active_filters = infotable_active_filters(@table_class, @filters, @search) %>
5
5
  <%= render "infotable/tables/active_filters", chips: active_filters, table_name: params[:table_name] %>
6
6
  <div<% if @pagy && !@table_class.countless? %> data-total-count="<%= @pagy.count %>"<% end %> class="relative overflow-y-auto overflow-x-auto scroll-smooth [scrollbar-width:thin] [scrollbar-color:#cbd5e1_#f1f5f9] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-slate-100 [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:rounded [&::-webkit-scrollbar-thumb:hover]:bg-slate-400 dark:[scrollbar-color:#475569_#1e293b] dark:[&::-webkit-scrollbar-track]:bg-slate-800 dark:[&::-webkit-scrollbar-thumb]:bg-slate-600"
7
+ <% if @table_class.fill_height? %>data-fill-height data-fill-offset="<%= @table_class.fill_offset %>"<% end %>
7
8
  style="min-height: <%= @table_class.min_height %>; max-height: <%= @table_class.max_height %>;">
8
9
 
9
10
  <% if @records.any? %>
@@ -114,7 +115,7 @@
114
115
 
115
116
  <%# Pagination - Only visible when infinite scroll is disabled %>
116
117
  <% unless @infinite_scroll_enabled %>
117
- <div class="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-6 py-4">
118
+ <div class="border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 sm:px-6 py-4">
118
119
  <%= render "infotable/tables/pagination", pagy: @pagy, table_name: params[:table_name] %>
119
120
  </div>
120
121
  <% end %>
@@ -12,6 +12,7 @@ en:
12
12
  initial: "Loading data..."
13
13
  more_items: "Loading more items..."
14
14
  all_loaded: "All items loaded"
15
+ resize_column: "resize column"
15
16
  empty_state:
16
17
  title: "No data found"
17
18
  description: "Get started by adding some records to see them here."
@@ -12,6 +12,7 @@ pl:
12
12
  initial: "Ładowanie danych..."
13
13
  more_items: "Ładowanie kolejnych elementów..."
14
14
  all_loaded: "Wszystkie elementy załadowane"
15
+ resize_column: "zmień szerokość kolumny"
15
16
  empty_state:
16
17
  title: "Nie znaleziono danych"
17
18
  description: "Rozpocznij od dodania rekordów, aby zobaczyć je tutaj."
@@ -1,7 +1,7 @@
1
1
  module Infotable
2
2
  class Base
3
3
  class << self
4
- attr_accessor :table_columns, :table_query, :table_query_sql, :pagination_settings, :sort_settings, :height_settings, :infinite_scroll_settings, :row_link_settings, :slim_rows_settings, :row_size_settings, :filters_button_settings, :display_settings, :nil_value_settings, :empty_value_settings, :table_authorize, :checkbox_settings, :batch_action_list, :countless_setting, :url_state_setting
4
+ attr_accessor :table_columns, :table_query, :table_query_sql, :pagination_settings, :sort_settings, :height_settings, :infinite_scroll_settings, :row_link_settings, :slim_rows_settings, :row_size_settings, :filters_button_settings, :display_settings, :nil_value_settings, :empty_value_settings, :table_authorize, :checkbox_settings, :batch_action_list, :countless_setting, :url_state_setting, :resizable_columns_setting
5
5
 
6
6
  def inherited(subclass)
7
7
  super
@@ -13,7 +13,8 @@ module Infotable
13
13
  subclass.sort_settings = {}
14
14
  subclass.height_settings = {
15
15
  min_height: nil, # nil means use global config
16
- max_height: nil # nil means use global config
16
+ max_height: nil, # nil means use global config
17
+ fill_offset: nil # Integer when the table fills the viewport (table_height :fill)
17
18
  }
18
19
  subclass.infinite_scroll_settings = {
19
20
  enabled: false
@@ -45,6 +46,7 @@ module Infotable
45
46
  subclass.batch_action_list = []
46
47
  subclass.countless_setting = false
47
48
  subclass.url_state_setting = nil
49
+ subclass.resizable_columns_setting = nil
48
50
 
49
51
  inherit_from_parent(subclass)
50
52
  end
@@ -62,6 +64,7 @@ module Infotable
62
64
  subclass.batch_action_list = parent.batch_action_list.map(&:dup)
63
65
  subclass.countless_setting = parent.countless_setting
64
66
  subclass.url_state_setting = parent.url_state_setting
67
+ subclass.resizable_columns_setting = parent.resizable_columns_setting
65
68
  %i[pagination_settings sort_settings height_settings infinite_scroll_settings row_link_settings
66
69
  slim_rows_settings row_size_settings filters_button_settings display_settings
67
70
  nil_value_settings empty_value_settings].each do |attr|
@@ -88,6 +91,17 @@ module Infotable
88
91
  @url_state_setting = enabled
89
92
  end
90
93
 
94
+ # Drag handles on the column headers (and arrow keys on the focused handle) to change
95
+ # column widths; widths are remembered per browser. Double-click resets one column.
96
+ # resizable_columns false
97
+ def resizable_columns(enabled = true)
98
+ @resizable_columns_setting = enabled
99
+ end
100
+
101
+ def resizable_columns?
102
+ @resizable_columns_setting.nil? ? true : @resizable_columns_setting ? true : false
103
+ end
104
+
91
105
  def url_state?
92
106
  @url_state_setting.nil? ? Infotable.configuration.url_state : @url_state_setting ? true : false
93
107
  end
@@ -244,6 +258,30 @@ module Infotable
244
258
  @height_settings[:max_height] = value
245
259
  end
246
260
 
261
+ # Size the table to the viewport: the rows area grows until the bottom of the table (and its
262
+ # pagination) sits `offset` pixels above the bottom of the window, and follows window resizes.
263
+ # table_min_height still applies as a floor. :fixed (default) uses table_max_height.
264
+ # @example
265
+ # table_height :fill
266
+ # table_height :fill, offset: 32
267
+ def table_height(mode, offset: 16)
268
+ unless [ :fill, :fixed ].include?(mode)
269
+ raise ArgumentError, "Invalid table height: #{mode}. Must be one of fill, fixed"
270
+ end
271
+ raise ArgumentError, "offset must be a non-negative Integer" unless offset.is_a?(Integer) && offset >= 0
272
+
273
+ @height_settings[:fill_offset] = mode == :fill ? offset : nil
274
+ end
275
+
276
+ # Pixels to keep free below the table when it fills the viewport, nil for a fixed height
277
+ def fill_offset
278
+ @height_settings[:fill_offset]
279
+ end
280
+
281
+ def fill_height?
282
+ !fill_offset.nil?
283
+ end
284
+
247
285
  # Enable/disable infinite scroll
248
286
  def infinite_scroll(enabled)
249
287
  @infinite_scroll_settings[:enabled] = enabled
@@ -1,3 +1,3 @@
1
1
  module Infotable
2
- VERSION = "1.2.0"
2
+ VERSION = "1.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: infotable
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Cieślar