plutonium 0.49.1 → 0.51.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.
Files changed (206) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +37 -0
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1323 -1184
  14. data/app/assets/plutonium.js.map +4 -4
  15. data/app/assets/plutonium.min.js +50 -49
  16. data/app/assets/plutonium.min.js.map +4 -4
  17. data/app/views/plutonium/_resource_header.html.erb +4 -4
  18. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  19. data/app/views/resource/_resource_grid.html.erb +1 -0
  20. data/config/brakeman.ignore +25 -2
  21. data/docs/.vitepress/config.ts +37 -27
  22. data/docs/getting-started/index.md +22 -29
  23. data/docs/getting-started/installation.md +37 -80
  24. data/docs/getting-started/tutorial/index.md +4 -5
  25. data/docs/guides/adding-resources.md +66 -377
  26. data/docs/guides/authentication.md +94 -463
  27. data/docs/guides/authorization.md +124 -370
  28. data/docs/guides/creating-packages.md +94 -296
  29. data/docs/guides/custom-actions.md +121 -441
  30. data/docs/guides/index.md +22 -42
  31. data/docs/guides/multi-tenancy.md +116 -187
  32. data/docs/guides/nested-resources.md +103 -431
  33. data/docs/guides/search-filtering.md +123 -240
  34. data/docs/guides/testing.md +5 -4
  35. data/docs/guides/theming.md +157 -407
  36. data/docs/guides/troubleshooting.md +5 -3
  37. data/docs/guides/user-invites.md +106 -425
  38. data/docs/guides/user-profile.md +76 -243
  39. data/docs/index.md +1 -1
  40. data/docs/reference/app/generators.md +517 -0
  41. data/docs/reference/app/index.md +158 -0
  42. data/docs/reference/app/packages.md +146 -0
  43. data/docs/reference/app/portals.md +377 -0
  44. data/docs/reference/auth/accounts.md +230 -0
  45. data/docs/reference/auth/index.md +88 -0
  46. data/docs/reference/auth/profile.md +185 -0
  47. data/docs/reference/behavior/controllers.md +395 -0
  48. data/docs/reference/behavior/index.md +22 -0
  49. data/docs/reference/behavior/interactions.md +341 -0
  50. data/docs/reference/behavior/policies.md +417 -0
  51. data/docs/reference/index.md +56 -49
  52. data/docs/reference/resource/actions.md +423 -0
  53. data/docs/reference/resource/definition.md +508 -0
  54. data/docs/reference/resource/index.md +50 -0
  55. data/docs/reference/resource/model.md +348 -0
  56. data/docs/reference/resource/query.md +305 -0
  57. data/docs/reference/tenancy/entity-scoping.md +361 -0
  58. data/docs/reference/tenancy/index.md +36 -0
  59. data/docs/reference/tenancy/invites.md +393 -0
  60. data/docs/reference/tenancy/nested-resources.md +267 -0
  61. data/docs/reference/testing/index.md +287 -0
  62. data/docs/reference/ui/assets.md +400 -0
  63. data/docs/reference/ui/components.md +165 -0
  64. data/docs/reference/ui/displays.md +104 -0
  65. data/docs/reference/ui/forms.md +284 -0
  66. data/docs/reference/ui/index.md +30 -0
  67. data/docs/reference/ui/layouts.md +106 -0
  68. data/docs/reference/ui/pages.md +189 -0
  69. data/docs/reference/ui/tables.md +117 -0
  70. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  71. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  72. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  73. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  74. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  75. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  76. data/gemfiles/rails_7.gemfile.lock +1 -1
  77. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  78. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  79. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  80. data/lib/generators/pu/invites/install_generator.rb +1 -0
  81. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  82. data/lib/plutonium/action/base.rb +44 -1
  83. data/lib/plutonium/action/interactive.rb +1 -1
  84. data/lib/plutonium/configuration.rb +4 -0
  85. data/lib/plutonium/definition/actions.rb +3 -0
  86. data/lib/plutonium/definition/base.rb +8 -0
  87. data/lib/plutonium/definition/index_views.rb +95 -0
  88. data/lib/plutonium/definition/metadata.rb +40 -0
  89. data/lib/plutonium/helpers/turbo_helper.rb +12 -1
  90. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  91. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  92. data/lib/plutonium/query/base.rb +8 -0
  93. data/lib/plutonium/query/filters/association.rb +30 -8
  94. data/lib/plutonium/query/filters/boolean.rb +5 -0
  95. data/lib/plutonium/resource/controller.rb +1 -0
  96. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  97. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  98. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  99. data/lib/plutonium/resource/definition.rb +42 -0
  100. data/lib/plutonium/resource/policy.rb +7 -0
  101. data/lib/plutonium/resource/query_object.rb +64 -6
  102. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  103. data/lib/plutonium/testing/resource_definition.rb +2 -2
  104. data/lib/plutonium/ui/action_button.rb +4 -2
  105. data/lib/plutonium/ui/component/kit.rb +12 -0
  106. data/lib/plutonium/ui/component/methods.rb +4 -0
  107. data/lib/plutonium/ui/display/base.rb +3 -1
  108. data/lib/plutonium/ui/display/resource.rb +109 -25
  109. data/lib/plutonium/ui/display/theme.rb +2 -1
  110. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  111. data/lib/plutonium/ui/empty_card.rb +1 -1
  112. data/lib/plutonium/ui/form/base.rb +35 -3
  113. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  114. data/lib/plutonium/ui/form/components/json.rb +58 -0
  115. data/lib/plutonium/ui/form/components/resource_select.rb +133 -1
  116. data/lib/plutonium/ui/form/components/secure_association.rb +105 -24
  117. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  118. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  119. data/lib/plutonium/ui/form/resource.rb +45 -10
  120. data/lib/plutonium/ui/form/theme.rb +1 -1
  121. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  122. data/lib/plutonium/ui/grid/card.rb +235 -0
  123. data/lib/plutonium/ui/grid/resource.rb +149 -0
  124. data/lib/plutonium/ui/layout/base.rb +38 -1
  125. data/lib/plutonium/ui/layout/header.rb +1 -2
  126. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  127. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  128. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  129. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  130. data/lib/plutonium/ui/modal/base.rb +109 -0
  131. data/lib/plutonium/ui/modal/centered.rb +21 -0
  132. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  133. data/lib/plutonium/ui/page/base.rb +18 -6
  134. data/lib/plutonium/ui/page/edit.rb +13 -1
  135. data/lib/plutonium/ui/page/index.rb +40 -1
  136. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  137. data/lib/plutonium/ui/page/new.rb +13 -1
  138. data/lib/plutonium/ui/page/show.rb +8 -1
  139. data/lib/plutonium/ui/page_header.rb +8 -13
  140. data/lib/plutonium/ui/panel.rb +10 -19
  141. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  142. data/lib/plutonium/ui/tab_list.rb +29 -7
  143. data/lib/plutonium/ui/table/base.rb +106 -0
  144. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  145. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  146. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  147. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  148. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  149. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  150. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  151. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  152. data/lib/plutonium/ui/table/resource.rb +158 -89
  153. data/lib/plutonium/ui/table/theme.rb +14 -5
  154. data/lib/plutonium/version.rb +1 -1
  155. data/lib/plutonium.rb +14 -0
  156. data/lib/tasks/release.rake +15 -1
  157. data/package.json +10 -10
  158. data/src/css/components.css +304 -131
  159. data/src/css/slim_select.css +4 -0
  160. data/src/css/tokens.css +101 -85
  161. data/src/js/controllers/autosubmit_controller.js +24 -0
  162. data/src/js/controllers/bulk_actions_controller.js +15 -16
  163. data/src/js/controllers/capture_url_controller.js +14 -0
  164. data/src/js/controllers/filter_panel_controller.js +77 -19
  165. data/src/js/controllers/frame_navigator_controller.js +34 -6
  166. data/src/js/controllers/icon_rail_controller.js +22 -0
  167. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  168. data/src/js/controllers/register_controllers.js +16 -0
  169. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  170. data/src/js/controllers/row_click_controller.js +21 -0
  171. data/src/js/controllers/slim_select_controller.js +61 -0
  172. data/src/js/controllers/table_column_menu_controller.js +43 -0
  173. data/src/js/controllers/table_header_controller.js +16 -0
  174. data/src/js/controllers/view_switcher_controller.js +29 -0
  175. data/src/js/turbo/turbo_actions.js +33 -0
  176. data/yarn.lock +553 -543
  177. metadata +71 -32
  178. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  179. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  180. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  181. data/.claude/skills/plutonium-definition/SKILL.md +0 -1138
  182. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  183. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  184. data/.claude/skills/plutonium-installation/SKILL.md +0 -325
  185. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  186. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  187. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  188. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  189. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  190. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  191. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  192. data/.claude/skills/plutonium-views/SKILL.md +0 -592
  193. data/docs/reference/assets/index.md +0 -496
  194. data/docs/reference/controller/index.md +0 -412
  195. data/docs/reference/definition/actions.md +0 -449
  196. data/docs/reference/definition/fields.md +0 -383
  197. data/docs/reference/definition/index.md +0 -268
  198. data/docs/reference/definition/query.md +0 -351
  199. data/docs/reference/generators/index.md +0 -648
  200. data/docs/reference/interaction/index.md +0 -449
  201. data/docs/reference/model/features.md +0 -248
  202. data/docs/reference/model/index.md +0 -218
  203. data/docs/reference/policy/index.md +0 -456
  204. data/docs/reference/portal/index.md +0 -379
  205. data/docs/reference/views/forms.md +0 -411
  206. data/docs/reference/views/index.md +0 -501
@@ -24,6 +24,14 @@ import BulkActionsController from "./bulk_actions_controller.js"
24
24
  import FilterPanelController from "./filter_panel_controller.js"
25
25
  import TextareaAutogrowController from "./textarea_autogrow_controller.js"
26
26
  import ClipboardController from "./clipboard_controller.js"
27
+ import IconRailController from "./icon_rail_controller.js"
28
+ import IconRailFlyoutController from "./icon_rail_flyout_controller.js"
29
+ import TableHeaderController from "./table_header_controller.js"
30
+ import TableColumnMenuController from "./table_column_menu_controller.js"
31
+ import CaptureUrlController from "./capture_url_controller.js"
32
+ import RowClickController from "./row_click_controller.js"
33
+ import ViewSwitcherController from "./view_switcher_controller.js"
34
+ import AutosubmitController from "./autosubmit_controller.js"
27
35
 
28
36
  export default function (application) {
29
37
  // Register controllers here
@@ -52,4 +60,12 @@ export default function (application) {
52
60
  application.register("filter-panel", FilterPanelController)
53
61
  application.register("textarea-autogrow", TextareaAutogrowController)
54
62
  application.register("clipboard", ClipboardController)
63
+ application.register("icon-rail", IconRailController)
64
+ application.register("icon-rail-flyout", IconRailFlyoutController)
65
+ application.register("table-header", TableHeaderController)
66
+ application.register("table-column-menu", TableColumnMenuController)
67
+ application.register("capture-url", CaptureUrlController)
68
+ application.register("row-click", RowClickController)
69
+ application.register("view-switcher", ViewSwitcherController)
70
+ application.register("autosubmit", AutosubmitController)
55
71
  }
@@ -1,6 +1,15 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="resource-tab-list"
4
+ //
5
+ // URL hash sync:
6
+ // - On connect, if location.hash matches a tab identifier, that tab is
7
+ // selected (overrides defaultTabValue). Falls back to defaultTabValue
8
+ // or the first tab.
9
+ // - On user click, the URL hash is updated via history.replaceState
10
+ // (no scroll, no back-button entry).
11
+ // - On hashchange (back/forward, manual hash edit), the matching tab
12
+ // is re-selected.
4
13
  export default class extends Controller {
5
14
  static targets = ["btn", "tab"]
6
15
  static values = {
@@ -13,14 +22,37 @@ export default class extends Controller {
13
22
  this.activeClasses = this.hasActiveClassesValue ? this.activeClassesValue.split(" ") : []
14
23
  this.inActiveClasses = this.hasInActiveClassesValue ? this.inActiveClassesValue.split(" ") : []
15
24
 
16
- this.#selectInternal(this.defaultTabValue || this.btnTargets[0].id)
25
+ const fromHash = this.#buttonIdFromHash()
26
+ const initialId = fromHash || this.defaultTabValue || this.btnTargets[0]?.id
27
+ this.#selectInternal(initialId, { skipFocus: true, skipHashUpdate: true })
28
+
29
+ this._syncFromHash = this._syncFromHash.bind(this)
30
+ // hashchange covers manual hash edits and back/forward.
31
+ window.addEventListener("hashchange", this._syncFromHash)
32
+ // turbo:load covers Turbo navigations (including morph) where the URL
33
+ // changes via pushState — which doesn't fire hashchange. Without this,
34
+ // a second submit landing via morph leaves the previously-active tab
35
+ // selected even though the URL hash points elsewhere.
36
+ document.addEventListener("turbo:load", this._syncFromHash)
37
+ }
38
+
39
+ disconnect() {
40
+ if (this._syncFromHash) {
41
+ window.removeEventListener("hashchange", this._syncFromHash)
42
+ document.removeEventListener("turbo:load", this._syncFromHash)
43
+ }
44
+ }
45
+
46
+ _syncFromHash() {
47
+ const id = this.#buttonIdFromHash()
48
+ if (id) this.#selectInternal(id, { skipFocus: true, skipHashUpdate: true })
17
49
  }
18
50
 
19
51
  select(event) {
20
52
  this.#selectInternal(event.currentTarget.id)
21
53
  }
22
54
 
23
- #selectInternal(id) {
55
+ #selectInternal(id, options = {}) {
24
56
  const selectedBtn = this.btnTargets.find(element => element.id === id)
25
57
  if (!selectedBtn) {
26
58
  console.error(`Tab Button with id "${id}" not found`)
@@ -56,9 +88,30 @@ export default class extends Controller {
56
88
  selectedTab.hidden = false
57
89
  selectedTab.setAttribute('aria-hidden', 'false')
58
90
 
91
+ // Sync URL hash so the tab is shareable / restorable on reload.
92
+ if (!options.skipHashUpdate) this.#updateHash(id)
93
+
59
94
  // Focus management
60
- if (selectedBtn !== document.activeElement) {
95
+ if (!options.skipFocus && selectedBtn !== document.activeElement) {
61
96
  selectedBtn.focus()
62
97
  }
63
98
  }
99
+
100
+ // Button ids follow `${identifier}-tab`. The URL hash carries just
101
+ // the identifier (e.g., #details, #orders).
102
+ #buttonIdFromHash() {
103
+ const hash = window.location.hash.replace(/^#/, "")
104
+ if (!hash) return null
105
+ const candidateId = `${hash}-tab`
106
+ const exists = this.btnTargets.some(btn => btn.id === candidateId)
107
+ return exists ? candidateId : null
108
+ }
109
+
110
+ #updateHash(buttonId) {
111
+ const identifier = buttonId.replace(/-tab$/, "")
112
+ const newHash = `#${identifier}`
113
+ if (window.location.hash !== newHash) {
114
+ history.replaceState(null, "", newHash)
115
+ }
116
+ }
64
117
  }
@@ -0,0 +1,21 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="row-click"
4
+ //
5
+ // Makes a table row or grid card behave like the resource's Show
6
+ // affordance: clicking anywhere except a real interactive element
7
+ // triggers the row's existing Show button. Single source of truth for
8
+ // the URL — turbo-frame targets, modal opening, and any other
9
+ // configuration on the Show action are inherited automatically.
10
+ //
11
+ // Mark the show element with `data-row-click-target="show"`.
12
+ // Opt out of triggering for a specific element with
13
+ // `data-row-click-ignore`.
14
+ export default class extends Controller {
15
+ click(event) {
16
+ if (event.target.closest("a, button, input, label, select, textarea, [data-row-click-ignore]")) {
17
+ return
18
+ }
19
+ this.element.querySelector('[data-row-click-target="show"]')?.click()
20
+ }
21
+ }
@@ -1,7 +1,19 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="slim-select"
4
+ //
5
+ // Optional values (used by the typeahead-capable ResourceSelect):
6
+ // typeahead-url — backend endpoint that returns
7
+ // {results: [{value, label}, ...], has_more: bool}.
8
+ // When present, SlimSelect's built-in client-side
9
+ // filter is replaced by a debounced fetch through
10
+ // this URL.
4
11
  export default class extends Controller {
12
+ static values = {
13
+ typeaheadUrl: String,
14
+ typeaheadDebounceMs: { type: Number, default: 200 }
15
+ }
16
+
5
17
  connect() {
6
18
  if (this.slimSelect) return;
7
19
 
@@ -47,9 +59,18 @@ export default class extends Controller {
47
59
  settings.openPosition = "auto";
48
60
  }
49
61
 
62
+ const events = {};
63
+
64
+ if (this.hasTypeaheadUrlValue && this.typeaheadUrlValue) {
65
+ // Replace SlimSelect's client-side filter with a server fetch.
66
+ // Returns the SlimSelect data array shape: {value, text}.
67
+ events.search = (search, currentData) => this.#typeaheadFetch(search, currentData);
68
+ }
69
+
50
70
  this.slimSelect = new SlimSelect({
51
71
  select: this.element,
52
72
  settings: settings,
73
+ events: events,
53
74
  });
54
75
 
55
76
  // Add event listeners for better positioning
@@ -177,6 +198,46 @@ export default class extends Controller {
177
198
  this.#cleanupSlimSelect();
178
199
  }
179
200
 
201
+ // Server-driven search. SlimSelect calls events.search on each
202
+ // keystroke; we debounce so that rapid typing produces a single
203
+ // request, and abort any in-flight fetch when a newer one starts.
204
+ // Returns a Promise resolving to either a DataArray (rendered as
205
+ // options) or a string (rendered as the no-results label).
206
+ #typeaheadFetch(search, _currentData) {
207
+ if (this._typeaheadDebounce) clearTimeout(this._typeaheadDebounce);
208
+ if (this._typeaheadAbort) this._typeaheadAbort.abort();
209
+
210
+ return new Promise((resolve) => {
211
+ this._typeaheadDebounce = setTimeout(() => {
212
+ this._typeaheadAbort = new AbortController();
213
+ this.#performTypeaheadFetch(search, this._typeaheadAbort.signal).then(resolve);
214
+ }, this.typeaheadDebounceMsValue);
215
+ });
216
+ }
217
+
218
+ async #performTypeaheadFetch(search, signal) {
219
+ const url = new URL(this.typeaheadUrlValue, window.location.origin);
220
+ url.searchParams.set("q", search || "");
221
+
222
+ try {
223
+ const res = await fetch(url.toString(), {
224
+ headers: { Accept: "application/json" },
225
+ signal: signal,
226
+ });
227
+ if (!res.ok) return "Search failed";
228
+ const json = await res.json();
229
+ const results = Array.isArray(json.results) ? json.results : [];
230
+ return results.map((row) => ({
231
+ value: String(row.value ?? ""),
232
+ text: String(row.label ?? ""),
233
+ }));
234
+ } catch (e) {
235
+ if (e.name === "AbortError") return [];
236
+ console.warn("[slim-select] typeahead error", e);
237
+ return "Search failed";
238
+ }
239
+ }
240
+
180
241
  #handleMorph() {
181
242
  if (!this.element.isConnected) return;
182
243
 
@@ -0,0 +1,43 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="table-column-menu"
4
+ // Toggles the column ⋯ menu panel; closes on outside click and Escape.
5
+ export default class extends Controller {
6
+ static targets = ["panel"]
7
+
8
+ connect() {
9
+ this._onDocClick = this._onDocClick.bind(this)
10
+ }
11
+
12
+ toggle(event) {
13
+ event.preventDefault()
14
+ event.stopPropagation()
15
+ if (this.hasPanelTarget) {
16
+ const isNowVisible = !this.panelTarget.classList.toggle("hidden")
17
+ if (isNowVisible) {
18
+ document.addEventListener("click", this._onDocClick)
19
+ this._onKey = (e) => { if (e.key === "Escape") this._close() }
20
+ document.addEventListener("keydown", this._onKey)
21
+ } else {
22
+ this._unbind()
23
+ }
24
+ }
25
+ }
26
+
27
+ _close() {
28
+ if (this.hasPanelTarget) this.panelTarget.classList.add("hidden")
29
+ this._unbind()
30
+ }
31
+
32
+ _unbind() {
33
+ document.removeEventListener("click", this._onDocClick)
34
+ if (this._onKey) {
35
+ document.removeEventListener("keydown", this._onKey)
36
+ this._onKey = null
37
+ }
38
+ }
39
+
40
+ _onDocClick(event) {
41
+ if (!this.element.contains(event.target)) this._close()
42
+ }
43
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="table-header"
4
+ // Routes column-header clicks: shift-click navigates to multi-href instead of href,
5
+ // adding the column to the existing sort stack (multi-sort).
6
+ // Plain click lets the default link navigation happen, which replaces all sorts.
7
+ export default class extends Controller {
8
+ headerClick(event) {
9
+ if (!event.shiftKey) return // plain click: let the link navigate normally
10
+ const link = event.currentTarget
11
+ const multiHref = link.dataset.tableHeaderMultiHref
12
+ if (!multiHref) return
13
+ event.preventDefault()
14
+ Turbo.visit(multiHref)
15
+ }
16
+ }
@@ -0,0 +1,29 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="view-switcher"
4
+ // Persists the user's chosen index view in a cookie so the server can
5
+ // render the right shape on next request. Reload after writing so the
6
+ // page comes back with everything (toolbar, filters, table/grid)
7
+ // matching the new view.
8
+ export default class extends Controller {
9
+ static values = { cookieName: String, cookiePath: { type: String, default: "/" } }
10
+
11
+ select(event) {
12
+ const view = event.params.view
13
+ if (!view || !this.cookieNameValue) return
14
+
15
+ // 1 year, scoped to the portal's mount path so different portals
16
+ // can hold different view preferences for the same resource.
17
+ // SameSite=Lax keeps it on top-level navigations but blocks
18
+ // cross-site requests from carrying it along.
19
+ const maxAge = 60 * 60 * 24 * 365
20
+ const path = this.cookiePathValue || "/"
21
+ document.cookie = `${this.cookieNameValue}=${encodeURIComponent(view)}; Path=${path}; Max-Age=${maxAge}; SameSite=Lax`
22
+
23
+ // Strip any legacy `?view=` param so the cookie is the source of
24
+ // truth from now on.
25
+ const url = new URL(window.location.href)
26
+ url.searchParams.delete("view")
27
+ window.location.href = url.toString()
28
+ }
29
+ }
@@ -6,3 +6,36 @@ Turbo.StreamActions.redirect = function () {
6
6
  const url = this.getAttribute("url")
7
7
  Turbo.visit(url)
8
8
  }
9
+
10
+ // Closes the <dialog> rendered inside the targeted turbo-frame and
11
+ // empties the frame so the dialog can be re-opened later. Used by the
12
+ // stacked-modal flow: after a successful create inside the secondary
13
+ // modal, the server tells the browser to dismiss it.
14
+ Turbo.StreamActions.close_frame = function () {
15
+ const frameId = this.getAttribute("target")
16
+ if (!frameId) return
17
+
18
+ const frame = document.getElementById(frameId)
19
+ if (!frame) return
20
+
21
+ const dialog = frame.querySelector("dialog")
22
+ if (dialog && typeof dialog.close === "function") dialog.close()
23
+
24
+ // Clearing the frame's content keeps a future visit to the same URL
25
+ // re-fetching (turbo would otherwise treat the frame as cached).
26
+ frame.innerHTML = ""
27
+ frame.removeAttribute("src")
28
+ }
29
+
30
+ // Reloads the targeted turbo-frame from its current src. Used after a
31
+ // secondary-modal action mutates data the primary modal depends on
32
+ // (e.g. a newly created association option) so the primary re-renders.
33
+ Turbo.StreamActions.reload_frame = function () {
34
+ const frameId = this.getAttribute("target")
35
+ if (!frameId) return
36
+
37
+ const frame = document.getElementById(frameId)
38
+ if (!frame || typeof frame.reload !== "function") return
39
+
40
+ frame.reload()
41
+ }