infotable 1.2.0 → 1.3.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: f5df1bcdf9da714e75a1f5b2f39681f69de99243954eefa1a936e1895d93370b
4
+ data.tar.gz: 8e0d96b405da00978ceaa3090a30dd310a1d6edc5ae1591ac50e96bede219bd7
5
5
  SHA512:
6
- metadata.gz: e5bc91b7d123b62cf9db4a4814a98b389ac12d3082a913da6f6d6593cf07f545e5ece6373d408407a3503fb1697d977a523b48cff194ae488bac8c76bb7e5322
7
- data.tar.gz: 502b8de5cd278b4f5c3ebfb184984eabb82e1260fcb048c236fb0e799f9f41ea1bedede69cc811101a4626b39b915634603c96a2b61c983905f8747096bf1908
6
+ metadata.gz: ca2f4b93b865921e6d8b8d62d8fabd3d935cd6db93600e3ecd2934df6d58e17930cb22bbf1f4bddeaa78c28dbedba7a3e6a4ff205320f144488dcffd432c950a
7
+ data.tar.gz: 2d86610f937536dca157b8d99ee9265f77e652ea0772ef3c96fc0306dd7a3f3b3d2712bd8524b427e548f600b63f5694efbc636f818ba1afd4e737b54cc34332
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
@@ -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
 
@@ -54,6 +58,7 @@ export default class extends Controller {
54
58
  applyOrderAndVisibility() {
55
59
  this.applyColumnOrder()
56
60
  this.applyColumnVisibility()
61
+ this.applyColumnWidths()
57
62
  this.applyColumnPinning()
58
63
  }
59
64
 
@@ -780,6 +785,150 @@ export default class extends Controller {
780
785
  }
781
786
  }
782
787
 
788
+ // ---- Column widths ----------------------------------------------------------
789
+ // Every header gets a drag handle on its right edge (pointer drag, or arrow keys while it is
790
+ // focused). The width is written to the header and each cell of the column and remembered in
791
+ // localStorage as { columnName: px }. Double-click on a handle goes back to the table's width.
792
+
793
+ static MIN_COLUMN_WIDTH = 60
794
+ static MAX_COLUMN_WIDTH = 800
795
+
796
+ loadColumnWidths() {
797
+ try {
798
+ const stored = JSON.parse(localStorage.getItem(`infotable_column_widths_${this.nameValue}`) || "{}")
799
+ return stored && typeof stored === "object" ? stored : {}
800
+ } catch (e) {
801
+ return {}
802
+ }
803
+ }
804
+
805
+ saveColumnWidths(widths) {
806
+ try {
807
+ const key = `infotable_column_widths_${this.nameValue}`
808
+ Object.keys(widths).length ? localStorage.setItem(key, JSON.stringify(widths)) : localStorage.removeItem(key)
809
+ } catch (e) {
810
+ // storage unavailable (private window): the width still applies until the next load
811
+ }
812
+ }
813
+
814
+ columnTable() {
815
+ return this.element.querySelector("table") || document.getElementById(`${this.nameValue}_content`)?.querySelector("table")
816
+ }
817
+
818
+ // Writes one column's width to its header and cells. null restores the width the table was rendered with.
819
+ setColumnWidth(table, columnName, width) {
820
+ const escaped = CSS.escape(columnName)
821
+ const cells = table.querySelectorAll(`th[data-column-name="${escaped}"], td[data-column-name="${escaped}"]`)
822
+
823
+ 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
829
+ })
830
+ }
831
+
832
+ applyColumnWidths() {
833
+ const table = this.columnTable()
834
+ if (!table) return
835
+
836
+ const widths = this.loadColumnWidths()
837
+ Object.entries(widths).forEach(([name, width]) => {
838
+ if (Number.isFinite(width)) this.setColumnWidth(table, name, width)
839
+ })
840
+
841
+ if (this.resizableValue) this.addResizeHandles(table)
842
+ }
843
+
844
+ addResizeHandles(table) {
845
+ table.querySelectorAll("thead th[data-column-name]").forEach(header => {
846
+ if (header.querySelector("[data-resize-handle]")) return
847
+
848
+ const handle = document.createElement("div")
849
+ handle.setAttribute("data-resize-handle", "")
850
+ handle.setAttribute("role", "separator")
851
+ handle.setAttribute("aria-orientation", "vertical")
852
+ handle.setAttribute("aria-label", `${header.textContent.trim()}: ${this.resizeLabel()}`)
853
+ handle.tabIndex = 0
854
+ 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"
855
+
856
+ handle.addEventListener("pointerdown", event => this.startResize(event, header, handle))
857
+ handle.addEventListener("keydown", event => this.resizeWithKeys(event, header))
858
+ handle.addEventListener("dblclick", event => { event.stopPropagation(); this.resetColumnWidth(header) })
859
+ handle.addEventListener("click", event => { event.preventDefault(); event.stopPropagation() })
860
+
861
+ header.appendChild(handle)
862
+ })
863
+ }
864
+
865
+ resizeLabel() {
866
+ return this.element.dataset.resizeLabel || "resize column"
867
+ }
868
+
869
+ startResize(event, header, handle) {
870
+ if (event.button !== undefined && event.button !== 0) return
871
+ event.preventDefault()
872
+ event.stopPropagation()
873
+
874
+ const table = header.closest("table")
875
+ const name = header.dataset.columnName
876
+ const startX = event.clientX
877
+ const startWidth = header.getBoundingClientRect().width
878
+ let width = startWidth
879
+
880
+ handle.setPointerCapture?.(event.pointerId)
881
+
882
+ const move = moveEvent => {
883
+ width = this.clampColumnWidth(startWidth + (moveEvent.clientX - startX))
884
+ this.setColumnWidth(table, name, width)
885
+ }
886
+ const stop = () => {
887
+ handle.removeEventListener("pointermove", move)
888
+ handle.removeEventListener("pointerup", stop)
889
+ handle.removeEventListener("pointercancel", stop)
890
+ this.commitColumnWidth(name, width)
891
+ }
892
+
893
+ handle.addEventListener("pointermove", move)
894
+ handle.addEventListener("pointerup", stop)
895
+ handle.addEventListener("pointercancel", stop)
896
+ }
897
+
898
+ resizeWithKeys(event, header) {
899
+ const step = event.shiftKey ? 50 : 10
900
+ const delta = { ArrowLeft: -step, ArrowRight: step }[event.key]
901
+ if (delta === undefined) return
902
+
903
+ event.preventDefault()
904
+ event.stopPropagation()
905
+ const width = this.clampColumnWidth(header.getBoundingClientRect().width + delta)
906
+ this.setColumnWidth(header.closest("table"), header.dataset.columnName, width)
907
+ this.commitColumnWidth(header.dataset.columnName, width)
908
+ }
909
+
910
+ resetColumnWidth(header) {
911
+ const name = header.dataset.columnName
912
+ this.setColumnWidth(header.closest("table"), name, null)
913
+ const widths = this.loadColumnWidths()
914
+ delete widths[name]
915
+ this.saveColumnWidths(widths)
916
+ this.applyColumnPinning()
917
+ this.updateExportLink()
918
+ }
919
+
920
+ clampColumnWidth(width) {
921
+ return Math.round(Math.min(this.constructor.MAX_COLUMN_WIDTH, Math.max(this.constructor.MIN_COLUMN_WIDTH, width)))
922
+ }
923
+
924
+ commitColumnWidth(name, width) {
925
+ const widths = this.loadColumnWidths()
926
+ widths[name] = width
927
+ this.saveColumnWidths(widths)
928
+ // pinned columns are positioned from the widths of the columns before them
929
+ this.applyColumnPinning()
930
+ }
931
+
783
932
  // Apply column pinning (sticky positioning) from localStorage
784
933
  applyColumnPinning() {
785
934
  const tableName = this.nameValue
@@ -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]}" %>
@@ -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"
@@ -114,7 +114,7 @@
114
114
 
115
115
  <%# Pagination - Only visible when infinite scroll is disabled %>
116
116
  <% 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">
117
+ <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
118
  <%= render "infotable/tables/pagination", pagy: @pagy, table_name: params[:table_name] %>
119
119
  </div>
120
120
  <% 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
@@ -45,6 +45,7 @@ module Infotable
45
45
  subclass.batch_action_list = []
46
46
  subclass.countless_setting = false
47
47
  subclass.url_state_setting = nil
48
+ subclass.resizable_columns_setting = nil
48
49
 
49
50
  inherit_from_parent(subclass)
50
51
  end
@@ -62,6 +63,7 @@ module Infotable
62
63
  subclass.batch_action_list = parent.batch_action_list.map(&:dup)
63
64
  subclass.countless_setting = parent.countless_setting
64
65
  subclass.url_state_setting = parent.url_state_setting
66
+ subclass.resizable_columns_setting = parent.resizable_columns_setting
65
67
  %i[pagination_settings sort_settings height_settings infinite_scroll_settings row_link_settings
66
68
  slim_rows_settings row_size_settings filters_button_settings display_settings
67
69
  nil_value_settings empty_value_settings].each do |attr|
@@ -88,6 +90,17 @@ module Infotable
88
90
  @url_state_setting = enabled
89
91
  end
90
92
 
93
+ # Drag handles on the column headers (and arrow keys on the focused handle) to change
94
+ # column widths; widths are remembered per browser. Double-click resets one column.
95
+ # resizable_columns false
96
+ def resizable_columns(enabled = true)
97
+ @resizable_columns_setting = enabled
98
+ end
99
+
100
+ def resizable_columns?
101
+ @resizable_columns_setting.nil? ? true : @resizable_columns_setting ? true : false
102
+ end
103
+
91
104
  def url_state?
92
105
  @url_state_setting.nil? ? Infotable.configuration.url_state : @url_state_setting ? true : false
93
106
  end
@@ -1,3 +1,3 @@
1
1
  module Infotable
2
- VERSION = "1.2.0"
2
+ VERSION = "1.3.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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Cieślar