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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-definition/SKILL.md +87 -2
- data/.claude/skills/plutonium-installation/SKILL.md +6 -0
- data/.claude/skills/plutonium-views/SKILL.md +59 -0
- data/CHANGELOG.md +12 -0
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.js +369 -25
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +45 -45
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +4 -4
- data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
- data/app/views/resource/_resource_grid.html.erb +1 -0
- data/config/brakeman.ignore +25 -2
- data/docs/reference/definition/actions.md +14 -1
- data/docs/reference/definition/index.md +58 -0
- data/docs/reference/views/index.md +43 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
- data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
- data/lib/generators/pu/core/update/update_generator.rb +20 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
- data/lib/plutonium/action/base.rb +44 -1
- data/lib/plutonium/action/interactive.rb +1 -1
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/definition/actions.rb +3 -0
- data/lib/plutonium/definition/base.rb +8 -0
- data/lib/plutonium/definition/metadata.rb +40 -0
- data/lib/plutonium/definition/views.rb +94 -0
- data/lib/plutonium/helpers/turbo_helper.rb +1 -1
- data/lib/plutonium/interaction/response/redirect.rb +1 -1
- data/lib/plutonium/query/base.rb +8 -0
- data/lib/plutonium/query/filters/association.rb +30 -8
- data/lib/plutonium/query/filters/boolean.rb +5 -0
- data/lib/plutonium/resource/controllers/presentable.rb +11 -2
- data/lib/plutonium/resource/definition.rb +42 -0
- data/lib/plutonium/resource/query_object.rb +64 -6
- data/lib/plutonium/testing/resource_definition.rb +2 -2
- data/lib/plutonium/ui/action_button.rb +4 -2
- data/lib/plutonium/ui/component/kit.rb +12 -0
- data/lib/plutonium/ui/display/base.rb +3 -1
- data/lib/plutonium/ui/display/resource.rb +109 -25
- data/lib/plutonium/ui/display/theme.rb +2 -1
- data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
- data/lib/plutonium/ui/empty_card.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +29 -1
- data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
- data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
- data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
- data/lib/plutonium/ui/form/resource.rb +48 -9
- data/lib/plutonium/ui/form/theme.rb +1 -1
- data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
- data/lib/plutonium/ui/grid/card.rb +235 -0
- data/lib/plutonium/ui/grid/resource.rb +149 -0
- data/lib/plutonium/ui/layout/base.rb +37 -1
- data/lib/plutonium/ui/layout/header.rb +1 -2
- data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
- data/lib/plutonium/ui/layout/sidebar.rb +12 -24
- data/lib/plutonium/ui/layout/topbar.rb +100 -0
- data/lib/plutonium/ui/modal/base.rb +109 -0
- data/lib/plutonium/ui/modal/centered.rb +21 -0
- data/lib/plutonium/ui/modal/slideover.rb +26 -0
- data/lib/plutonium/ui/page/base.rb +25 -6
- data/lib/plutonium/ui/page/edit.rb +13 -1
- data/lib/plutonium/ui/page/index.rb +40 -1
- data/lib/plutonium/ui/page/interactive_action.rb +8 -39
- data/lib/plutonium/ui/page/new.rb +13 -1
- data/lib/plutonium/ui/page/show.rb +8 -1
- data/lib/plutonium/ui/page_header.rb +8 -13
- data/lib/plutonium/ui/panel.rb +10 -19
- data/lib/plutonium/ui/sidebar_menu.rb +2 -25
- data/lib/plutonium/ui/tab_list.rb +29 -7
- data/lib/plutonium/ui/table/base.rb +106 -0
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
- data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
- data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
- data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
- data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
- data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
- data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
- data/lib/plutonium/ui/table/resource.rb +158 -89
- data/lib/plutonium/ui/table/theme.rb +14 -5
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +6 -0
- data/package.json +1 -1
- data/src/css/components.css +304 -131
- data/src/css/tokens.css +101 -85
- data/src/js/controllers/autosubmit_controller.js +24 -0
- data/src/js/controllers/bulk_actions_controller.js +15 -16
- data/src/js/controllers/capture_url_controller.js +14 -0
- data/src/js/controllers/filter_panel_controller.js +77 -19
- data/src/js/controllers/frame_navigator_controller.js +34 -6
- data/src/js/controllers/icon_rail_controller.js +22 -0
- data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
- data/src/js/controllers/register_controllers.js +16 -0
- data/src/js/controllers/resource_tab_list_controller.js +56 -3
- data/src/js/controllers/row_click_controller.js +21 -0
- data/src/js/controllers/table_column_menu_controller.js +43 -0
- data/src/js/controllers/table_header_controller.js +16 -0
- data/src/js/controllers/view_switcher_controller.js +29 -0
- 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
|
-
|
|
11
|
+
--pu-body: #f8fafc;
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/* ===================
|
|
14
14
|
Surface Colors - Softer, more refined
|
|
15
15
|
=================== */
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
58
|
-
|
|
57
|
+
--pu-card-bg: #ffffff;
|
|
58
|
+
--pu-card-border: #e2e8f0;
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
/* ===================
|
|
61
61
|
Shadow System - Layered, soft
|
|
62
62
|
=================== */
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
105
|
+
--pu-body: #0f172a;
|
|
98
106
|
|
|
99
|
-
|
|
107
|
+
/* ===================
|
|
100
108
|
Surface Colors (Dark) - Rich, not too dark
|
|
101
109
|
=================== */
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
143
|
-
|
|
150
|
+
--pu-card-bg: #1e293b;
|
|
151
|
+
--pu-card-border: #334155;
|
|
144
152
|
|
|
145
|
-
|
|
153
|
+
/* ===================
|
|
146
154
|
Shadow System (Dark)
|
|
147
155
|
=================== */
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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", "
|
|
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(
|
|
7
|
-
if (input.type ===
|
|
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 ===
|
|
68
|
+
} else if (input.tagName === "SELECT") {
|
|
10
69
|
input.selectedIndex = 0
|
|
11
|
-
} else if (input.type ===
|
|
12
|
-
|
|
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,
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 (
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
+
}
|