plutonium 0.54.0 → 0.56.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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
  3. data/.claude/skills/plutonium-resource/SKILL.md +76 -2
  4. data/.claude/skills/plutonium-ui/SKILL.md +17 -3
  5. data/CHANGELOG.md +45 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +112 -26
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +31 -31
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/config/initializers/rabl.rb +16 -0
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  14. data/docs/public/images/reference/structured-inputs.png +0 -0
  15. data/docs/public/templates/lite.rb +10 -0
  16. data/docs/reference/generators/lite.md +65 -0
  17. data/docs/reference/resource/definition.md +128 -2
  18. data/docs/reference/ui/assets.md +14 -0
  19. data/docs/reference/ui/displays.md +27 -1
  20. data/docs/reference/ui/forms.md +2 -1
  21. data/docs/reference/ui/layouts.md +33 -0
  22. data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
  23. data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
  24. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  25. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  26. data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
  27. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  28. data/gemfiles/rails_7.gemfile.lock +1 -1
  29. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  31. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  32. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  33. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  34. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  37. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  38. data/lib/plutonium/definition/base.rb +1 -0
  39. data/lib/plutonium/definition/structured_inputs.rb +67 -0
  40. data/lib/plutonium/interaction/README.md +24 -78
  41. data/lib/plutonium/interaction/base.rb +10 -2
  42. data/lib/plutonium/models/has_cents.rb +10 -0
  43. data/lib/plutonium/resource/controller.rb +6 -1
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
  45. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  46. data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
  47. data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
  48. data/lib/plutonium/ui/display/base.rb +9 -0
  49. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  50. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  51. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  52. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  53. data/lib/plutonium/ui/display/theme.rb +5 -0
  54. data/lib/plutonium/ui/form/base.rb +5 -0
  55. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  56. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
  57. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  58. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
  59. data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
  60. data/lib/plutonium/ui/form/interaction.rb +7 -2
  61. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  62. data/lib/plutonium/ui/form/resource.rb +5 -1
  63. data/lib/plutonium/ui/form/theme.rb +12 -0
  64. data/lib/plutonium/ui/grid/card.rb +58 -21
  65. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  66. data/lib/plutonium/ui/modal/slideover.rb +9 -3
  67. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  68. data/lib/plutonium/version.rb +1 -1
  69. data/package.json +1 -1
  70. data/plutonium.gemspec +5 -4
  71. data/src/css/components.css +136 -5
  72. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  73. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  74. data/src/js/controllers/register_controllers.js +2 -0
  75. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  76. data/src/js/controllers/structured_input_row_controller.js +26 -0
  77. metadata +30 -8
  78. data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
  79. data/lib/plutonium/interaction/nested_attributes.rb +0 -93
@@ -100,46 +100,83 @@ module Plutonium
100
100
  end
101
101
 
102
102
  def render_subheader_slot
103
- value = field_value(slots[:subheader])
104
- return if value.blank?
105
- p(class: "text-xs text-[var(--pu-text-muted)] truncate") { plain helpers.display_name_of(value) }
103
+ name = slots[:subheader]
104
+ value = field_value(name)
105
+ p(class: "text-xs text-[var(--pu-text-muted)] truncate") do
106
+ value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
107
+ end
106
108
  end
107
109
 
108
110
  def render_body_slot
109
- value = field_value(slots[:body])
110
- return if value.blank?
111
- p(class: "text-sm text-[var(--pu-text)] line-clamp-3") { plain helpers.display_name_of(value) }
111
+ name = slots[:body]
112
+ value = field_value(name)
113
+ p(class: "text-sm text-[var(--pu-text)] line-clamp-3") do
114
+ value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
115
+ end
112
116
  end
113
117
 
114
118
  def render_meta_slot
115
119
  fields = Array(slots[:meta])
116
120
  values = fields.map { |f| field_value(f) }.reject(&:blank?)
117
- return if values.empty?
118
121
 
119
122
  div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
120
- values.each do |v|
121
- span(class: "inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium " \
122
- "bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)]") do
123
- plain helpers.display_name_of(v)
124
- end
123
+ if values.empty?
124
+ render_blank_placeholder
125
+ else
126
+ values.each { |v| render_meta_badge(v) }
125
127
  end
126
128
  end
127
129
  end
128
130
 
129
131
  def render_footer_slot
130
- value = field_value(footer_field)
131
- return if value.blank?
132
+ name = footer_field
133
+ value = field_value(name)
132
134
  p(class: "text-xs text-[var(--pu-text-subtle)] mt-1") do
133
- if value.respond_to?(:strftime)
134
- # display_datetime_value returns HTML-safe <time> markup
135
- # rendered by the timeago Stimulus controller.
136
- raw safe(helpers.display_datetime_value(value))
137
- else
138
- plain helpers.display_name_of(value)
139
- end
135
+ value.blank? ? render_blank_placeholder : render_formatted_value(name, value)
136
+ end
137
+ end
138
+
139
+ # Emits a slot value formatted by type, reusing the display layer's
140
+ # logic without its show-page label/wrapper chrome:
141
+ # - dates/times → timeago markup (same path as the footer used)
142
+ # - has_cents columns → currency (matches the Currency component)
143
+ # - everything else → display_name_of
144
+ def render_formatted_value(name, value)
145
+ if value.respond_to?(:strftime)
146
+ # display_datetime_value returns HTML-safe <time> markup
147
+ # rendered by the timeago Stimulus controller.
148
+ raw safe(helpers.display_datetime_value(value))
149
+ elsif currency_field?(name)
150
+ plain helpers.number_to_currency(value, unit: "")
151
+ else
152
+ plain helpers.display_name_of(value)
153
+ end
154
+ end
155
+
156
+ # Renders a meta value as a colored pill, borrowing the Badge
157
+ # display component's semantic color + humanize logic. Status-like
158
+ # values (published, pending, failed…) get meaningful colors;
159
+ # free-form values get a deterministic decorative color.
160
+ def render_meta_badge(value)
161
+ badge = Plutonium::UI::Display::Components::Badge
162
+ variant = badge.variant_for(value)
163
+ span(class: tokens("pu-badge", "pu-badge-#{variant}")) do
164
+ plain badge.humanize(value)
140
165
  end
141
166
  end
142
167
 
168
+ def currency_field?(name)
169
+ klass = record.class
170
+ klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(name.to_sym)
171
+ end
172
+
173
+ # A declared slot with no value renders a muted em-dash rather than
174
+ # collapsing, so cards in a grid keep an even height instead of
175
+ # ragged rows when some records lack the field.
176
+ def render_blank_placeholder
177
+ span(class: "text-[var(--pu-text-subtle)]") { plain "—" }
178
+ end
179
+
143
180
  # ---------------------------------------------------------------
144
181
  # Card chrome — selection, actions, show
145
182
  # ---------------------------------------------------------------
@@ -113,8 +113,7 @@ module Plutonium
113
113
  a(
114
114
  href: item.url,
115
115
  title: item.label,
116
- aria: {label: item.label},
117
- class: "icon-rail-leaf #{leaf_classes(item, depth)}"
116
+ **item_link_attributes(item, "icon-rail-leaf #{leaf_classes(item, depth)}", base_aria: {label: item.label})
118
117
  ) do
119
118
  render_item_icon(item)
120
119
  span(class: "icon-rail-label hidden") { item.label }
@@ -137,12 +136,12 @@ module Plutonium
137
136
  a(
138
137
  href: item.url || "#",
139
138
  title: item.label,
140
- aria: {label: item.label, haspopup: "menu", expanded: "false"},
141
- data: {
142
- "icon-rail-flyout-target": "trigger",
143
- action: "click->icon-rail-flyout#toggle"
144
- },
145
- class: "icon-rail-parent-trigger #{parent_trigger_classes(item, depth)}"
139
+ **item_link_attributes(
140
+ item,
141
+ "icon-rail-parent-trigger #{parent_trigger_classes(item, depth)}",
142
+ base_aria: {label: item.label, haspopup: "menu", expanded: "false"},
143
+ base_data: {"icon-rail-flyout-target": "trigger", action: "click->icon-rail-flyout#toggle"}
144
+ )
146
145
  ) do
147
146
  render_item_icon(item)
148
147
  span(class: "icon-rail-label") { item.label }
@@ -159,7 +158,11 @@ module Plutonium
159
158
  div(class: "icon-rail-flyout-inner") do
160
159
  div(class: "icon-rail-flyout-label") { item.label }
161
160
  item.items.each do |child|
162
- a(href: child.url, class: "icon-rail-flyout-item", role: "menuitem") { child.label }
161
+ a(
162
+ href: child.url,
163
+ role: "menuitem",
164
+ **item_link_attributes(child, "icon-rail-flyout-item")
165
+ ) { child.label }
163
166
  end
164
167
  end
165
168
  end
@@ -206,6 +209,23 @@ module Plutonium
206
209
  def active?(item)
207
210
  item.active?(self)
208
211
  end
212
+
213
+ # Anchor attributes a menu item opts into via its Phlexi::Menu options
214
+ # (target:, rel:, data:, aria:, …), merged with the anchor's own
215
+ # framework attributes and spread onto the <a>. The framework's
216
+ # class / data / aria (base styling, flyout wiring, popup semantics)
217
+ # take precedence so a menu item can *extend* the link without breaking
218
+ # navigation behavior. Phlexi keeps its own :active key in options,
219
+ # which must never become an attribute.
220
+ def item_link_attributes(item, base_class, base_data: {}, base_aria: {})
221
+ opts = (item.options || {}).except(:active)
222
+ data = (opts[:data] || {}).merge(base_data)
223
+ aria = (opts[:aria] || {}).merge(base_aria)
224
+ opts[:class] = [base_class, opts[:class]].compact.join(" ")
225
+ opts[:data] = data unless data.empty?
226
+ opts[:aria] = aria unless aria.empty?
227
+ opts
228
+ end
209
229
  end
210
230
  end
211
231
  end
@@ -25,11 +25,17 @@ module Plutonium
25
25
  # controller on the frame after showModal(). Mirrors the filter
26
26
  # slideover's pattern — see Centered for the same rationale.
27
27
  def base_dialog_classes
28
+ # The backdrop dim+blur is static (no [data-open] gating, no
29
+ # transition): a ::backdrop that fades its bg-color while carrying
30
+ # backdrop-filter re-rasterises the blur every frame and stutters
31
+ # the panel slide. Keeping it static lets only the panel animate
32
+ # (transform), composited smoothly. The backdrop snaps in at
33
+ # showModal() and is dropped when the dialog leaves the top layer
34
+ # on close(), so it still covers the panel's slide-out. Mirrors
35
+ # the .pu-dialog::backdrop rule in components.css.
28
36
  "fixed top-0 right-0 bottom-0 left-auto m-0 h-screen max-w-full max-h-screen " \
29
37
  "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
30
- "backdrop:bg-transparent data-[open]:backdrop:bg-black/60 " \
31
- "data-[open]:backdrop:backdrop-blur-sm " \
32
- "backdrop:transition-[background-color] backdrop:duration-300 backdrop:ease-out " \
38
+ "backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
33
39
  "rounded-none p-0 " \
34
40
  "open:flex flex-col " \
35
41
  "translate-x-full data-[open]:translate-x-0 " \
@@ -89,6 +89,35 @@ module Plutonium
89
89
  end
90
90
  end
91
91
 
92
+ # Spread any per-item HTML attributes (target:, rel:, data:, …) the item
93
+ # opts into via its Phlexi::Menu options — e.g. a menu item that opens a
94
+ # full-screen SPA in its own tab. The base Phlexi implementation
95
+ # hardcodes the anchor and drops these, so we re-render the leaf.
96
+ def render_item_link(item, depth)
97
+ link_class = themed(:item_link, depth)
98
+ active = active_class(item, depth)
99
+ classes = active ? "#{link_class} #{active}" : link_class
100
+
101
+ a(href: item.url, **item_link_attributes(item, classes)) do
102
+ render_item_interior(item, depth)
103
+ end
104
+ end
105
+
106
+ # Anchor attributes opted into via Phlexi::Menu item options (target:,
107
+ # rel:, data:, aria:, …), minus Phlexi's own :active key (which must not
108
+ # leak onto the <a>). A user-supplied :class merges with the themed base
109
+ # classes; base_data / base_aria (none on the plain leaf today) always
110
+ # win so options extend rather than replace framework wiring.
111
+ def item_link_attributes(item, base_class, base_data: {}, base_aria: {})
112
+ opts = (item.options || {}).except(:active)
113
+ data = (opts[:data] || {}).merge(base_data)
114
+ aria = (opts[:aria] || {}).merge(base_aria)
115
+ opts[:class] = [base_class, opts[:class]].compact.join(" ")
116
+ opts[:data] = data unless data.empty?
117
+ opts[:aria] = aria unless aria.empty?
118
+ opts
119
+ end
120
+
92
121
  def render_collapsible_button(item, depth)
93
122
  button(
94
123
  type: "button",
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.54.0"
2
+ VERSION = "0.56.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
data/plutonium.gemspec CHANGED
@@ -17,17 +17,18 @@ Gem::Specification.new do |spec|
17
17
  spec.metadata["allowed_push_host"] = "https://rubygems.org"
18
18
 
19
19
  spec.post_install_message = <<~MSG
20
- ⚠️ Plutonium #{Plutonium::VERSION} — breaking change
20
+ ℹ️ Plutonium — breaking change introduced in 0.49.0
21
21
 
22
- Entity-scoped URL helpers and path params have been renamed from
22
+ Entity-scoped URL helpers and path params were renamed in 0.49.0 from
23
23
  `<entity>_scope_*` to `<entity>_scoped_*`.
24
24
 
25
25
  Examples:
26
26
  organization_scope_widgets_path → organization_scoped_widgets_path
27
27
  params[:organization_scope] → params[:organization_scoped]
28
28
 
29
- If you reference these helpers or params directly (e.g. in tests, custom
30
- redirects, or hand-written links), update them to the new names.
29
+ If you are upgrading from 0.48.0 or earlier and reference these helpers or
30
+ params directly (e.g. in tests, custom redirects, or hand-written links),
31
+ update them to the new names.
31
32
 
32
33
  Apps that only use `resource_url_for` are unaffected.
33
34
  MSG
@@ -178,6 +178,116 @@
178
178
  @apply bg-accent-950/50 text-accent-300 hover:bg-accent-900/60 active:bg-accent-900/80;
179
179
  }
180
180
 
181
+ /* ===================
182
+ BADGES - Status pills
183
+ =================== */
184
+
185
+ .pu-badge {
186
+ @apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap align-middle;
187
+ }
188
+
189
+ .pu-badge-neutral {
190
+ @apply bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)] border border-[var(--pu-border-muted)];
191
+ }
192
+
193
+ .pu-badge-primary {
194
+ @apply bg-primary-50 text-primary-700;
195
+ }
196
+ .dark .pu-badge-primary {
197
+ @apply bg-primary-950/50 text-primary-300;
198
+ }
199
+
200
+ .pu-badge-secondary {
201
+ @apply bg-secondary-50 text-secondary-700;
202
+ }
203
+ .dark .pu-badge-secondary {
204
+ @apply bg-secondary-950/50 text-secondary-300;
205
+ }
206
+
207
+ .pu-badge-success {
208
+ @apply bg-success-50 text-success-700;
209
+ }
210
+ .dark .pu-badge-success {
211
+ @apply bg-success-950/50 text-success-300;
212
+ }
213
+
214
+ .pu-badge-danger {
215
+ @apply bg-danger-50 text-danger-700;
216
+ }
217
+ .dark .pu-badge-danger {
218
+ @apply bg-danger-950/50 text-danger-300;
219
+ }
220
+
221
+ .pu-badge-warning {
222
+ @apply bg-warning-50 text-warning-700;
223
+ }
224
+ .dark .pu-badge-warning {
225
+ @apply bg-warning-950/50 text-warning-300;
226
+ }
227
+
228
+ .pu-badge-info {
229
+ @apply bg-info-50 text-info-700;
230
+ }
231
+ .dark .pu-badge-info {
232
+ @apply bg-info-950/50 text-info-300;
233
+ }
234
+
235
+ .pu-badge-accent {
236
+ @apply bg-accent-50 text-accent-700;
237
+ }
238
+ .dark .pu-badge-accent {
239
+ @apply bg-accent-950/50 text-accent-300;
240
+ }
241
+
242
+ /* ===================
243
+ TOGGLE - Switch-styled checkbox
244
+ =================== */
245
+
246
+ .pu-toggle {
247
+ appearance: none;
248
+ -webkit-appearance: none;
249
+ box-sizing: border-box;
250
+ position: relative;
251
+ display: inline-block;
252
+ flex-shrink: 0;
253
+ width: 2.75rem; /* 44px */
254
+ height: 1.5rem; /* 24px */
255
+ padding: 0;
256
+ border: 0;
257
+ border-radius: 9999px;
258
+ background-color: var(--pu-border);
259
+ background-image: none; /* override @tailwindcss/forms checkmark */
260
+ cursor: pointer;
261
+ vertical-align: middle;
262
+ transition: background-color var(--pu-transition-normal, 0.2s);
263
+ }
264
+
265
+ .pu-toggle:checked {
266
+ background-image: none; /* override @tailwindcss/forms checkmark */
267
+ @apply bg-primary-600;
268
+ }
269
+
270
+ .pu-toggle::before {
271
+ content: "";
272
+ position: absolute;
273
+ top: 3px;
274
+ left: 3px;
275
+ width: 18px;
276
+ height: 18px;
277
+ border-radius: 9999px;
278
+ background-color: #fff;
279
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
280
+ transition: transform var(--pu-transition-normal, 0.2s);
281
+ }
282
+
283
+ .pu-toggle:checked::before {
284
+ transform: translateX(20px); /* 44 - 18 - 3 - 3 */
285
+ }
286
+
287
+ .pu-toggle:focus-visible {
288
+ @apply outline-none ring-2 ring-primary-500 ring-offset-2;
289
+ }
290
+
181
291
  /* ===================
182
292
  CARDS - Clean, elevated
183
293
  =================== */
@@ -206,6 +316,22 @@
206
316
  @apply w-full px-3 h-9 text-sm focus:outline-none;
207
317
  }
208
318
 
319
+ /* Native multi-selects (and sized list boxes) must grow — pu-input's fixed
320
+ h-9 collapses them to a single clipped row. */
321
+ select.pu-input[multiple],
322
+ select.pu-input[size]:not([size="1"]) {
323
+ height: auto;
324
+ min-height: 7.5rem;
325
+ @apply py-1.5;
326
+ }
327
+
328
+ select.pu-input[multiple] option {
329
+ border-radius: var(--pu-radius-md);
330
+ margin-block: 4px;
331
+ padding: 0.4rem 0.625rem;
332
+ line-height: 1.4;
333
+ }
334
+
209
335
  .pu-input-toolbar {
210
336
  @apply h-8 text-sm;
211
337
  }
@@ -513,12 +639,17 @@
513
639
  border-radius: var(--pu-radius-lg);
514
640
  }
515
641
 
642
+ /* Dim + frosted blur for the whole time the dialog is in the top layer.
643
+ Deliberately NOT transitioned and NOT gated on [data-open]: a ::backdrop
644
+ that repaints every frame (a bg-color fade) while carrying a
645
+ backdrop-filter forces the browser to re-rasterise the blur per frame,
646
+ which stutters the panel's open/close animation — and doubly so when a
647
+ nested confirm animates over a parent modal whose blur is still live.
648
+ Keeping it static lets the GPU rasterise the blur once; only the panel
649
+ animates (opacity/scale/translate, cheaply composited). The backdrop
650
+ snaps in at showModal() and is removed when the dialog leaves the top
651
+ layer on close(), so the dim still covers the panel's exit. */
516
652
  .pu-dialog::backdrop {
517
- background-color: transparent;
518
- transition: background-color 200ms ease-out;
519
- }
520
-
521
- .pu-dialog[data-open]::backdrop {
522
653
  background-color: rgb(0 0 0 / 0.6);
523
654
  backdrop-filter: blur(4px);
524
655
  }
@@ -5,6 +5,27 @@ import { Controller } from "@hotwired/stimulus";
5
5
  // Self-disables when not inside a <dialog>, so it's safe to attach to
6
6
  // every form unconditionally.
7
7
  //
8
+ // Dirtiness is a diff against a baseline captured at the user's *first*
9
+ // real interaction — not at connect. Field widgets (intl-tel-input,
10
+ // flatpickr, slim-select, easymde) mutate the form *after* connect —
11
+ // injecting hidden inputs, reformatting values, replacing the native
12
+ // control — via silent `input.value = …` writes (no event) or synthetic
13
+ // events. Snapshotting at connect counted that settling as edits and
14
+ // prompted on a pristine modal.
15
+ //
16
+ // Instead:
17
+ // • On the first *trusted* pointer/key action inside the form, serialize
18
+ // the form into `baseline`. The user can't interact before the form has
19
+ // rendered, so widgets have already settled — their hidden inputs and
20
+ // reformatted values are part of the baseline, not a phantom diff. The
21
+ // key/pointer event fires *before* the value changes, so the baseline is
22
+ // pre-edit.
23
+ // • On close, the form is dirty if its serialization differs from
24
+ // `baseline`. This is independent of which events a widget dispatches
25
+ // (or whether it dispatches any), catches widget-mediated edits the same
26
+ // as native typing, and — being a diff — treats an edit reverted to its
27
+ // original value as clean. No interaction → no baseline → never dirty.
28
+ //
8
29
  // Esc is intercepted at the document's capture phase: relying on the
9
30
  // dialog's `cancel` event alone proved flaky under rapid/held Esc when
10
31
  // the parent dialog uses `closedby="any"`. The cancel listener stays
@@ -12,24 +33,38 @@ import { Controller } from "@hotwired/stimulus";
12
33
  export default class extends Controller {
13
34
  static targets = ["confirmDialog"];
14
35
 
15
- // Set by controllers, not the user — comparing them would flag
16
- // every form as dirty on connect (return_to) or on submit (pre_submit).
36
+ // Set by controllers, not the user — they're already present (or absent)
37
+ // when the baseline is taken, so they never contribute to the diff; listed
38
+ // for safety against a controller writing them mid-edit.
17
39
  static IGNORED_KEYS = new Set(["authenticity_token", "return_to", "pre_submit"]);
18
40
 
41
+ // Keys that move focus or dismiss the dialog rather than edit it — they
42
+ // must not, on their own, baseline the form.
43
+ static NON_EDITING_KEYS = new Set([
44
+ "Tab", "Escape", "Shift", "Control", "Alt", "Meta",
45
+ ]);
46
+
19
47
  connect() {
20
48
  this.dialog = this.element.closest("dialog");
21
49
  if (!this.dialog) return;
22
50
 
23
- this.snapshot = this.#serialize();
51
+ this.baseline = null;
24
52
  this.forceClose = false;
25
53
  this.submitting = false;
26
54
 
55
+ this.onFirstIntent = this.#onFirstIntent.bind(this);
27
56
  this.onCancel = this.#onCancel.bind(this);
28
57
  this.onSubmit = this.#onSubmit.bind(this);
29
58
  this.onCloseButtonClick = this.#onCloseButtonClick.bind(this);
30
59
  this.onConfirmCancel = this.#onConfirmCancel.bind(this);
31
60
  this.onKeydown = this.#onKeydown.bind(this);
32
61
 
62
+ // A trusted pointer/key action inside the form is the user starting to
63
+ // edit — capture the (settled, pre-edit) baseline then. Capture phase so
64
+ // a widget that stops propagation can't hide it from us.
65
+ this.element.addEventListener("pointerdown", this.onFirstIntent, true);
66
+ this.element.addEventListener("keydown", this.onFirstIntent, true);
67
+
33
68
  document.addEventListener("keydown", this.onKeydown, true);
34
69
  // Capture phase so this runs before remote-modal's cancel handler
35
70
  // — that way `defaultPrevented` is visible there if we intervene.
@@ -47,6 +82,8 @@ export default class extends Controller {
47
82
 
48
83
  disconnect() {
49
84
  if (!this.dialog) return;
85
+ this.element.removeEventListener("pointerdown", this.onFirstIntent, true);
86
+ this.element.removeEventListener("keydown", this.onFirstIntent, true);
50
87
  document.removeEventListener("keydown", this.onKeydown, true);
51
88
  this.dialog.removeEventListener("cancel", this.onCancel, true);
52
89
  this.element.removeEventListener("submit", this.onSubmit);
@@ -84,6 +121,17 @@ export default class extends Controller {
84
121
  return this.dialog.querySelectorAll('[data-action~="remote-modal#close"]');
85
122
  }
86
123
 
124
+ // Capture the baseline the first time the user really touches the form —
125
+ // a trusted pointer or editing keystroke. The form has rendered (so widgets
126
+ // have settled) and the event fires before the value changes, so this is
127
+ // the settled, pre-edit state. Runs once; later interactions are no-ops.
128
+ #onFirstIntent(event) {
129
+ if (this.baseline != null) return;
130
+ if (!event.isTrusted) return;
131
+ if (event.type === "keydown" && this.constructor.NON_EDITING_KEYS.has(event.key)) return;
132
+ this.baseline = this.#serialize();
133
+ }
134
+
87
135
  #serialize() {
88
136
  const data = new FormData(this.element);
89
137
  const enc = encodeURIComponent;
@@ -97,8 +145,11 @@ export default class extends Controller {
97
145
  .join("&");
98
146
  }
99
147
 
148
+ // No interaction → no baseline → never dirty. Otherwise dirty iff the form
149
+ // now serializes differently than it did at first touch (so an edit reverted
150
+ // to its original value reads as clean).
100
151
  #isDirty() {
101
- return this.#serialize() !== this.snapshot;
152
+ return this.baseline != null && this.#serialize() !== this.baseline;
102
153
  }
103
154
 
104
155
  #onSubmit() {
@@ -1,7 +1,12 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="nested-resource-form-fields"
4
- // Copied from https://github.com/stimulus-components/stimulus-rails-nested-form/blob/master/src/index.ts
4
+ // Adapted from https://github.com/stimulus-components/stimulus-rails-nested-form
5
+ //
6
+ // Persisted rows soft-delete: the row collapses to a "Removed — Restore" bar,
7
+ // its `_destroy` flag flips to "1", and it drops out of the row count so the
8
+ // add button can come back. Restoring reverses all three. Unpersisted rows are
9
+ // removed from the DOM outright — there is nothing to restore server-side.
5
10
  export default class extends Controller {
6
11
  static targets = ["target", "template", "addButton"]
7
12
 
@@ -23,9 +28,7 @@ export default class extends Controller {
23
28
  const content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime().toString())
24
29
  this.targetTarget.insertAdjacentHTML("beforebegin", content)
25
30
 
26
- const event = new CustomEvent("nested-resource-form-fields:add", { bubbles: true })
27
- this.element.dispatchEvent(event)
28
-
31
+ this.dispatch("add")
29
32
  this.updateState()
30
33
  }
31
34
 
@@ -36,19 +39,37 @@ export default class extends Controller {
36
39
  if (wrapper.dataset.newRecord !== undefined) {
37
40
  wrapper.remove()
38
41
  } else {
39
- wrapper.style.display = "none"
40
- wrapper.classList.remove(...wrapper.classList)
41
-
42
- const input = wrapper.querySelector("input[name*='_destroy']")
43
- input.value = "1"
42
+ this.toggleRemoved(wrapper, true)
44
43
  }
45
44
 
46
- const event = new CustomEvent("nested-resource-form-fields:remove", { bubbles: true })
47
- this.element.dispatchEvent(event)
45
+ this.dispatch("remove")
46
+ this.updateState()
47
+ }
48
+
49
+ restore(e) {
50
+ e.preventDefault()
51
+
52
+ const wrapper = e.target.closest(this.wrapperSelectorValue)
53
+ this.toggleRemoved(wrapper, false)
48
54
 
55
+ this.dispatch("restore")
49
56
  this.updateState()
50
57
  }
51
58
 
59
+ // Collapse a persisted row to its "Removed" bar (or expand it back), keeping
60
+ // the `_destroy` flag and the removed-state marker in sync.
61
+ toggleRemoved(wrapper, removed) {
62
+ wrapper.toggleAttribute("data-removed", removed)
63
+
64
+ const content = wrapper.querySelector(":scope > [data-nested-content]")
65
+ const removedBar = wrapper.querySelector(":scope > [data-nested-removed]")
66
+ if (content) content.hidden = removed
67
+ if (removedBar) removedBar.hidden = !removed
68
+
69
+ const destroyInput = wrapper.querySelector("input[name*='_destroy']")
70
+ if (destroyInput) destroyInput.value = removed ? "1" : "0"
71
+ }
72
+
52
73
  updateState() {
53
74
  if (!this.hasAddButtonTarget || this.limitValue == 0) return
54
75
 
@@ -58,7 +79,9 @@ export default class extends Controller {
58
79
  this.addButtonTarget.style.display = "initial"
59
80
  }
60
81
 
82
+ // Removed rows keep their wrapper (so they can be restored) but are excluded
83
+ // from the count so the limit reflects rows that will actually be saved.
61
84
  get childCount() {
62
- return this.element.querySelectorAll(this.wrapperSelectorValue).length
85
+ return this.element.querySelectorAll(`${this.wrapperSelectorValue}:not([data-removed])`).length
63
86
  }
64
87
  }
@@ -1,6 +1,7 @@
1
1
  // Import controllers here
2
2
  import ResourceHeaderController from "./resource_header_controller.js"
3
3
  import NestedResourceFormFieldsController from "./nested_resource_form_fields_controller.js"
4
+ import StructuredInputRowController from "./structured_input_row_controller.js"
4
5
  import FormController from "./form_controller.js"
5
6
  import ResourceDropDownController from "./resource_drop_down_controller.js"
6
7
  import ResourceCollapseController from "./resource_collapse_controller.js"
@@ -40,6 +41,7 @@ export default function (application) {
40
41
  application.register("sidebar", SidebarController)
41
42
  application.register("resource-header", ResourceHeaderController)
42
43
  application.register("nested-resource-form-fields", NestedResourceFormFieldsController)
44
+ application.register("structured-input-row", StructuredInputRowController)
43
45
  application.register("form", FormController)
44
46
  application.register("resource-drop-down", ResourceDropDownController)
45
47
  application.register("resource-collapse", ResourceCollapseController)