plutonium 0.49.1 → 0.50.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-definition/SKILL.md +87 -2
  3. data/.claude/skills/plutonium-installation/SKILL.md +6 -0
  4. data/.claude/skills/plutonium-views/SKILL.md +59 -0
  5. data/CHANGELOG.md +12 -0
  6. data/app/assets/plutonium.css +2 -2
  7. data/app/assets/plutonium.js +369 -25
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +45 -45
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_resource_header.html.erb +4 -4
  12. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  13. data/app/views/resource/_resource_grid.html.erb +1 -0
  14. data/config/brakeman.ignore +25 -2
  15. data/docs/reference/definition/actions.md +14 -1
  16. data/docs/reference/definition/index.md +58 -0
  17. data/docs/reference/views/index.md +43 -0
  18. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  19. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  20. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  23. data/lib/generators/pu/core/update/update_generator.rb +20 -0
  24. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  25. data/lib/plutonium/action/base.rb +44 -1
  26. data/lib/plutonium/action/interactive.rb +1 -1
  27. data/lib/plutonium/configuration.rb +4 -0
  28. data/lib/plutonium/definition/actions.rb +3 -0
  29. data/lib/plutonium/definition/base.rb +8 -0
  30. data/lib/plutonium/definition/metadata.rb +40 -0
  31. data/lib/plutonium/definition/views.rb +94 -0
  32. data/lib/plutonium/helpers/turbo_helper.rb +1 -1
  33. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  34. data/lib/plutonium/query/base.rb +8 -0
  35. data/lib/plutonium/query/filters/association.rb +30 -8
  36. data/lib/plutonium/query/filters/boolean.rb +5 -0
  37. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  38. data/lib/plutonium/resource/definition.rb +42 -0
  39. data/lib/plutonium/resource/query_object.rb +64 -6
  40. data/lib/plutonium/testing/resource_definition.rb +2 -2
  41. data/lib/plutonium/ui/action_button.rb +4 -2
  42. data/lib/plutonium/ui/component/kit.rb +12 -0
  43. data/lib/plutonium/ui/display/base.rb +3 -1
  44. data/lib/plutonium/ui/display/resource.rb +109 -25
  45. data/lib/plutonium/ui/display/theme.rb +2 -1
  46. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  47. data/lib/plutonium/ui/empty_card.rb +1 -1
  48. data/lib/plutonium/ui/form/base.rb +29 -1
  49. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  50. data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
  51. data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
  52. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  53. data/lib/plutonium/ui/form/resource.rb +48 -9
  54. data/lib/plutonium/ui/form/theme.rb +1 -1
  55. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  56. data/lib/plutonium/ui/grid/card.rb +235 -0
  57. data/lib/plutonium/ui/grid/resource.rb +149 -0
  58. data/lib/plutonium/ui/layout/base.rb +37 -1
  59. data/lib/plutonium/ui/layout/header.rb +1 -2
  60. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  61. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  62. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  63. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  64. data/lib/plutonium/ui/modal/base.rb +109 -0
  65. data/lib/plutonium/ui/modal/centered.rb +21 -0
  66. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  67. data/lib/plutonium/ui/page/base.rb +25 -6
  68. data/lib/plutonium/ui/page/edit.rb +13 -1
  69. data/lib/plutonium/ui/page/index.rb +40 -1
  70. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  71. data/lib/plutonium/ui/page/new.rb +13 -1
  72. data/lib/plutonium/ui/page/show.rb +8 -1
  73. data/lib/plutonium/ui/page_header.rb +8 -13
  74. data/lib/plutonium/ui/panel.rb +10 -19
  75. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  76. data/lib/plutonium/ui/tab_list.rb +29 -7
  77. data/lib/plutonium/ui/table/base.rb +106 -0
  78. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  79. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  80. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  81. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  82. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  83. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  84. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  85. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  86. data/lib/plutonium/ui/table/resource.rb +158 -89
  87. data/lib/plutonium/ui/table/theme.rb +14 -5
  88. data/lib/plutonium/version.rb +1 -1
  89. data/lib/plutonium.rb +6 -0
  90. data/package.json +1 -1
  91. data/src/css/components.css +304 -131
  92. data/src/css/tokens.css +101 -85
  93. data/src/js/controllers/autosubmit_controller.js +24 -0
  94. data/src/js/controllers/bulk_actions_controller.js +15 -16
  95. data/src/js/controllers/capture_url_controller.js +14 -0
  96. data/src/js/controllers/filter_panel_controller.js +77 -19
  97. data/src/js/controllers/frame_navigator_controller.js +34 -6
  98. data/src/js/controllers/icon_rail_controller.js +22 -0
  99. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  100. data/src/js/controllers/register_controllers.js +16 -0
  101. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  102. data/src/js/controllers/row_click_controller.js +21 -0
  103. data/src/js/controllers/table_column_menu_controller.js +43 -0
  104. data/src/js/controllers/table_header_controller.js +16 -0
  105. data/src/js/controllers/view_switcher_controller.js +29 -0
  106. metadata +31 -3
data/src/css/tokens.css CHANGED
@@ -5,147 +5,163 @@
5
5
  */
6
6
 
7
7
  :root {
8
- /* ===================
8
+ /* ===================
9
9
  Body/Page Background
10
10
  =================== */
11
- --pu-body: #f8fafc;
11
+ --pu-body: #f8fafc;
12
12
 
13
- /* ===================
13
+ /* ===================
14
14
  Surface Colors - Softer, more refined
15
15
  =================== */
16
- --pu-surface: #ffffff;
17
- --pu-surface-alt: #f1f5f9;
18
- --pu-surface-raised: #ffffff;
19
- --pu-surface-overlay: rgba(255, 255, 255, 0.95);
16
+ --pu-surface: #ffffff;
17
+ --pu-surface-alt: #f1f5f9;
18
+ --pu-surface-raised: #ffffff;
19
+ --pu-surface-overlay: rgba(255, 255, 255, 0.95);
20
20
 
21
- /* ===================
21
+ /* ===================
22
22
  Border Colors - Subtle
23
23
  =================== */
24
- --pu-border: #e2e8f0;
25
- --pu-border-muted: #f1f5f9;
26
- --pu-border-strong: #cbd5e1;
24
+ --pu-border: #e2e8f0;
25
+ --pu-border-muted: #f1f5f9;
26
+ --pu-border-strong: #cbd5e1;
27
27
 
28
- /* ===================
28
+ /* ===================
29
29
  Text Colors - Better hierarchy
30
30
  =================== */
31
- --pu-text: #0f172a;
32
- --pu-text-muted: #64748b;
33
- --pu-text-subtle: #94a3b8;
34
- --pu-text-danger: #dc2626;
31
+ --pu-text: #0f172a;
32
+ --pu-text-muted: #64748b;
33
+ --pu-text-subtle: #94a3b8;
34
+ --pu-text-danger: #dc2626;
35
35
 
36
- /* ===================
36
+ /* ===================
37
37
  Table Tokens - Clean minimal design
38
38
  =================== */
39
- --pu-table-header-bg: #f8fafc;
40
- --pu-table-header-text: #475569;
41
- --pu-table-row-bg: #ffffff;
42
- --pu-table-row-hover: #f8fafc;
43
- --pu-table-row-selected: theme(colors.primary.50);
44
- --pu-table-border: #f1f5f9;
39
+ --pu-table-header-bg: #f8fafc;
40
+ --pu-table-header-text: #475569;
41
+ --pu-table-row-bg: #ffffff;
42
+ --pu-table-row-hover: #f8fafc;
43
+ --pu-table-row-selected: theme(colors.primary.50);
44
+ --pu-table-border: #f1f5f9;
45
45
 
46
- /* ===================
46
+ /* ===================
47
47
  Form Tokens
48
48
  =================== */
49
- --pu-input-bg: #ffffff;
50
- --pu-input-border: #e2e8f0;
51
- --pu-input-focus-ring: theme(colors.primary.500);
52
- --pu-input-placeholder: #94a3b8;
49
+ --pu-input-bg: #ffffff;
50
+ --pu-input-border: #e2e8f0;
51
+ --pu-input-focus-ring: theme(colors.primary.500);
52
+ --pu-input-placeholder: #94a3b8;
53
53
 
54
- /* ===================
54
+ /* ===================
55
55
  Card Tokens - Refined shadows
56
56
  =================== */
57
- --pu-card-bg: #ffffff;
58
- --pu-card-border: #e2e8f0;
57
+ --pu-card-bg: #ffffff;
58
+ --pu-card-border: #e2e8f0;
59
59
 
60
- /* ===================
60
+ /* ===================
61
61
  Shadow System - Layered, soft
62
62
  =================== */
63
- --pu-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03), 0 1px 3px 0 rgb(0 0 0 / 0.05);
64
- --pu-shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.04), 0 4px 6px -1px rgb(0 0 0 / 0.06);
65
- --pu-shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.03), 0 10px 15px -3px rgb(0 0 0 / 0.08);
63
+ --pu-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.03), 0 1px 3px 0 rgb(0 0 0 / 0.05);
64
+ --pu-shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.04), 0 4px 6px -1px rgb(0 0 0 / 0.06);
65
+ --pu-shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.03), 0 10px 15px -3px rgb(0 0 0 / 0.08);
66
66
 
67
- /* ===================
67
+ /* ===================
68
68
  Spacing Scale
69
69
  =================== */
70
- --pu-space-xs: 0.25rem;
71
- --pu-space-sm: 0.5rem;
72
- --pu-space-md: 1rem;
73
- --pu-space-lg: 1.5rem;
74
- --pu-space-xl: 2rem;
70
+ --pu-space-xs: 0.25rem;
71
+ --pu-space-sm: 0.5rem;
72
+ --pu-space-md: 1rem;
73
+ --pu-space-lg: 1.5rem;
74
+ --pu-space-xl: 2rem;
75
75
 
76
- /* ===================
76
+ /* ===================
77
77
  Border Radius - Refined
78
78
  =================== */
79
- --pu-radius-sm: 0.375rem;
80
- --pu-radius-md: 0.5rem;
81
- --pu-radius-lg: 0.75rem;
82
- --pu-radius-xl: 1rem;
83
- --pu-radius-full: 9999px;
79
+ --pu-radius-sm: 0.375rem;
80
+ --pu-radius-md: 0.5rem;
81
+ --pu-radius-lg: 0.75rem;
82
+ --pu-radius-xl: 1rem;
83
+ --pu-radius-full: 9999px;
84
84
 
85
- /* ===================
85
+ /* ===================
86
86
  Transitions - Smooth
87
87
  =================== */
88
- --pu-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
89
- --pu-transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
90
- --pu-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
88
+ --pu-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
89
+ --pu-transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
90
+ --pu-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
91
+
92
+ /* ===================
93
+ Density Scale
94
+ =================== */
95
+ --pu-row-height: 32px;
96
+ --pu-section-gap: 16px;
97
+ --pu-field-gap: 12px;
98
+ --pu-page-padding: 24px;
91
99
  }
92
100
 
93
101
  .dark {
94
- /* ===================
102
+ /* ===================
95
103
  Body/Page Background (Dark)
96
104
  =================== */
97
- --pu-body: #0f172a;
105
+ --pu-body: #0f172a;
98
106
 
99
- /* ===================
107
+ /* ===================
100
108
  Surface Colors (Dark) - Rich, not too dark
101
109
  =================== */
102
- --pu-surface: #1e293b;
103
- --pu-surface-alt: #0f172a;
104
- --pu-surface-raised: #334155;
105
- --pu-surface-overlay: rgba(30, 41, 59, 0.95);
110
+ --pu-surface: #1e293b;
111
+ --pu-surface-alt: #0f172a;
112
+ --pu-surface-raised: #334155;
113
+ --pu-surface-overlay: rgba(30, 41, 59, 0.95);
106
114
 
107
- /* ===================
115
+ /* ===================
108
116
  Border Colors (Dark)
109
117
  =================== */
110
- --pu-border: #334155;
111
- --pu-border-muted: #1e293b;
112
- --pu-border-strong: #475569;
118
+ --pu-border: #334155;
119
+ --pu-border-muted: #1e293b;
120
+ --pu-border-strong: #475569;
113
121
 
114
- /* ===================
122
+ /* ===================
115
123
  Text Colors (Dark)
116
124
  =================== */
117
- --pu-text: #f8fafc;
118
- --pu-text-muted: #94a3b8;
119
- --pu-text-subtle: #64748b;
120
- --pu-text-danger: #f87171;
125
+ --pu-text: #f8fafc;
126
+ --pu-text-muted: #94a3b8;
127
+ --pu-text-subtle: #64748b;
128
+ --pu-text-danger: #f87171;
121
129
 
122
- /* ===================
130
+ /* ===================
123
131
  Table Tokens (Dark)
124
132
  =================== */
125
- --pu-table-header-bg: #0f172a;
126
- --pu-table-header-text: #cbd5e1;
127
- --pu-table-row-bg: #1e293b;
128
- --pu-table-row-hover: #334155;
129
- --pu-table-row-selected: theme(colors.primary.900 / 40%);
130
- --pu-table-border: #334155;
133
+ --pu-table-header-bg: #0f172a;
134
+ --pu-table-header-text: #cbd5e1;
135
+ --pu-table-row-bg: #1e293b;
136
+ --pu-table-row-hover: #334155;
137
+ --pu-table-row-selected: theme(colors.primary.900 / 40%);
138
+ --pu-table-border: #334155;
131
139
 
132
- /* ===================
140
+ /* ===================
133
141
  Form Tokens (Dark)
134
142
  =================== */
135
- --pu-input-bg: #1e293b;
136
- --pu-input-border: #475569;
137
- --pu-input-placeholder: #64748b;
143
+ --pu-input-bg: #1e293b;
144
+ --pu-input-border: #475569;
145
+ --pu-input-placeholder: #64748b;
138
146
 
139
- /* ===================
147
+ /* ===================
140
148
  Card Tokens (Dark)
141
149
  =================== */
142
- --pu-card-bg: #1e293b;
143
- --pu-card-border: #334155;
150
+ --pu-card-bg: #1e293b;
151
+ --pu-card-border: #334155;
144
152
 
145
- /* ===================
153
+ /* ===================
146
154
  Shadow System (Dark)
147
155
  =================== */
148
- --pu-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.2), 0 1px 3px 0 rgb(0 0 0 / 0.3);
149
- --pu-shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.25), 0 4px 6px -1px rgb(0 0 0 / 0.35);
150
- --pu-shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.2), 0 10px 15px -3px rgb(0 0 0 / 0.4);
156
+ --pu-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.2), 0 1px 3px 0 rgb(0 0 0 / 0.3);
157
+ --pu-shadow-md: 0 2px 4px -1px rgb(0 0 0 / 0.25), 0 4px 6px -1px rgb(0 0 0 / 0.35);
158
+ --pu-shadow-lg: 0 4px 6px -2px rgb(0 0 0 / 0.2), 0 10px 15px -3px rgb(0 0 0 / 0.4);
159
+
160
+ /* ===================
161
+ Density Scale (Dark)
162
+ =================== */
163
+ --pu-row-height: 32px;
164
+ --pu-section-gap: 16px;
165
+ --pu-field-gap: 12px;
166
+ --pu-page-padding: 24px;
151
167
  }
@@ -0,0 +1,24 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="autosubmit"
4
+ // Submits the closest <form> after the user stops typing for `delay` ms
5
+ // (default 300). Use on inputs where the user expects "as you type"
6
+ // behavior (e.g. the toolbar search input).
7
+ export default class extends Controller {
8
+ static values = { delay: { type: Number, default: 300 } }
9
+
10
+ connect() {
11
+ this._timer = null
12
+ }
13
+
14
+ disconnect() {
15
+ if (this._timer) clearTimeout(this._timer)
16
+ }
17
+
18
+ submit() {
19
+ if (this._timer) clearTimeout(this._timer)
20
+ this._timer = setTimeout(() => {
21
+ this.element.closest("form")?.requestSubmit()
22
+ }, this.delayValue)
23
+ }
24
+ }
@@ -3,22 +3,7 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="bulk-actions"
4
4
  // Manages bulk action selection in resource tables
5
5
  export default class extends Controller {
6
- static targets = ["checkbox", "checkboxAll", "toolbar", "selectedCount", "actionButton", "selectionCell"]
7
- static values = {
8
- hasActions: { type: Boolean, default: false }
9
- }
10
-
11
- connect() {
12
- // Show selection column only if bulk actions exist
13
- if (this.hasActionsValue) {
14
- this.enableSelection()
15
- }
16
- }
17
-
18
- enableSelection() {
19
- // Show all selection cells (header + body cells)
20
- this.selectionCellTargets.forEach(el => el.classList.remove("hidden"))
21
- }
6
+ static targets = ["checkbox", "checkboxAll", "toolbar", "selectedCount", "actionButton", "filterPills"]
22
7
 
23
8
  toggle() {
24
9
  this.updateUI()
@@ -45,6 +30,11 @@ export default class extends Controller {
45
30
  this.toolbarTarget.classList.toggle("hidden", checked.length === 0)
46
31
  }
47
32
 
33
+ // FilterPills strip is mutually exclusive with the toolbar
34
+ if (this.hasFilterPillsTarget) {
35
+ this.filterPillsTarget.classList.toggle("hidden", checked.length > 0)
36
+ }
37
+
48
38
  // Update selected count display
49
39
  if (this.hasSelectedCountTarget) {
50
40
  this.selectedCountTarget.textContent = checked.length
@@ -99,6 +89,15 @@ export default class extends Controller {
99
89
  return allowedActions ? allowedActions.split(",").filter(a => a) : []
100
90
  }
101
91
 
92
+ clearSelection() {
93
+ this.checkboxTargets.forEach(cb => cb.checked = false)
94
+ if (this.hasCheckboxAllTarget) {
95
+ this.checkboxAllTarget.checked = false
96
+ this.checkboxAllTarget.indeterminate = false
97
+ }
98
+ this.updateUI()
99
+ }
100
+
102
101
  get checked() {
103
102
  return this.checkboxTargets.filter(cb => cb.checked)
104
103
  }
@@ -0,0 +1,14 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="capture-url"
4
+ // Sets the controller's own element's `value` to window.location.href
5
+ // on connect — capturing URL fragments (#tab-id) that the server never
6
+ // sees over HTTP. Apply directly to any input/button whose value should
7
+ // reflect the full client-side URL.
8
+ export default class extends Controller {
9
+ connect() {
10
+ if ("value" in this.element) {
11
+ this.element.value = window.location.href
12
+ }
13
+ }
14
+ }
@@ -1,35 +1,93 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="filter-panel"
4
+ //
5
+ // Hosts the toolbar's filter slideover. The trigger lives in the toolbar
6
+ // strip; the panel + backdrop are rendered as siblings inside the same
7
+ // controller scope. `open` is mirrored on both targets via the `data-open`
8
+ // attribute, which CSS uses to drive the slide / fade transitions.
4
9
  export default class extends Controller {
10
+ static targets = ["panel", "backdrop"]
11
+
12
+ connect() {
13
+ this._onKeydown = this._onKeydown.bind(this)
14
+ }
15
+
16
+ disconnect() {
17
+ if (this.isOpen) {
18
+ document.removeEventListener("keydown", this._onKeydown)
19
+ this._unlockBodyScroll()
20
+ }
21
+ }
22
+
23
+ toggle() {
24
+ this.isOpen ? this.close() : this.open()
25
+ }
26
+
27
+ open() {
28
+ if (this.hasPanelTarget) {
29
+ this.panelTarget.setAttribute("data-open", "")
30
+ this.panelTarget.setAttribute("aria-hidden", "false")
31
+ }
32
+ if (this.hasBackdropTarget) this.backdropTarget.setAttribute("data-open", "")
33
+ this._lockBodyScroll()
34
+ document.addEventListener("keydown", this._onKeydown)
35
+ }
36
+
37
+ close() {
38
+ if (this.hasPanelTarget) {
39
+ this.panelTarget.removeAttribute("data-open")
40
+ this.panelTarget.setAttribute("aria-hidden", "true")
41
+ }
42
+ if (this.hasBackdropTarget) this.backdropTarget.removeAttribute("data-open")
43
+ this._unlockBodyScroll()
44
+ document.removeEventListener("keydown", this._onKeydown)
45
+ }
46
+
47
+ // Mirrors remote-modal's approach: stash the body's current overflow
48
+ // and restore it on close. Avoids stomping a value another component
49
+ // (e.g. an open dialog) may have set.
50
+ _lockBodyScroll() {
51
+ if (this._previousBodyOverflow != null) return
52
+ this._previousBodyOverflow = document.body.style.overflow
53
+ document.body.style.overflow = "hidden"
54
+ }
55
+
56
+ _unlockBodyScroll() {
57
+ if (this._previousBodyOverflow == null) return
58
+ document.body.style.overflow = this._previousBodyOverflow
59
+ this._previousBodyOverflow = null
60
+ }
61
+
62
+ // Reset every input under this controller's scope, then submit so the
63
+ // table reflects the cleared filters immediately.
5
64
  clear() {
6
- this.element.querySelectorAll('input, select, textarea').forEach(input => {
7
- if (input.type === 'checkbox' || input.type === 'radio') {
65
+ this.element.querySelectorAll("input, select, textarea").forEach(input => {
66
+ if (input.type === "checkbox" || input.type === "radio") {
8
67
  input.checked = false
9
- } else if (input.tagName === 'SELECT') {
68
+ } else if (input.tagName === "SELECT") {
10
69
  input.selectedIndex = 0
11
- } else if (input.type === 'hidden') {
12
- // Clear hidden inputs that are filter values (e.g., flatpickr)
13
- if (input.dataset.controller === 'flatpickr') {
14
- input.value = ''
15
- }
70
+ } else if (input.type === "hidden") {
71
+ if (input.dataset.controller === "flatpickr") input.value = ""
16
72
  } else {
17
- input.value = ''
73
+ input.value = ""
18
74
  }
19
75
  })
20
76
 
21
- // Clear flatpickr instances via Stimulus controller
22
77
  this.element.querySelectorAll('[data-controller="flatpickr"]').forEach(input => {
23
- const controller = this.application.getControllerForElementAndIdentifier(input, 'flatpickr')
24
- if (controller?.picker) {
25
- controller.picker.clear()
26
- }
78
+ const controller = this.application.getControllerForElementAndIdentifier(input, "flatpickr")
79
+ if (controller?.picker) controller.picker.clear()
27
80
  })
28
81
 
29
- // Submit the parent form
30
- const form = this.element.closest('form')
31
- if (form) {
32
- form.requestSubmit()
33
- }
82
+ const form = this.element.querySelector("form")
83
+ if (form) form.requestSubmit()
84
+ }
85
+
86
+ get isOpen() {
87
+ return this.hasPanelTarget && this.panelTarget.hasAttribute("data-open")
88
+ }
89
+
90
+ _onKeydown(event) {
91
+ if (event.key === "Escape") this.close()
34
92
  }
35
93
  }
@@ -49,6 +49,16 @@ export default class extends Controller {
49
49
  }
50
50
 
51
51
  frameLoading(event) {
52
+ // turbo:click / turbo:submit-start bubble from links and forms inside
53
+ // the frame, even when those links target a different frame
54
+ // (e.g. data-turbo-frame="remote_modal"). Without this filter, the
55
+ // pulse animation is triggered for navigations that never resolve
56
+ // against this frame, leaving it stuck in a loading state.
57
+ if (event) {
58
+ const trigger = event.target.closest("a, form")
59
+ const requested = trigger?.dataset?.turboFrame
60
+ if (requested && requested !== this.frameTarget.id) return
61
+ }
52
62
  this.#loadingStarted()
53
63
  }
54
64
 
@@ -79,20 +89,38 @@ export default class extends Controller {
79
89
  homeButtonClicked(event) {
80
90
  this.frameLoading(null)
81
91
 
92
+ // Clear history immediately so Back/Home vanish during the load.
93
+ this.srcHistory = [this.originalFrameSrc]
94
+ this.#updateNavigationButtonsDisplay()
95
+
96
+ // Mark the next frame load as "home" so notifySrcChanged doesn't
97
+ // push the loaded URL onto a fresh stack (the loaded URL may differ
98
+ // from originalFrameSrc due to redirects / trailing slashes).
99
+ this._homeRequested = true
100
+
101
+ // Force a reload even if frame.src already matches the original
102
+ // (a same-value assignment wouldn't fire turbo:frame-load).
82
103
  this.frameTarget.src = this.originalFrameSrc
104
+ this.frameTarget.reload()
83
105
  }
84
106
 
85
107
  get currentSrc() { return this.srcHistory[this.srcHistory.length - 1] }
86
108
 
87
109
  #notifySrcChanged(src) {
88
- if (src == this.currentSrc) {
89
- // this must be a refresh
90
- // do nothing
91
- }
92
- else if (src == this.originalFrameSrc)
110
+ if (this._homeRequested) {
111
+ // Home click: capture the actually-loaded URL as the new singleton
112
+ // history root (handles redirect/trailing-slash differences from
113
+ // originalFrameSrc).
114
+ this._homeRequested = false
93
115
  this.srcHistory = [src]
94
- else
116
+ this.originalFrameSrc = src
117
+ } else if (src == this.currentSrc) {
118
+ // refresh — do nothing
119
+ } else if (src == this.originalFrameSrc) {
120
+ this.srcHistory = [src]
121
+ } else {
95
122
  this.srcHistory.push(src)
123
+ }
96
124
 
97
125
  this.#updateNavigationButtonsDisplay()
98
126
  if (this.hasMaximizeLinkTarget) this.maximizeLinkTarget.href = src
@@ -0,0 +1,22 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="icon-rail"
4
+ // Manages the collapsed ↔ pinned-expanded state of the IconRail sidebar.
5
+ // Pinned state is persisted in localStorage so it survives page reloads.
6
+ export default class extends Controller {
7
+ static values = {
8
+ storageKey: { type: String, default: "pu_rail_pinned" }
9
+ }
10
+
11
+ connect() {
12
+ const pinned = localStorage.getItem(this.storageKeyValue) === "true"
13
+ if (pinned) {
14
+ document.body.classList.add("pu-rail-pinned")
15
+ }
16
+ }
17
+
18
+ togglePin() {
19
+ const pinned = document.body.classList.toggle("pu-rail-pinned")
20
+ localStorage.setItem(this.storageKeyValue, pinned)
21
+ }
22
+ }
@@ -0,0 +1,128 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="icon-rail-flyout"
4
+ // Manages a flyout panel anchored to a trigger element.
5
+ // - Hover or focus the wrapper → open
6
+ // - Mouse leave (with delay) or blur → close
7
+ // - Esc → close immediately
8
+ // - Click trigger → toggle (touch-friendly)
9
+ //
10
+ // On open, the panel is portaled to <body> so it escapes any ancestor
11
+ // transform / overflow:hidden (the rail aside has both). On close,
12
+ // the panel returns to its original parent.
13
+ //
14
+ // IMPORTANT: once portaled, the panel is OUTSIDE the controller's
15
+ // element scope, so this.panelTarget stops resolving. We capture the
16
+ // node into _panel before moving it.
17
+ export default class extends Controller {
18
+ static targets = ["trigger", "panel"]
19
+ static values = {
20
+ closeDelay: { type: Number, default: 150 }
21
+ }
22
+
23
+ connect() {
24
+ this._closeTimer = null
25
+ this._open = false
26
+ this._panel = null
27
+ this._panelHome = null
28
+ this._onPanelEnter = () => {
29
+ if (this._closeTimer) {
30
+ clearTimeout(this._closeTimer)
31
+ this._closeTimer = null
32
+ }
33
+ }
34
+ this._onPanelLeave = () => this.scheduleClose()
35
+ }
36
+
37
+ disconnect() {
38
+ this._returnPanel()
39
+ }
40
+
41
+ open() {
42
+ if (this._closeTimer) {
43
+ clearTimeout(this._closeTimer)
44
+ this._closeTimer = null
45
+ }
46
+ if (this._open) return
47
+ if (!this._panel && !this.hasPanelTarget) return
48
+ this._open = true
49
+ this.element.dataset.flyoutOpen = "true"
50
+ this._portalPanel()
51
+ this._position()
52
+ }
53
+
54
+ scheduleClose() {
55
+ if (this._closeTimer) clearTimeout(this._closeTimer)
56
+ this._closeTimer = setTimeout(() => this.close(), this.closeDelayValue)
57
+ }
58
+
59
+ close() {
60
+ if (!this._open) return
61
+ this._open = false
62
+ delete this.element.dataset.flyoutOpen
63
+ this._returnPanel()
64
+ }
65
+
66
+ toggle(event) {
67
+ event.preventDefault()
68
+ this._open ? this.close() : this.open()
69
+ }
70
+
71
+ closeOnEsc(event) {
72
+ if (event.key === "Escape") this.close()
73
+ }
74
+
75
+ _portalPanel() {
76
+ if (this._panel) return
77
+ // Capture the panel BEFORE moving it — once it leaves the
78
+ // controller element, this.panelTarget no longer resolves.
79
+ const panel = this.panelTarget
80
+ if (!panel) return
81
+ this._panel = panel
82
+ this._panelHome = panel.parentElement
83
+ panel.addEventListener("mouseenter", this._onPanelEnter)
84
+ panel.addEventListener("mouseleave", this._onPanelLeave)
85
+ document.body.appendChild(panel)
86
+ panel.style.display = "block"
87
+ }
88
+
89
+ _returnPanel() {
90
+ if (!this._panel) return
91
+ const panel = this._panel
92
+ panel.removeEventListener("mouseenter", this._onPanelEnter)
93
+ panel.removeEventListener("mouseleave", this._onPanelLeave)
94
+ panel.style.position = ""
95
+ panel.style.left = ""
96
+ panel.style.top = ""
97
+ panel.style.display = ""
98
+ // If the original parent has been morphed away, the panel would
99
+ // orphan in <body>. Drop it instead of re-attaching to a detached
100
+ // home node.
101
+ if (this._panelHome && document.contains(this._panelHome)) {
102
+ this._panelHome.appendChild(panel)
103
+ } else {
104
+ panel.remove()
105
+ }
106
+ this._panel = null
107
+ this._panelHome = null
108
+ }
109
+
110
+ _position() {
111
+ if (!this._panel || !this.hasTriggerTarget) return
112
+ const panel = this._panel
113
+ const triggerRect = this.triggerTarget.getBoundingClientRect()
114
+ panel.style.position = "fixed"
115
+ panel.style.left = `${triggerRect.right + 4}px`
116
+ panel.style.top = `${triggerRect.top}px`
117
+
118
+ // Shift up if the panel would overflow the viewport bottom.
119
+ requestAnimationFrame(() => {
120
+ const panelRect = panel.getBoundingClientRect()
121
+ const viewportH = window.innerHeight
122
+ if (panelRect.bottom > viewportH - 8) {
123
+ const overflow = panelRect.bottom - (viewportH - 8)
124
+ panel.style.top = `${parseFloat(panel.style.top) - overflow}px`
125
+ }
126
+ })
127
+ }
128
+ }