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 +4 -4
- data/README.md +20 -0
- data/app/assets/javascripts/infotable/controllers/drawer_controller.js +43 -1
- data/app/assets/javascripts/infotable/controllers/table_controller.js +176 -0
- data/app/helpers/infotable/tables_helper.rb +1 -1
- data/app/views/infotable/tables/_active_filters.html.erb +1 -1
- data/app/views/infotable/tables/_filter_drawer.html.erb +3 -3
- data/app/views/infotable/tables/_rail.html.erb +3 -3
- data/app/views/infotable/tables/_table.html.erb +6 -6
- data/app/views/infotable/tables/show.html.erb +2 -1
- data/config/locales/en.yml +1 -0
- data/config/locales/pl.yml +1 -0
- data/lib/infotable/base.rb +40 -2
- data/lib/infotable/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61eaa702c985b223c91d6da00bf84515ba067ad2a7d8dd3b6fde0c5e9eac7603
|
|
4
|
+
data.tar.gz: edda1595d51b8ae913cecd87315a01ebf2f8788df68578ff3b45086b04c942d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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-
|
|
33
|
-
<label class="block text-
|
|
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#
|
|
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#
|
|
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 %>
|
data/config/locales/en.yml
CHANGED
data/config/locales/pl.yml
CHANGED
|
@@ -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."
|
data/lib/infotable/base.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/infotable/version.rb
CHANGED