studio-engine 0.6.1 → 0.6.2

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: e0dff947db7bda136b0fcca271b86347449fa0d5f259c102ba5fe50e8e29dea8
4
- data.tar.gz: 898fb4de879042127da3c85ee072603a8cf4d02125037d13d16793ecc8579e17
3
+ metadata.gz: bc7a335b47486105708b2b7739c6a567e1486c6a68b2bf6d2e5e5abd8bc8d6a1
4
+ data.tar.gz: a0ab08402099d51e7fa059f6171cd83a7ad175c7a6ba2ae983e59dba9a36672f
5
5
  SHA512:
6
- metadata.gz: 91f96cfc96d999ef6ea31aa7d427fb903c538da72470768891dd1e3667d7e5380bf8f931089c24548b9ad844b4c7815e0a9167231dd989c36fd674f04391e831
7
- data.tar.gz: dc439df4fc3125f2a98c1511606008f74df5dc869c98a72ca0c4fe6a8ba4e082d093d5093de211e9b6534e280140cb35a6ba31952dc40b667e5fbe5de1b04552
6
+ metadata.gz: f4b8203aa82fb630a33c0c12b3cc77f2f51cfe3cbbb8328354c6743a43643758403d570ecfb78569b386b614ca12cedeb29b81b1c122b1f1104fa7ab3ba83998
7
+ data.tar.gz: e568830a6432462d083ad833d75f999a9447f3547f97a83fbb7b9f9ea9fdfcdc2b05566e0f77159b72bb4a84d8395e5aaeef61e3a1ebbae7f931167bcf36df9b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## v0.6.2 (2026-06-19)
8
+
9
+ ### Added
10
+ - **`Studio.sticky_table_headers` opt-in** for shared sticky table headers. The
11
+ flag defaults off and, when enabled by a consumer app, loads the engine-owned
12
+ `studio/sticky_table_header.css` and `studio/sticky_table_header.js` assets
13
+ from the shared Studio head partial.
14
+
7
15
  ## v0.6.1 (2026-06-18)
8
16
 
9
17
  ### Added
@@ -0,0 +1,194 @@
1
+ (() => {
2
+ if (window.StudioStickyTableHeaders?.loaded) return;
3
+
4
+ const MANUAL_TABLE_SELECTOR = "table[data-sticky-table-header]";
5
+ const AUTO_TABLE_SELECTOR = "table";
6
+ const SKIP_CONTAINER_SELECTOR = "[data-sticky-table-skip]";
7
+
8
+ let stickyTables = [];
9
+ let scheduled = false;
10
+ let bootScheduled = false;
11
+ let navResizeObserver = null;
12
+ let tableMutationObserver = null;
13
+
14
+ window.StudioStickyTableHeaders = { loaded: true };
15
+
16
+ function navOffset() {
17
+ const raw = getComputedStyle(document.documentElement).getPropertyValue("--nav-h");
18
+ return Number.parseFloat(raw) || 0;
19
+ }
20
+
21
+ function scheduleUpdate() {
22
+ if (scheduled) return;
23
+ scheduled = true;
24
+ requestAnimationFrame(() => {
25
+ scheduled = false;
26
+ stickyTables.forEach((stickyTable) => stickyTable.update());
27
+ });
28
+ }
29
+
30
+ function scheduleBoot() {
31
+ if (bootScheduled) return;
32
+ bootScheduled = true;
33
+ requestAnimationFrame(() => {
34
+ bootScheduled = false;
35
+ bootStickyTables();
36
+ });
37
+ }
38
+
39
+ class StickyTableHeader {
40
+ constructor(table) {
41
+ this.table = table;
42
+ this.prepareTable();
43
+ this.scroller = table.closest("[data-sticky-table-scroll]") || table.parentElement;
44
+ this.cloneShell = document.createElement("div");
45
+ this.cloneShell.className = "sticky-table-header-clone";
46
+ this.cloneShell.setAttribute("aria-hidden", "true");
47
+
48
+ this.cloneTable = document.createElement("table");
49
+ this.cloneTable.className = table.className;
50
+ this.cloneTable.innerHTML = table.tHead ? table.tHead.outerHTML : "";
51
+ this.cloneShell.appendChild(this.cloneTable);
52
+ document.body.appendChild(this.cloneShell);
53
+
54
+ this.scroller?.addEventListener("scroll", scheduleUpdate, { passive: true });
55
+ }
56
+
57
+ prepareTable() {
58
+ this.table.classList.add("sticky-data-table");
59
+ if (!this.table.hasAttribute("data-sticky-table-header")) {
60
+ this.table.setAttribute("data-sticky-table-header", "auto");
61
+ }
62
+
63
+ const scroller = this.table.closest("[data-sticky-table-scroll]") || this.table.parentElement;
64
+ if (scroller && !scroller.hasAttribute("data-sticky-table-scroll")) {
65
+ scroller.setAttribute("data-sticky-table-scroll", "auto");
66
+ }
67
+ }
68
+
69
+ destroy() {
70
+ this.scroller?.removeEventListener("scroll", scheduleUpdate);
71
+ this.cloneShell.remove();
72
+ }
73
+
74
+ syncColumnWidths() {
75
+ const originalCells = this.table.tHead?.querySelectorAll("th") || [];
76
+ const cloneCells = this.cloneTable.tHead?.querySelectorAll("th") || [];
77
+ originalCells.forEach((cell, index) => {
78
+ if (!cloneCells[index]) return;
79
+ cloneCells[index].style.width = `${cell.getBoundingClientRect().width}px`;
80
+ });
81
+ }
82
+
83
+ update() {
84
+ if (!document.body.contains(this.table) || !this.table.tHead || !this.scroller) {
85
+ this.destroy();
86
+ stickyTables = stickyTables.filter((stickyTable) => stickyTable !== this);
87
+ return;
88
+ }
89
+
90
+ const offset = navOffset();
91
+ const headerRect = this.table.tHead.getBoundingClientRect();
92
+ const tableRect = this.table.getBoundingClientRect();
93
+ const scrollerRect = this.scroller.getBoundingClientRect();
94
+ const cloneHeight = headerRect.height || this.cloneShell.getBoundingClientRect().height;
95
+ const active = headerRect.top <= offset && tableRect.bottom > offset;
96
+
97
+ if (!active) {
98
+ this.cloneShell.style.display = "none";
99
+ this.table.removeAttribute("data-sticky-table-active");
100
+ return;
101
+ }
102
+
103
+ this.syncColumnWidths();
104
+
105
+ const top = Math.min(offset, tableRect.bottom - cloneHeight);
106
+ const translateX = tableRect.left - scrollerRect.left;
107
+
108
+ this.cloneShell.style.display = "block";
109
+ this.cloneShell.style.left = `${scrollerRect.left}px`;
110
+ this.cloneShell.style.top = `${top}px`;
111
+ this.cloneShell.style.width = `${scrollerRect.width}px`;
112
+ this.cloneShell.style.height = `${cloneHeight}px`;
113
+ this.cloneTable.style.width = `${tableRect.width}px`;
114
+ this.cloneTable.style.transform = `translateX(${translateX}px)`;
115
+ this.table.setAttribute("data-sticky-table-active", "true");
116
+ }
117
+ }
118
+
119
+ function bootStickyTables() {
120
+ startTableObserver();
121
+
122
+ if (navResizeObserver) navResizeObserver.disconnect();
123
+ if (window.ResizeObserver) {
124
+ const header = document.querySelector("header");
125
+ if (header) {
126
+ navResizeObserver = new ResizeObserver(scheduleUpdate);
127
+ navResizeObserver.observe(header);
128
+ }
129
+ }
130
+
131
+ stickyTables.forEach((stickyTable) => stickyTable.destroy());
132
+ stickyTables = stickyTableCandidates().map((table) => new StickyTableHeader(table));
133
+ scheduleUpdate();
134
+ }
135
+
136
+ function startTableObserver() {
137
+ if (!window.MutationObserver || tableMutationObserver || !document.body) return;
138
+
139
+ tableMutationObserver = new MutationObserver((mutations) => {
140
+ const shouldReboot = mutations.some((mutation) => (
141
+ Array.from(mutation.addedNodes).some(nodeContainsCandidateTable) ||
142
+ Array.from(mutation.removedNodes).some(nodeContainsCandidateTable)
143
+ ));
144
+
145
+ if (shouldReboot) scheduleBoot();
146
+ });
147
+
148
+ tableMutationObserver.observe(document.body, { childList: true, subtree: true });
149
+ }
150
+
151
+ function nodeContainsCandidateTable(node) {
152
+ if (node.nodeType !== Node.ELEMENT_NODE) return false;
153
+ if (node.matches(".sticky-table-header-clone") || node.closest(".sticky-table-header-clone")) return false;
154
+
155
+ return node.matches("table, thead, th") || Boolean(node.querySelector("table, thead, th"));
156
+ }
157
+
158
+ function stickyTableCandidates() {
159
+ const candidates = [];
160
+ const seen = new Set();
161
+
162
+ document.querySelectorAll(`${MANUAL_TABLE_SELECTOR}, ${AUTO_TABLE_SELECTOR}`).forEach((table) => {
163
+ if (seen.has(table) || !shouldEnhanceTable(table)) return;
164
+ seen.add(table);
165
+ candidates.push(table);
166
+ });
167
+
168
+ return candidates;
169
+ }
170
+
171
+ function shouldEnhanceTable(table) {
172
+ if (table.getAttribute("data-sticky-table-header") === "false") return false;
173
+ if (table.closest(SKIP_CONTAINER_SELECTOR)) return false;
174
+ if (table.closest(".sticky-table-header-clone")) return false;
175
+ if (table.closest("template")) return false;
176
+ if (table.getAttribute("role")?.toLowerCase() === "presentation") return false;
177
+ if (!table.tHead || !table.tHead.querySelector("th")) return false;
178
+
179
+ return true;
180
+ }
181
+
182
+ document.addEventListener("turbo:load", bootStickyTables);
183
+ document.addEventListener("DOMContentLoaded", bootStickyTables);
184
+ document.addEventListener("turbo:before-cache", () => {
185
+ if (navResizeObserver) navResizeObserver.disconnect();
186
+ if (tableMutationObserver) tableMutationObserver.disconnect();
187
+ navResizeObserver = null;
188
+ tableMutationObserver = null;
189
+ stickyTables.forEach((stickyTable) => stickyTable.destroy());
190
+ stickyTables = [];
191
+ });
192
+ window.addEventListener("scroll", scheduleUpdate, { passive: true });
193
+ window.addEventListener("resize", scheduleUpdate, { passive: true });
194
+ })();
@@ -0,0 +1,48 @@
1
+ .sticky-data-table {
2
+ border-collapse: separate;
3
+ border-spacing: 0;
4
+ }
5
+
6
+ .sticky-data-table thead th {
7
+ background: var(--color-surface-alt);
8
+ box-shadow:
9
+ inset 0 -1px 0 var(--color-border),
10
+ 0 10px 18px -18px rgba(15, 23, 42, 0.55);
11
+ transition:
12
+ background-color 200ms ease,
13
+ box-shadow 200ms ease,
14
+ top 200ms ease;
15
+ }
16
+
17
+ .dark .sticky-data-table thead th {
18
+ box-shadow:
19
+ inset 0 -1px 0 var(--color-border),
20
+ 0 12px 22px -18px rgba(0, 0, 0, 0.85);
21
+ }
22
+
23
+ .sticky-table-header-clone {
24
+ position: fixed;
25
+ display: none;
26
+ overflow: hidden;
27
+ pointer-events: none;
28
+ z-index: 45;
29
+ }
30
+
31
+ .sticky-table-header-clone table {
32
+ border-collapse: separate;
33
+ border-spacing: 0;
34
+ }
35
+
36
+ .sticky-table-header-clone thead th {
37
+ position: static;
38
+ background: var(--color-surface-alt);
39
+ box-shadow:
40
+ inset 0 -1px 0 var(--color-border),
41
+ 0 12px 22px -18px rgba(15, 23, 42, 0.65);
42
+ }
43
+
44
+ .dark .sticky-table-header-clone thead th {
45
+ box-shadow:
46
+ inset 0 -1px 0 var(--color-border),
47
+ 0 14px 24px -18px rgba(0, 0, 0, 0.9);
48
+ }
@@ -107,4 +107,10 @@
107
107
 
108
108
  <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
109
109
  <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
110
+ <% if Studio.sticky_table_headers %>
111
+ <%= stylesheet_link_tag "studio/sticky_table_header", "data-turbo-track": "reload" %>
112
+ <% end %>
110
113
  <%= javascript_importmap_tags %>
114
+ <% if Studio.sticky_table_headers %>
115
+ <%= javascript_include_tag "studio/sticky_table_header", defer: true, "data-turbo-track": "reload" %>
116
+ <% end %>
data/lib/studio/engine.rb CHANGED
@@ -1,5 +1,12 @@
1
1
  module Studio
2
2
  class Engine < ::Rails::Engine
3
+ initializer "studio.assets" do |app|
4
+ app.config.assets.precompile += %w[
5
+ studio/sticky_table_header.css
6
+ studio/sticky_table_header.js
7
+ ]
8
+ end
9
+
3
10
  rake_tasks do
4
11
  load File.expand_path("../tasks/studio_email.rake", __dir__)
5
12
  load File.expand_path("../tasks/studio_ses.rake", __dir__)
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.6.1"
2
+ VERSION = "0.6.2"
3
3
  end
data/lib/studio.rb CHANGED
@@ -20,6 +20,7 @@ module Studio
20
20
  mattr_accessor :sso_logo, default: nil
21
21
  mattr_accessor :wallet_address_method, default: nil
22
22
  mattr_accessor :theme_logos, default: []
23
+ mattr_accessor :sticky_table_headers, default: false
23
24
 
24
25
  # ---- Authentication ------------------------------------------------------
25
26
  # Which sign-in methods this app offers. The shared login/signup views render
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-18 00:00:00.000000000 Z
11
+ date: 2026-06-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -142,6 +142,8 @@ files:
142
142
  - README.md
143
143
  - app/assets/images/resend-favicon.png
144
144
  - app/assets/images/ses-favicon.png
145
+ - app/assets/javascripts/studio/sticky_table_header.js
146
+ - app/assets/stylesheets/studio/sticky_table_header.css
145
147
  - app/controllers/concerns/solana/session_auth.rb
146
148
  - app/controllers/concerns/studio/admin_models.rb
147
149
  - app/controllers/concerns/studio/error_handling.rb