stimulus_grid_rails 0.1.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 +7 -0
- data/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +359 -0
- data/Rakefile +16 -0
- data/app/assets/javascripts/stimulus_grid.js +1547 -0
- data/app/assets/javascripts/stimulus_grid_rails.js +630 -0
- data/app/assets/stylesheets/stimulus_grid.css +1 -0
- data/app/assets/stylesheets/stimulus_grid_rails.css +47 -0
- data/app/controllers/stimulus_grid_rails/base_controller.rb +51 -0
- data/app/controllers/stimulus_grid_rails/cells_controller.rb +113 -0
- data/app/controllers/stimulus_grid_rails/history_controller.rb +53 -0
- data/app/controllers/stimulus_grid_rails/rows_controller.rb +98 -0
- data/app/models/stimulus_grid_rails/audit.rb +32 -0
- data/app/views/stimulus_grid_rails/grids/_grid.html.erb +107 -0
- data/app/views/stimulus_grid_rails/grids/_row.html.erb +15 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +24 -0
- data/db/migrate/20260520000001_create_stimulus_grid_audits.rb +18 -0
- data/lib/stimulus_grid_rails/column.rb +239 -0
- data/lib/stimulus_grid_rails/concerns/broadcastable.rb +91 -0
- data/lib/stimulus_grid_rails/engine.rb +39 -0
- data/lib/stimulus_grid_rails/grid.rb +221 -0
- data/lib/stimulus_grid_rails/turbo_streams_helper.rb +105 -0
- data/lib/stimulus_grid_rails/version.rb +3 -0
- data/lib/stimulus_grid_rails.rb +79 -0
- metadata +132 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/* stimulus_grid_rails — Rails + Hotwire bindings for stimulus_grid.
|
|
2
|
+
*
|
|
3
|
+
* Loaded via importmap. Exports `start()` which:
|
|
4
|
+
* - registers Stimulus controllers: `grid-sync`, `cell-editor`
|
|
5
|
+
* - registers Turbo Stream actions: cell, cell-attr, cell-confirm,
|
|
6
|
+
* cell-revert, cell-conflict, row-insert-sorted, row-remove, bulk,
|
|
7
|
+
* aggregate, presence
|
|
8
|
+
*
|
|
9
|
+
* Usage in app/javascript/application.js:
|
|
10
|
+
*
|
|
11
|
+
* import "@hotwired/turbo-rails"
|
|
12
|
+
* import { Application } from "@hotwired/stimulus"
|
|
13
|
+
* import StimulusGrid from "stimulus_grid"
|
|
14
|
+
* import StimulusGridRails from "stimulus_grid_rails"
|
|
15
|
+
*
|
|
16
|
+
* const app = Application.start()
|
|
17
|
+
* StimulusGrid.start(app)
|
|
18
|
+
* StimulusGridRails.start(app)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Controller } from "@hotwired/stimulus"
|
|
22
|
+
|
|
23
|
+
// ---------- helpers ----------
|
|
24
|
+
|
|
25
|
+
function gridById(name) {
|
|
26
|
+
const el = document.querySelector(`[data-grid-name="${name}"]`)
|
|
27
|
+
if (!el) console.warn(`[stimulus_grid_rails] no grid element found for grid="${name}"`)
|
|
28
|
+
return el
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findCell(gridEl, rowId, column) {
|
|
32
|
+
return gridEl?.querySelector(
|
|
33
|
+
`tr[data-row-id="${CSS.escape(String(rowId))}"] td[data-col-id="${CSS.escape(String(column))}"]`
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function csrfToken() {
|
|
38
|
+
return document.querySelector('meta[name="csrf-token"]')?.content || ""
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function debounce(fn, ms) {
|
|
42
|
+
let t = null
|
|
43
|
+
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------- Stimulus: GridSyncController ----------
|
|
47
|
+
// Mount on the same element as data-controller="grid". Listens for
|
|
48
|
+
// `grid:cellValueChanged` from the base grid controller and converts edits
|
|
49
|
+
// into a PATCH /grids/:resource/:row_id/cells/:column with an optimistic id.
|
|
50
|
+
// Marks the cell `data-pending` until the server confirms.
|
|
51
|
+
|
|
52
|
+
class GridSyncController extends Controller {
|
|
53
|
+
static values = {
|
|
54
|
+
resource: String,
|
|
55
|
+
cellsPathTemplate: String, // "/grids/:resource/:row_id/cells/:column"
|
|
56
|
+
rowsPath: String, // "/grids/:resource/rows"
|
|
57
|
+
rowPathTemplate: String, // "/grids/:resource/rows/:row_id"
|
|
58
|
+
bulkRowsPath: String, // "/grids/:resource/rows/bulk"
|
|
59
|
+
cellsBulkPath: String, // "/grids/:resource/bulk"
|
|
60
|
+
undoPath: String, // "/grids/:resource/undo"
|
|
61
|
+
redoPath: String, // "/grids/:resource/redo"
|
|
62
|
+
serverSide: Boolean, // server-side row model (windowed fetch)
|
|
63
|
+
optimisticIdPrefix: { type: String, default: "" },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
connect() {
|
|
67
|
+
this._gridEl = this.element
|
|
68
|
+
this._gridEl.dataset.gridName = this.resourceValue
|
|
69
|
+
this._onCellChange = this._onCellChange.bind(this)
|
|
70
|
+
this._gridEl.addEventListener("grid:cellValueChanged", this._onCellChange)
|
|
71
|
+
|
|
72
|
+
// Row add/delete are driven by events dispatched on the grid element, so a
|
|
73
|
+
// toolbar living anywhere in the page can trigger them without being inside
|
|
74
|
+
// this controller's Stimulus scope:
|
|
75
|
+
// gridEl.dispatchEvent(new CustomEvent("grid-sync:add-row", { detail: { attributes } }))
|
|
76
|
+
// gridEl.dispatchEvent(new CustomEvent("grid-sync:delete-selected"))
|
|
77
|
+
this._onAddRow = (e) => this.addRow(e?.detail?.attributes || {})
|
|
78
|
+
this._onDeleteSelected = () => this.deleteSelected()
|
|
79
|
+
this._gridEl.addEventListener("grid-sync:add-row", this._onAddRow)
|
|
80
|
+
this._gridEl.addEventListener("grid-sync:delete-selected", this._onDeleteSelected)
|
|
81
|
+
|
|
82
|
+
// Per-row delete buttons rendered by a cell renderer live inside the grid,
|
|
83
|
+
// so a single delegated listener handles them.
|
|
84
|
+
this._onDelegatedClick = (e) => {
|
|
85
|
+
const btn = e.target.closest('[data-sgr-action="delete-row"]')
|
|
86
|
+
if (!btn) return
|
|
87
|
+
e.preventDefault()
|
|
88
|
+
const tr = btn.closest("tr[data-row-id]")
|
|
89
|
+
if (tr) this.removeRow(tr.dataset.rowId)
|
|
90
|
+
}
|
|
91
|
+
this._gridEl.addEventListener("click", this._onDelegatedClick)
|
|
92
|
+
|
|
93
|
+
// Bulk paste (RAILS.md §9). Track the last-clicked cell as the paste anchor;
|
|
94
|
+
// a multi-cell clipboard paste fills from there and POSTs to /bulk.
|
|
95
|
+
this._onCellClicked = (e) => { this._anchor = { rowId: e.detail.rowId, colId: e.detail.colId } }
|
|
96
|
+
this._gridEl.addEventListener("grid:cellClicked", this._onCellClicked)
|
|
97
|
+
this._onPaste = (e) => this._handlePaste(e)
|
|
98
|
+
document.addEventListener("paste", this._onPaste)
|
|
99
|
+
|
|
100
|
+
// Server-side search / filtering. The grid fetches matching rows from the
|
|
101
|
+
// index endpoint and swaps the dataset via setRowData. Driven by events:
|
|
102
|
+
// gridEl.dispatchEvent(new CustomEvent("grid-sync:search", { detail: { q } }))
|
|
103
|
+
// gridEl.dispatchEvent(new CustomEvent("grid-sync:filter", { detail: { column, criteria } })) // criteria=null clears
|
|
104
|
+
// gridEl.dispatchEvent(new CustomEvent("grid-sync:clear-filters"))
|
|
105
|
+
this._query = { q: "", filters: {}, sort: [] }
|
|
106
|
+
// In server-side mode, a query change resets to page 0 (debounced); the
|
|
107
|
+
// resulting paginationChanged is the single trigger that fetches a window.
|
|
108
|
+
// In client mode, a query change refetches the (capped) full set directly.
|
|
109
|
+
this._afterQueryChange = this.serverSideValue
|
|
110
|
+
? debounce(() => this._gridEl.gridApi?.paginationGoToFirstPage(), 200)
|
|
111
|
+
: debounce(() => this._fetchRows(), 200)
|
|
112
|
+
this._onSearch = (e) => { this._query.q = e?.detail?.q ?? ""; this._afterQueryChange() }
|
|
113
|
+
this._onFilter = (e) => {
|
|
114
|
+
const { column, criteria } = e?.detail || {}
|
|
115
|
+
if (!column) return
|
|
116
|
+
if (criteria == null || criteria === "") delete this._query.filters[column]
|
|
117
|
+
else this._query.filters[column] = criteria
|
|
118
|
+
this._afterQueryChange()
|
|
119
|
+
}
|
|
120
|
+
this._onClearFilters = () => {
|
|
121
|
+
this._query = { q: "", filters: {}, sort: this._query.sort }
|
|
122
|
+
this.serverSideValue ? this._gridEl.gridApi?.paginationGoToFirstPage() : this._fetchRows()
|
|
123
|
+
}
|
|
124
|
+
this._gridEl.addEventListener("grid-sync:search", this._onSearch)
|
|
125
|
+
this._gridEl.addEventListener("grid-sync:filter", this._onFilter)
|
|
126
|
+
this._gridEl.addEventListener("grid-sync:clear-filters", this._onClearFilters)
|
|
127
|
+
|
|
128
|
+
// Server-side row model: paginationChanged (page click / size change / a
|
|
129
|
+
// reset from above) fetches that window; sortChanged sorts on the server.
|
|
130
|
+
if (this.serverSideValue) {
|
|
131
|
+
this._onSrvPage = () => this._fetchRows()
|
|
132
|
+
this._onSrvSort = (e) => {
|
|
133
|
+
this._query.sort = e?.detail?.sortModel || []
|
|
134
|
+
this._gridEl.gridApi?.paginationGoToFirstPage() // → paginationChanged → fetch
|
|
135
|
+
}
|
|
136
|
+
this._gridEl.addEventListener("grid:paginationChanged", this._onSrvPage)
|
|
137
|
+
this._gridEl.addEventListener("grid:sortChanged", this._onSrvSort)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Undo / redo keyboard shortcuts (RAILS.md §16). Cmd/Ctrl+Z undoes,
|
|
141
|
+
// Cmd/Ctrl+Shift+Z (or Cmd/Ctrl+Y) redoes. Skipped while a cell editor or
|
|
142
|
+
// any text field is focused, so native text undo still works there.
|
|
143
|
+
this._onKeydown = (e) => {
|
|
144
|
+
const mod = e.metaKey || e.ctrlKey
|
|
145
|
+
const key = e.key.toLowerCase()
|
|
146
|
+
if (!mod || (key !== "z" && key !== "y")) return
|
|
147
|
+
const ae = document.activeElement
|
|
148
|
+
if (ae && /^(input|textarea|select)$/i.test(ae.tagName)) return
|
|
149
|
+
if (this._gridEl.querySelector('td[data-editing="true"]')) return
|
|
150
|
+
const isRedo = key === "y" || (key === "z" && e.shiftKey)
|
|
151
|
+
e.preventDefault()
|
|
152
|
+
isRedo ? this.redo() : this.undo()
|
|
153
|
+
}
|
|
154
|
+
document.addEventListener("keydown", this._onKeydown)
|
|
155
|
+
|
|
156
|
+
this._opCounter = 0
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
disconnect() {
|
|
160
|
+
this._gridEl.removeEventListener("grid:cellValueChanged", this._onCellChange)
|
|
161
|
+
this._gridEl.removeEventListener("grid-sync:add-row", this._onAddRow)
|
|
162
|
+
this._gridEl.removeEventListener("grid-sync:delete-selected", this._onDeleteSelected)
|
|
163
|
+
this._gridEl.removeEventListener("click", this._onDelegatedClick)
|
|
164
|
+
this._gridEl.removeEventListener("grid-sync:search", this._onSearch)
|
|
165
|
+
this._gridEl.removeEventListener("grid-sync:filter", this._onFilter)
|
|
166
|
+
this._gridEl.removeEventListener("grid-sync:clear-filters", this._onClearFilters)
|
|
167
|
+
if (this._onSrvPage) this._gridEl.removeEventListener("grid:paginationChanged", this._onSrvPage)
|
|
168
|
+
if (this._onSrvSort) this._gridEl.removeEventListener("grid:sortChanged", this._onSrvSort)
|
|
169
|
+
this._gridEl.removeEventListener("grid:cellClicked", this._onCellClicked)
|
|
170
|
+
document.removeEventListener("keydown", this._onKeydown)
|
|
171
|
+
document.removeEventListener("paste", this._onPaste)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Bulk paste: parse TSV from the clipboard and fill cells from the anchor
|
|
175
|
+
// (last-clicked cell) rightward + downward across editable columns and the
|
|
176
|
+
// loaded rows, then POST one /bulk request. The server validates + coerces +
|
|
177
|
+
// saves each mutation and returns cell-confirms (which fill the cells).
|
|
178
|
+
_handlePaste(e) {
|
|
179
|
+
if (!this._anchor || !this.hasCellsBulkPathValue) return
|
|
180
|
+
if (this._gridEl.querySelector('td[data-editing="true"]')) return // editing → native paste
|
|
181
|
+
const ae = document.activeElement
|
|
182
|
+
if (ae && /^(input|textarea|select)$/i.test(ae.tagName) && !this._gridEl.contains(ae)) return
|
|
183
|
+
|
|
184
|
+
const text = e.clipboardData?.getData("text/plain")
|
|
185
|
+
if (!text) return
|
|
186
|
+
const grid = text.replace(/\r\n?/g, "\n").replace(/\n$/, "")
|
|
187
|
+
.split("\n").map((line) => line.split("\t"))
|
|
188
|
+
if (!grid.length) return
|
|
189
|
+
|
|
190
|
+
const api = this._gridEl.gridApi
|
|
191
|
+
const cols = api.getColumnDefs().filter((c) =>
|
|
192
|
+
c.editable && !c.hidden && !c._isCheckbox && !String(c.field).startsWith("_"))
|
|
193
|
+
const rows = api.getRowData()
|
|
194
|
+
const colStart = cols.findIndex((c) => c.field === this._anchor.colId)
|
|
195
|
+
const rowStart = rows.findIndex((r) => String(r.id) === String(this._anchor.rowId))
|
|
196
|
+
if (colStart < 0 || rowStart < 0) return // anchor must be an editable cell
|
|
197
|
+
|
|
198
|
+
e.preventDefault()
|
|
199
|
+
const mutations = []
|
|
200
|
+
grid.forEach((line, r) => {
|
|
201
|
+
const row = rows[rowStart + r]
|
|
202
|
+
if (!row) return
|
|
203
|
+
line.forEach((value, c) => {
|
|
204
|
+
const col = cols[colStart + c]
|
|
205
|
+
if (col) mutations.push({ row_id: row.id, column: col.field, value })
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
if (!mutations.length) return
|
|
209
|
+
|
|
210
|
+
const optimisticId = this._nextOptimisticId()
|
|
211
|
+
;(this._gridEl.__sgrOwnOps ||= new Set()).add(optimisticId) // suppress broadcast echo
|
|
212
|
+
fetch(this.cellsBulkPathValue, {
|
|
213
|
+
method: "POST", credentials: "same-origin", headers: this._headers(),
|
|
214
|
+
body: JSON.stringify({ mutations, optimistic_id: optimisticId }),
|
|
215
|
+
})
|
|
216
|
+
.then((res) => res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`)))
|
|
217
|
+
.then((html) => { if (html.trim()) window.Turbo?.renderStreamMessage(html) })
|
|
218
|
+
.catch((err) => console.error("[stimulus_grid_rails] bulk paste failed:", err))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// POST /grids/:resource/undo (or /redo). The server replays the inverse /
|
|
222
|
+
// forward value as a normal mutation, which auto-broadcasts back to this tab.
|
|
223
|
+
async undo() { return this._history(this.hasUndoPathValue && this.undoPathValue) }
|
|
224
|
+
async redo() { return this._history(this.hasRedoPathValue && this.redoPathValue) }
|
|
225
|
+
|
|
226
|
+
async _history(path) {
|
|
227
|
+
if (!path) return
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetch(path, {
|
|
230
|
+
method: "POST", credentials: "same-origin", headers: this._headers(),
|
|
231
|
+
})
|
|
232
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
233
|
+
const html = await res.text()
|
|
234
|
+
if (html.trim()) window.Turbo?.renderStreamMessage(html)
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error("[stimulus_grid_rails] history failed:", err)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// GET /grids/:resource/rows?q=&filters= — server applies the global search +
|
|
241
|
+
// per-column filters via the column registry, returns matching rows, and we
|
|
242
|
+
// swap the grid's dataset. This is the scalable path for large tables.
|
|
243
|
+
async fetchRows() { return this._fetchRows() }
|
|
244
|
+
|
|
245
|
+
async _fetchRows() {
|
|
246
|
+
if (!this.hasRowsPathValue) return
|
|
247
|
+
const api = this._gridEl.gridApi
|
|
248
|
+
if (!api) return
|
|
249
|
+
const url = new URL(this.rowsPathValue, window.location.origin)
|
|
250
|
+
if (this._query.q) url.searchParams.set("q", this._query.q)
|
|
251
|
+
if (Object.keys(this._query.filters).length) {
|
|
252
|
+
url.searchParams.set("filters", JSON.stringify(this._query.filters))
|
|
253
|
+
}
|
|
254
|
+
if (this.serverSideValue) {
|
|
255
|
+
url.searchParams.set("page", api.paginationGetCurrentPage())
|
|
256
|
+
url.searchParams.set("page_size", api.paginationGetPageSize())
|
|
257
|
+
if (this._query.sort.length) url.searchParams.set("sort", JSON.stringify(this._query.sort))
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const res = await fetch(url, {
|
|
261
|
+
method: "GET", credentials: "same-origin",
|
|
262
|
+
headers: { "Accept": "application/json", "X-CSRF-Token": csrfToken() },
|
|
263
|
+
})
|
|
264
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
265
|
+
const data = await res.json()
|
|
266
|
+
// setRowCount first so the pagination refresh (fired by setRowData's
|
|
267
|
+
// rowDataChanged) reads the new total.
|
|
268
|
+
if (this.serverSideValue) api.setRowCount(data.total)
|
|
269
|
+
api.setRowData(data.rows)
|
|
270
|
+
this._gridEl.dispatchEvent(new CustomEvent("grid-sync:rows-fetched", {
|
|
271
|
+
detail: { total: data.total, shown: data.rows.length, limited: data.limited },
|
|
272
|
+
bubbles: true,
|
|
273
|
+
}))
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error("[stimulus_grid_rails] fetchRows failed:", err)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_headers(extra = {}) {
|
|
280
|
+
return {
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
283
|
+
"X-CSRF-Token": csrfToken(),
|
|
284
|
+
...extra,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// POST /grids/:resource/rows — create a row with the grid's server defaults
|
|
289
|
+
// (optionally merged with `attributes`). Server returns + broadcasts a
|
|
290
|
+
// row-insert-sorted, which adds the persisted row (real id) to the grid.
|
|
291
|
+
async addRow(attributes = {}) {
|
|
292
|
+
if (!this.hasRowsPathValue) return
|
|
293
|
+
try {
|
|
294
|
+
const res = await fetch(this.rowsPathValue, {
|
|
295
|
+
method: "POST", credentials: "same-origin",
|
|
296
|
+
headers: this._headers(),
|
|
297
|
+
body: JSON.stringify({ attributes }),
|
|
298
|
+
})
|
|
299
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
300
|
+
const html = await res.text()
|
|
301
|
+
window.Turbo?.renderStreamMessage(html)
|
|
302
|
+
} catch (err) {
|
|
303
|
+
console.error("[stimulus_grid_rails] addRow failed:", err)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// DELETE a single row by id.
|
|
308
|
+
async removeRow(rowId) {
|
|
309
|
+
if (!this.hasRowPathTemplateValue) return
|
|
310
|
+
const path = this.rowPathTemplateValue.replace(":row_id", encodeURIComponent(String(rowId)))
|
|
311
|
+
try {
|
|
312
|
+
const res = await fetch(path, {
|
|
313
|
+
method: "DELETE", credentials: "same-origin", headers: this._headers(),
|
|
314
|
+
})
|
|
315
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
316
|
+
const html = await res.text()
|
|
317
|
+
if (html.trim()) window.Turbo?.renderStreamMessage(html)
|
|
318
|
+
} catch (err) {
|
|
319
|
+
console.error("[stimulus_grid_rails] removeRow failed:", err)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// DELETE every selected row in one bulk request.
|
|
324
|
+
async deleteSelected() {
|
|
325
|
+
const api = this._gridEl.gridApi
|
|
326
|
+
if (!api) return
|
|
327
|
+
// Prefer explicit row selection (checkbox / Cmd-click); otherwise fall back
|
|
328
|
+
// to the rows covered by the current cell selection.
|
|
329
|
+
let ids = api.getSelectedRowIds ? api.getSelectedRowIds() : []
|
|
330
|
+
if (!ids.length && api.getCellSelectionRowIds) ids = api.getCellSelectionRowIds()
|
|
331
|
+
if (!ids.length) return
|
|
332
|
+
if (this.hasBulkRowsPathValue) {
|
|
333
|
+
try {
|
|
334
|
+
const res = await fetch(this.bulkRowsPathValue, {
|
|
335
|
+
method: "DELETE", credentials: "same-origin",
|
|
336
|
+
headers: this._headers(),
|
|
337
|
+
body: JSON.stringify({ ids }),
|
|
338
|
+
})
|
|
339
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
340
|
+
const html = await res.text()
|
|
341
|
+
if (html.trim()) window.Turbo?.renderStreamMessage(html)
|
|
342
|
+
} catch (err) {
|
|
343
|
+
console.error("[stimulus_grid_rails] deleteSelected failed:", err)
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
for (const id of ids) await this.removeRow(id)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_nextOptimisticId() {
|
|
351
|
+
this._opCounter += 1
|
|
352
|
+
return `${this.optimisticIdPrefixValue || "op"}-${Date.now()}-${this._opCounter}`
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async _onCellChange(e) {
|
|
356
|
+
const { rowId, colId, newValue, oldValue } = e.detail
|
|
357
|
+
const td = findCell(this._gridEl, rowId, colId)
|
|
358
|
+
if (!td) return
|
|
359
|
+
const optimisticId = this._nextOptimisticId()
|
|
360
|
+
// Remember our own optimistic ids so the broadcast echo of this very edit
|
|
361
|
+
// can be suppressed on this client (RAILS.md §4). Other clients don't have
|
|
362
|
+
// the id in their set, so they apply the broadcast normally.
|
|
363
|
+
;(this._gridEl.__sgrOwnOps ||= new Set()).add(optimisticId)
|
|
364
|
+
td.dataset.pending = optimisticId
|
|
365
|
+
td.dataset.priorValue = String(oldValue ?? "")
|
|
366
|
+
td.classList.add("sgr-cell-pending")
|
|
367
|
+
|
|
368
|
+
const path = this.cellsPathTemplateValue
|
|
369
|
+
.replace(":resource", encodeURIComponent(this.resourceValue))
|
|
370
|
+
.replace(":row_id", encodeURIComponent(String(rowId)))
|
|
371
|
+
.replace(":column", encodeURIComponent(String(colId)))
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const res = await fetch(path, {
|
|
375
|
+
method: "PATCH",
|
|
376
|
+
credentials: "same-origin",
|
|
377
|
+
headers: {
|
|
378
|
+
"Content-Type": "application/json",
|
|
379
|
+
"Accept": "text/vnd.turbo-stream.html",
|
|
380
|
+
"X-CSRF-Token": csrfToken(),
|
|
381
|
+
"X-Optimistic-Id": optimisticId,
|
|
382
|
+
},
|
|
383
|
+
body: JSON.stringify({
|
|
384
|
+
value: newValue,
|
|
385
|
+
optimistic_id: optimisticId,
|
|
386
|
+
lock_version: td.dataset.lockVersion ? Number(td.dataset.lockVersion) : null,
|
|
387
|
+
}),
|
|
388
|
+
})
|
|
389
|
+
if (!res.ok && res.status !== 422) {
|
|
390
|
+
throw new Error(`HTTP ${res.status}`)
|
|
391
|
+
}
|
|
392
|
+
const html = await res.text()
|
|
393
|
+
// Hand the response off to Turbo so any <turbo-stream> tags in the
|
|
394
|
+
// body run through the registered StreamActions below.
|
|
395
|
+
if (window.Turbo?.renderStreamMessage) {
|
|
396
|
+
window.Turbo.renderStreamMessage(html)
|
|
397
|
+
}
|
|
398
|
+
} catch (err) {
|
|
399
|
+
console.error("[stimulus_grid_rails] PATCH failed:", err)
|
|
400
|
+
// Network failure → roll back locally to prior value.
|
|
401
|
+
this._gridEl.gridApi?.applyTransaction({
|
|
402
|
+
update: [{ id: rowId, [colId]: oldValue }],
|
|
403
|
+
})
|
|
404
|
+
td.classList.remove("sgr-cell-pending")
|
|
405
|
+
td.removeAttribute("data-pending")
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ---------- Stimulus: CellEditorController ----------
|
|
411
|
+
// Currently a placeholder — the base grid uses its own editor. This controller
|
|
412
|
+
// is reserved for custom column editors (autocomplete, picker, etc.) that
|
|
413
|
+
// hosts can register via column(editor: "sku-autocomplete").
|
|
414
|
+
|
|
415
|
+
class CellEditorController extends Controller {
|
|
416
|
+
static values = {
|
|
417
|
+
column: String,
|
|
418
|
+
editor: String,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------- Turbo StreamActions ----------
|
|
423
|
+
|
|
424
|
+
function registerStreamActions() {
|
|
425
|
+
const Turbo = window.Turbo
|
|
426
|
+
if (!Turbo?.StreamActions) {
|
|
427
|
+
console.warn("[stimulus_grid_rails] Turbo not loaded — Stream actions skipped")
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// cell: replace cell content + update underlying gridApi row.
|
|
432
|
+
Turbo.StreamActions.cell = function () {
|
|
433
|
+
const gridEl = gridById(this.getAttribute("grid"))
|
|
434
|
+
const rowId = this.getAttribute("row-id")
|
|
435
|
+
const column = this.getAttribute("column")
|
|
436
|
+
const value = this.templateContent.textContent
|
|
437
|
+
const opId = this.getAttribute("optimistic-id")
|
|
438
|
+
// Suppress the originating client's own echo — it already applied this
|
|
439
|
+
// edit optimistically and gets a cell-confirm via the PATCH response.
|
|
440
|
+
if (opId && gridEl?.__sgrOwnOps?.has(opId)) return
|
|
441
|
+
applyCellUpdate(gridEl, rowId, column, value, { confirm: false })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// cell-attr: set a single attribute on the cell.
|
|
445
|
+
Turbo.StreamActions["cell-attr"] = function () {
|
|
446
|
+
const gridEl = gridById(this.getAttribute("grid"))
|
|
447
|
+
const td = findCell(gridEl, this.getAttribute("row-id"), this.getAttribute("column"))
|
|
448
|
+
if (!td) return
|
|
449
|
+
td.setAttribute(this.getAttribute("attr"), this.getAttribute("value"))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// cell-confirm: own-echo of an optimistic update — clear pending state.
|
|
453
|
+
Turbo.StreamActions["cell-confirm"] = function () {
|
|
454
|
+
const gridEl = gridById(this.getAttribute("grid"))
|
|
455
|
+
const rowId = this.getAttribute("row-id")
|
|
456
|
+
const column = this.getAttribute("column")
|
|
457
|
+
const value = this.templateContent.textContent
|
|
458
|
+
const opId = this.getAttribute("optimistic-id")
|
|
459
|
+
const td = findCell(gridEl, rowId, column)
|
|
460
|
+
if (!td) return
|
|
461
|
+
if (opId && td.dataset.pending && td.dataset.pending !== opId) {
|
|
462
|
+
// A newer optimistic edit superseded this one — leave the cell alone.
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
if (opId) gridEl?.__sgrOwnOps?.delete(opId)
|
|
466
|
+
applyCellUpdate(gridEl, rowId, column, value, { confirm: true })
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// cell-revert: restore prior value + show error.
|
|
470
|
+
Turbo.StreamActions["cell-revert"] = function () {
|
|
471
|
+
const gridEl = gridById(this.getAttribute("grid"))
|
|
472
|
+
const rowId = this.getAttribute("row-id")
|
|
473
|
+
const column = this.getAttribute("column")
|
|
474
|
+
const serverValue = this.templateContent.textContent
|
|
475
|
+
const errors = JSON.parse(this.getAttribute("errors") || "[]")
|
|
476
|
+
applyCellUpdate(gridEl, rowId, column, serverValue, { confirm: true })
|
|
477
|
+
// applyCellUpdate schedules an async re-render that rebuilds the <td>, so
|
|
478
|
+
// style the cell after the render settles — otherwise the class lands on
|
|
479
|
+
// the node about to be replaced and is lost.
|
|
480
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
481
|
+
const td = findCell(gridEl, rowId, column)
|
|
482
|
+
if (!td) return
|
|
483
|
+
td.classList.add("sgr-cell-error")
|
|
484
|
+
td.title = errors.join("\n")
|
|
485
|
+
setTimeout(() => {
|
|
486
|
+
td.classList.remove("sgr-cell-error")
|
|
487
|
+
td.removeAttribute("title")
|
|
488
|
+
}, 4000)
|
|
489
|
+
}))
|
|
490
|
+
// Toast-style event for the host app to handle however it likes.
|
|
491
|
+
gridEl?.dispatchEvent(new CustomEvent("grid:cellError", {
|
|
492
|
+
detail: { rowId, column, errors }, bubbles: true,
|
|
493
|
+
}))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// cell-conflict: server-vs-client value mismatch.
|
|
497
|
+
Turbo.StreamActions["cell-conflict"] = function () {
|
|
498
|
+
const gridEl = gridById(this.getAttribute("grid"))
|
|
499
|
+
const detail = {
|
|
500
|
+
rowId: this.getAttribute("row-id"),
|
|
501
|
+
column: this.getAttribute("column"),
|
|
502
|
+
serverValue: this.getAttribute("server-value"),
|
|
503
|
+
clientValue: this.getAttribute("client-value"),
|
|
504
|
+
optimisticId: this.getAttribute("optimistic-id"),
|
|
505
|
+
}
|
|
506
|
+
const td = findCell(gridEl, detail.rowId, detail.column)
|
|
507
|
+
td?.classList.add("sgr-cell-conflict")
|
|
508
|
+
gridEl?.dispatchEvent(new CustomEvent("grid:cellConflict", {
|
|
509
|
+
detail, bubbles: true,
|
|
510
|
+
}))
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// row-insert-sorted: insert respecting client's current sort.
|
|
514
|
+
Turbo.StreamActions["row-insert-sorted"] = function () {
|
|
515
|
+
const gridEl = gridById(this.getAttribute("grid"))
|
|
516
|
+
if (!gridEl?.gridApi) return
|
|
517
|
+
// The <template> payload may be either rendered <tr>...</tr> HTML or a
|
|
518
|
+
// JSON-encoded row object. We support both — JSON is preferred.
|
|
519
|
+
const raw = this.templateContent.textContent.trim()
|
|
520
|
+
let rowObj = null
|
|
521
|
+
try { rowObj = JSON.parse(raw) } catch { /* not JSON */ }
|
|
522
|
+
if (rowObj) {
|
|
523
|
+
gridEl.gridApi.applyTransaction({ add: [rowObj] })
|
|
524
|
+
} else {
|
|
525
|
+
console.warn("[stimulus_grid_rails] row-insert-sorted: HTML payload not yet supported")
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// row-remove: drop a row by id.
|
|
530
|
+
Turbo.StreamActions["row-remove"] = function () {
|
|
531
|
+
const gridEl = gridById(this.getAttribute("grid"))
|
|
532
|
+
const rowId = this.getAttribute("row-id")
|
|
533
|
+
if (!gridEl?.gridApi) return
|
|
534
|
+
const rows = gridEl.gridApi.getRowData()
|
|
535
|
+
const victim = rows.find(r => String(r.id) === String(rowId))
|
|
536
|
+
if (victim) gridEl.gridApi.applyTransaction({ remove: [victim] })
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// bulk: atomic batch. The inner <turbo-stream> nodes live inside a
|
|
540
|
+
// <template>, so they are NOT upgraded custom elements and lack
|
|
541
|
+
// `templateContent`. Re-serialize the fragment and feed it back through
|
|
542
|
+
// Turbo, which parses + upgrades + executes each inner stream.
|
|
543
|
+
Turbo.StreamActions.bulk = function () {
|
|
544
|
+
const tmp = document.createElement("div")
|
|
545
|
+
tmp.appendChild(this.templateContent.cloneNode(true))
|
|
546
|
+
window.Turbo.renderStreamMessage(tmp.innerHTML)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// aggregate: update a footer cell. Target element matches
|
|
550
|
+
// [data-grid-aggregate="<grid>:<column>:<kind>"].
|
|
551
|
+
Turbo.StreamActions.aggregate = function () {
|
|
552
|
+
const grid = this.getAttribute("grid")
|
|
553
|
+
const column = this.getAttribute("column")
|
|
554
|
+
const kind = this.getAttribute("kind")
|
|
555
|
+
const value = this.templateContent.textContent
|
|
556
|
+
document
|
|
557
|
+
.querySelectorAll(`[data-grid-aggregate="${grid}:${column}:${kind}"]`)
|
|
558
|
+
.forEach(el => { el.textContent = value })
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// presence: per-user editing indicator badge.
|
|
562
|
+
Turbo.StreamActions.presence = function () {
|
|
563
|
+
const gridEl = gridById(this.getAttribute("grid"))
|
|
564
|
+
const td = findCell(gridEl, this.getAttribute("row-id"), this.getAttribute("column"))
|
|
565
|
+
if (!td) return
|
|
566
|
+
const userId = this.getAttribute("user-id")
|
|
567
|
+
const label = this.getAttribute("user-label")
|
|
568
|
+
const active = this.getAttribute("active") === "true"
|
|
569
|
+
const existing = td.querySelector(`.sgr-presence[data-user-id="${CSS.escape(userId)}"]`)
|
|
570
|
+
if (active && !existing) {
|
|
571
|
+
const badge = document.createElement("span")
|
|
572
|
+
badge.className = "sgr-presence"
|
|
573
|
+
badge.dataset.userId = userId
|
|
574
|
+
badge.title = `${label} is editing`
|
|
575
|
+
badge.textContent = label.slice(0, 2).toUpperCase()
|
|
576
|
+
td.appendChild(badge)
|
|
577
|
+
} else if (!active && existing) {
|
|
578
|
+
existing.remove()
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Apply a server-pushed cell value into both the DOM and the underlying
|
|
584
|
+
// gridApi's row data so future renders show the right value.
|
|
585
|
+
function applyCellUpdate(gridEl, rowId, column, value, { confirm }) {
|
|
586
|
+
if (!gridEl?.gridApi) return
|
|
587
|
+
const rows = gridEl.gridApi.getRowData()
|
|
588
|
+
const row = rows.find(r => String(r.id) === String(rowId))
|
|
589
|
+
if (!row) return
|
|
590
|
+
const coerced = coerceFromString(value, typeof row[column])
|
|
591
|
+
if (row[column] !== coerced) {
|
|
592
|
+
// Spread the found row so the transaction carries the row's own id type
|
|
593
|
+
// (number vs string) — applyTransaction matches rows by their id, and a
|
|
594
|
+
// raw attribute string won't match a numerically-keyed row.
|
|
595
|
+
gridEl.gridApi.applyTransaction({ update: [{ ...row, [column]: coerced }] })
|
|
596
|
+
}
|
|
597
|
+
const td = findCell(gridEl, rowId, column)
|
|
598
|
+
if (!td) return
|
|
599
|
+
if (confirm) {
|
|
600
|
+
td.classList.remove("sgr-cell-pending")
|
|
601
|
+
td.removeAttribute("data-pending")
|
|
602
|
+
td.removeAttribute("data-prior-value")
|
|
603
|
+
td.classList.add("sgr-cell-just-confirmed")
|
|
604
|
+
setTimeout(() => td.classList.remove("sgr-cell-just-confirmed"), 500)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function coerceFromString(s, existingType) {
|
|
609
|
+
if (existingType === "number") {
|
|
610
|
+
const n = Number(s)
|
|
611
|
+
return Number.isFinite(n) ? n : s
|
|
612
|
+
}
|
|
613
|
+
if (existingType === "boolean") return s === "true"
|
|
614
|
+
return s
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ---------- entry ----------
|
|
618
|
+
|
|
619
|
+
function start(application) {
|
|
620
|
+
if (!application) {
|
|
621
|
+
throw new Error("[stimulus_grid_rails] start(application) requires a Stimulus Application")
|
|
622
|
+
}
|
|
623
|
+
application.register("grid-sync", GridSyncController)
|
|
624
|
+
application.register("cell-editor", CellEditorController)
|
|
625
|
+
registerStreamActions()
|
|
626
|
+
return application
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export { start, GridSyncController, CellEditorController, registerStreamActions }
|
|
630
|
+
export default { start }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.sg-grid{--sg-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--sg-fg: #1f2937;--sg-fg-muted: #6b7280;--sg-bg: #ffffff;--sg-bg-alt: #f9fafb;--sg-bg-header: #f3f4f6;--sg-bg-hover: #eef2ff;--sg-bg-selected: #dbeafe;--sg-bg-selected-hover: #bfdbfe;--sg-bg-row-selected: #c7f0d5;--sg-bg-row-selected-hover: #aee7c4;--sg-border: #e5e7eb;--sg-border-strong: #d1d5db;--sg-accent: #2563eb;--sg-row-height: 32px;--sg-header-height: 36px;--sg-cell-pad-x: 10px;font-family:var(--sg-font);color:var(--sg-fg);background:var(--sg-bg);border:1px solid var(--sg-border);border-radius:6px;font-size:13px;position:relative;overflow:hidden;display:flex;flex-direction:column}.sg-grid table{width:100%;border-collapse:separate;border-spacing:0;table-layout:fixed}.sg-grid thead{background:var(--sg-bg-header);position:sticky;top:0;z-index:2}.sg-grid thead th{height:var(--sg-header-height);padding:0 var(--sg-cell-pad-x);text-align:left;font-weight:600;font-size:12px;color:var(--sg-fg);border-bottom:1px solid var(--sg-border-strong);border-right:1px solid var(--sg-border);position:relative;user-select:none;box-sizing:border-box;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.sg-grid thead th:last-child{border-right:none}.sg-grid thead th[data-sortable=true]{cursor:pointer}.sg-grid thead th[data-sortable=true]:hover{background:var(--sg-bg-hover)}.sg-grid .sg-header-content{display:flex;align-items:center;gap:6px;height:100%;overflow:hidden}.sg-grid .sg-header-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sg-grid .sg-sort-icon{font-size:10px;color:var(--sg-fg-muted);width:12px;display:inline-block;text-align:right;visibility:hidden}.sg-grid thead th[data-sort=asc] .sg-sort-icon:before{content:"▲";visibility:visible}.sg-grid thead th[data-sort=desc] .sg-sort-icon:before{content:"▼";visibility:visible}.sg-grid thead th[data-sort=asc] .sg-sort-icon,.sg-grid thead th[data-sort=desc] .sg-sort-icon{visibility:visible;color:var(--sg-accent)}.sg-grid .sg-sort-index{font-size:9px;color:var(--sg-accent);vertical-align:super}.sg-grid .sg-filter-icon{font-size:12px;color:var(--sg-fg-muted);cursor:pointer;opacity:.45;padding:2px 4px;border-radius:2px}.sg-grid thead th[data-filterable=true]:hover .sg-filter-icon{opacity:1}.sg-grid .sg-filter-icon:hover{background:#0000000f}.sg-grid thead th[data-filter-active=true] .sg-filter-icon{opacity:1;color:var(--sg-accent)}.sg-grid .sg-filter-icon:before{content:"≡"}.sg-grid .sg-resize-handle{position:absolute;top:0;right:-3px;width:6px;height:100%;cursor:col-resize;z-index:3}.sg-grid .sg-resize-handle:hover{background:var(--sg-accent);opacity:.4}.sg-grid .sg-body-viewport{flex:1;overflow:auto;position:relative}.sg-grid .sg-body-canvas{position:relative;width:100%}.sg-grid tbody tr{background:var(--sg-bg)}.sg-grid tbody tr:nth-child(2n){background:var(--sg-bg-alt)}.sg-grid tbody tr:hover{background:var(--sg-bg-hover)}.sg-grid tbody tr[data-selected=true]{background:var(--sg-bg-row-selected)}.sg-grid tbody tr[data-selected=true]:hover{background:var(--sg-bg-row-selected-hover)}.sg-grid tbody td{height:var(--sg-row-height);padding:0 var(--sg-cell-pad-x);border-bottom:1px solid var(--sg-border);border-right:1px solid var(--sg-border);box-sizing:border-box;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;vertical-align:middle;user-select:none;-webkit-user-select:none}.sg-grid tbody td:last-child{border-right:none}.sg-grid tbody td[data-editing=true] input,.sg-grid tbody td[data-editing=true] textarea{user-select:text;-webkit-user-select:text}.sg-grid:focus,.sg-grid:focus-visible{outline:none}.sg-drag-ghost{position:fixed;z-index:10000;pointer-events:none;opacity:.9;background:var(--sg-bg);border:1px solid var(--sg-accent);border-radius:5px;box-shadow:0 10px 28px #00000047;overflow:hidden}.sg-drag-ghost table{border-collapse:collapse;width:100%;table-layout:fixed}.sg-drag-ghost td{height:30px;padding:0 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;color:var(--sg-fg);border-bottom:1px solid var(--sg-border);position:static!important;background:var(--sg-bg)!important}.sg-drag-ghost-more{padding:4px 10px;font-size:11px;color:var(--sg-fg-muted);background:var(--sg-bg-alt);border-top:1px solid var(--sg-border)}.sg-drop-indicator{position:fixed;height:2px;background:var(--sg-accent);z-index:9999;pointer-events:none}body.sg-row-dragging,body.sg-row-dragging *{cursor:grabbing!important}.sg-grid[data-row-drag-value=true] .sg-gutter-cell{cursor:grab}.sg-grid .sg-gutter-cell,.sg-grid th.sg-gutter-header{background:var(--sg-bg-header);color:var(--sg-fg-muted);text-align:center;font-size:11px;font-variant-numeric:tabular-nums;user-select:none;cursor:pointer}.sg-grid tbody tr[data-selected=true] .sg-gutter-cell{background:var(--sg-bg-row-selected);color:var(--sg-fg);font-weight:600}.sg-grid tbody td[data-cell-range=true]{background:var(--sg-bg-selected)}.sg-grid tbody tr[data-selected=true] td[data-cell-range=true]{background:var(--sg-bg-selected-hover)}.sg-grid tbody td[data-cell-active=true]{outline:2px solid var(--sg-accent);outline-offset:-2px;z-index:3}.sg-grid tbody td[data-editing=true]{padding:0}.sg-grid tbody td[data-editing=true] input,.sg-grid tbody td[data-editing=true] select{width:100%;height:100%;border:2px solid var(--sg-accent);padding:0 var(--sg-cell-pad-x);font:inherit;background:var(--sg-bg);box-sizing:border-box;outline:none}.sg-grid tbody td[data-focused=true]{outline:2px solid var(--sg-accent);outline-offset:-2px}.sg-grid .sg-checkbox-cell,.sg-grid .sg-checkbox-header{width:36px;text-align:center;padding:0}.sg-grid .sg-pagination{display:flex;align-items:center;gap:8px;padding:6px 10px;border-top:1px solid var(--sg-border);background:var(--sg-bg-header);font-size:12px;color:var(--sg-fg-muted)}.sg-grid .sg-pagination button{background:var(--sg-bg);border:1px solid var(--sg-border-strong);border-radius:3px;width:24px;height:24px;cursor:pointer;font-size:12px;display:inline-flex;align-items:center;justify-content:center}.sg-grid .sg-pagination button:disabled{opacity:.4;cursor:default}.sg-grid .sg-pagination button:not(:disabled):hover{background:var(--sg-bg-hover)}.sg-grid .sg-pagination select{background:var(--sg-bg);border:1px solid var(--sg-border-strong);border-radius:3px;height:24px;padding:0 4px;font:inherit}.sg-grid .sg-pagination .sg-spacer{flex:1}.sg-filter-popover{position:absolute;z-index:10;background:var(--sg-bg, #fff);border:1px solid var(--sg-border-strong, #d1d5db);border-radius:4px;box-shadow:0 4px 12px #0000001a;padding:8px;min-width:200px;font-family:var(--sg-font, system-ui, sans-serif);font-size:12px;display:flex;flex-direction:column;gap:6px}.sg-filter-popover label{color:var(--sg-fg-muted, #6b7280)}.sg-filter-popover select,.sg-filter-popover input{width:100%;border:1px solid var(--sg-border-strong, #d1d5db);border-radius:3px;padding:4px 6px;font:inherit;background:#fff;box-sizing:border-box}.sg-filter-popover .sg-filter-actions{display:flex;justify-content:flex-end;gap:4px;margin-top:4px}.sg-filter-popover button{background:var(--sg-bg, #fff);border:1px solid var(--sg-border-strong, #d1d5db);border-radius:3px;padding:3px 8px;cursor:pointer;font:inherit}.sg-filter-popover button.primary{background:var(--sg-accent, #2563eb);border-color:var(--sg-accent, #2563eb);color:#fff}.sg-grid th[data-pinned=left],.sg-grid td[data-pinned=left]{position:sticky;left:0;z-index:1;background:var(--sg-bg);border-right:2px solid var(--sg-border-strong)}.sg-grid thead th[data-pinned=left]{background:var(--sg-bg-header);z-index:3}.sg-grid tbody tr:nth-child(2n) td[data-pinned=left]{background:var(--sg-bg-alt)}.sg-grid tbody tr[data-selected=true] td[data-pinned=left]{background:var(--sg-bg-row-selected)}.sg-grid th[data-pinned=right],.sg-grid td[data-pinned=right]{position:sticky;right:0;z-index:1;background:var(--sg-bg);border-left:2px solid var(--sg-border-strong)}.sg-grid thead th[data-pinned=right]{background:var(--sg-bg-header);z-index:3}.sg-grid tbody tr:nth-child(2n) td[data-pinned=right]{background:var(--sg-bg-alt)}.sg-grid tbody tr[data-selected=true] td[data-pinned=right]{background:var(--sg-bg-row-selected)}.sg-grid tbody td[data-pinned][data-cell-range=true],.sg-grid tbody tr:nth-child(2n) td[data-pinned][data-cell-range=true],.sg-grid tbody tr[data-selected=true] td[data-pinned][data-cell-range=true]{background:var(--sg-bg-selected)}
|