infotable 1.3.0 → 1.4.1

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: f5df1bcdf9da714e75a1f5b2f39681f69de99243954eefa1a936e1895d93370b
4
- data.tar.gz: 8e0d96b405da00978ceaa3090a30dd310a1d6edc5ae1591ac50e96bede219bd7
3
+ metadata.gz: f00cd7591b4bc03f8f7443771dc1be2abc2e022602a2a2b14a61090d6eea76aa
4
+ data.tar.gz: 1c48ace8adb272674a1b0138b7aa1744dfe5154695b87ea5e1773cc918d912fc
5
5
  SHA512:
6
- metadata.gz: ca2f4b93b865921e6d8b8d62d8fabd3d935cd6db93600e3ecd2934df6d58e17930cb22bbf1f4bddeaa78c28dbedba7a3e6a4ff205320f144488dcffd432c950a
7
- data.tar.gz: 2d86610f937536dca157b8d99ee9265f77e652ea0772ef3c96fc0306dd7a3f3b3d2712bd8524b427e548f600b63f5694efbc636f818ba1afd4e737b54cc34332
6
+ metadata.gz: 9ed45ecaf4c41b844dd6a7df34e35bcecefe593fa146fbe352ec9c6e4b20501f7661464a383f6a6ae386190e13d6a4cb395765c6ea4450f1d2637f442256c148
7
+ data.tar.gz: 79fae3ddc36f3c98c826eb18fcb9856f810e5d8fd2681eb9244ba3577efa7a397b384eb8bb170c791cd1668c5c5f5e444f546553d55cffb0cd099813555366bd
data/README.md CHANGED
@@ -417,6 +417,11 @@ checkbox_column
417
417
  # Override global height settings for this table
418
418
  table_min_height "500px"
419
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
420
425
  ```
421
426
 
422
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) {
@@ -37,6 +37,11 @@ export default class extends Controller {
37
37
  // Fill the search box and the filter drawer from the page URL (the server already loaded the frame with it)
38
38
  if (this.urlStateValue) this.applyUrlStateToControls()
39
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
+
40
45
  // Listen for turbo frame loads to reapply order and visibility
41
46
  this.boundApplyOrderAndVisibility = this.applyOrderAndVisibility.bind(this)
42
47
  this.element.addEventListener("turbo:frame-load", this.boundApplyOrderAndVisibility)
@@ -51,9 +56,30 @@ export default class extends Controller {
51
56
  this.element.removeEventListener("turbo:frame-load", this.boundApplyOrderAndVisibility)
52
57
  document.removeEventListener("infotable:columns-changed", this.boundUpdateExportLink)
53
58
  document.removeEventListener("infotable:filters-changed", this.boundUpdateExportLink)
59
+ window.removeEventListener("resize", this.boundFitHeight)
60
+ cancelAnimationFrame(this.fitFrame)
54
61
  clearTimeout(this.searchTimeout)
55
62
  }
56
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
+
57
83
  // Apply both order and visibility (used for turbo frame loads)
58
84
  applyOrderAndVisibility() {
59
85
  this.applyColumnOrder()
@@ -73,6 +99,7 @@ export default class extends Controller {
73
99
 
74
100
  // Handle turbo frame load
75
101
  handleFrameLoad(event) {
102
+ this.fitHeight()
76
103
  this.updateFilterCount(event.target)
77
104
  // rows matching the current filters, when the server knows it (not with countless pagination)
78
105
  const total = parseInt(event.target.querySelector("[data-total-count]")?.dataset.totalCount, 10)
@@ -821,11 +848,20 @@ export default class extends Controller {
821
848
  const cells = table.querySelectorAll(`th[data-column-name="${escaped}"], td[data-column-name="${escaped}"]`)
822
849
 
823
850
  cells.forEach(cell => {
824
- if (cell.dataset.defaultWidth === undefined) cell.dataset.defaultWidth = cell.style.width
825
- const value = width === null ? cell.dataset.defaultWidth : `${width}px`
826
- cell.style.width = value
827
- cell.style.maxWidth = value
828
- cell.style.minWidth = width === null ? "" : value
851
+ // Remember how the server rendered the cell (action columns have width/min-width but no max-width)
852
+ if (cell.dataset.defaultWidth === undefined) {
853
+ cell.dataset.defaultWidth = cell.style.width
854
+ cell.dataset.defaultMaxWidth = cell.style.maxWidth
855
+ cell.dataset.defaultMinWidth = cell.style.minWidth
856
+ }
857
+
858
+ if (width === null) {
859
+ cell.style.width = cell.dataset.defaultWidth
860
+ cell.style.maxWidth = cell.dataset.defaultMaxWidth
861
+ cell.style.minWidth = cell.dataset.defaultMinWidth
862
+ } else {
863
+ cell.style.width = cell.style.maxWidth = cell.style.minWidth = `${width}px`
864
+ }
829
865
  })
830
866
  }
831
867
 
@@ -28,6 +28,17 @@ module Infotable
28
28
  render partial: "infotable/tables/table", locals: locals
29
29
  end
30
30
 
31
+ # Inline width style for a header/body cell. Data columns are clipped to their width (ellipsis);
32
+ # action columns hold buttons, so their width is only a minimum and the column grows to fit all
33
+ # of them (a clipped "Delete" button is worse than a wider column).
34
+ def infotable_cell_width_style(column, ellipsis: false)
35
+ if %w[action actions].include?(column[:type].to_s)
36
+ "width: 1%; min-width: #{column[:width]}; white-space: nowrap;"
37
+ else
38
+ "width: #{column[:width]}; max-width: #{column[:width]}; overflow: hidden;#{' text-overflow: ellipsis;' if ellipsis}"
39
+ end
40
+ end
41
+
31
42
  # Format cell value based on column definition
32
43
  def format_cell_value(record, column, table_class = nil)
33
44
  value = (table_class || Infotable::Base).cell_value(record, column)
@@ -258,7 +269,7 @@ module Infotable
258
269
 
259
270
  {
260
271
  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",
272
+ 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
273
  role: "dialog",
263
274
  inert: true, # closed drawer is off-screen: keep it out of the tab order (JS toggles it)
264
275
  aria: { label: I18n.t("infotable.drawer.label"), hidden: true },
@@ -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 %>">
@@ -29,7 +29,7 @@
29
29
  <% table_class.columns.each do |column| %>
30
30
  <td data-column-name="<%= column[:name] %>"
31
31
  data-default-visible="<%= column[:visible] %>"
32
- style="width: <%= column[:width] %>; max-width: <%= column[:width] %>; overflow: hidden; text-overflow: ellipsis;"
32
+ style="<%= infotable_cell_width_style(column, ellipsis: true) %>"
33
33
  class="<%= cell_padding %> whitespace-nowrap <%= text_size %> text-gray-700 dark:text-gray-300">
34
34
  <%= format_cell_value(record, column, table_class) %>
35
35
  </td>
@@ -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? %>
@@ -33,7 +34,7 @@
33
34
  data-column-name="<%= column[:name] %>"
34
35
  data-default-visible="<%= column[:visible] %>"
35
36
  <%= tag.attributes(data: { default_pinned: column[:pinned] }) if column[:pinned] %>
36
- style="width: <%= column[:width] %>; max-width: <%= column[:width] %>; overflow: hidden;"
37
+ style="<%= infotable_cell_width_style(column) %>"
37
38
  class="<%= header_padding %> text-left text-xs font-semibold text-gray-900 dark:text-gray-100 tracking-wider select-none bg-gray-50 dark:bg-gray-800 sticky top-0 z-20">
38
39
  <% if column[:sortable] %>
39
40
  <% sort_index = current_sorts.index { |name, _| name == column[:name].to_s } %>
@@ -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
@@ -257,6 +258,30 @@ module Infotable
257
258
  @height_settings[:max_height] = value
258
259
  end
259
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
+
260
285
  # Enable/disable infinite scroll
261
286
  def infinite_scroll(enabled)
262
287
  @infinite_scroll_settings[:enabled] = enabled
@@ -1,3 +1,3 @@
1
1
  module Infotable
2
- VERSION = "1.3.0"
2
+ VERSION = "1.4.1"
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.3.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Cieślar