plutonium 0.53.1 → 0.55.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-behavior/SKILL.md +22 -0
- data/.claude/skills/plutonium-resource/SKILL.md +55 -0
- data/.claude/skills/plutonium-ui/SKILL.md +2 -1
- data/CHANGELOG.md +38 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +40 -6
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +32 -32
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_flash_alerts.html.erb +8 -17
- data/app/views/plutonium/_flash_toasts.html.erb +9 -18
- data/docs/public/images/reference/structured-inputs-removed.png +0 -0
- data/docs/public/images/reference/structured-inputs.png +0 -0
- data/docs/reference/resource/definition.md +110 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
- data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/plutonium/definition/base.rb +1 -0
- data/lib/plutonium/definition/structured_inputs.rb +67 -0
- data/lib/plutonium/engine/validator.rb +11 -4
- data/lib/plutonium/interaction/README.md +24 -78
- data/lib/plutonium/interaction/base.rb +10 -2
- data/lib/plutonium/resource/controller.rb +6 -1
- data/lib/plutonium/resource/controllers/interactive_actions.rb +10 -6
- data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
- data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +3 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +178 -0
- data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
- data/lib/plutonium/ui/form/resource.rb +15 -5
- data/lib/plutonium/ui/form/theme.rb +14 -3
- data/lib/plutonium/ui/modal/slideover.rb +9 -3
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +119 -5
- data/src/js/controllers/capture_url_controller.js +20 -7
- data/src/js/controllers/dirty_form_guard_controller.js +28 -4
- data/src/js/controllers/icon_rail_flyout_controller.js +5 -2
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +5 -0
- data/src/js/controllers/structured_input_row_controller.js +26 -0
- metadata +14 -4
- data/lib/plutonium/interaction/nested_attributes.rb +0 -93
|
@@ -10,7 +10,7 @@ module Plutonium
|
|
|
10
10
|
module RendersNestedResourceFields
|
|
11
11
|
extend ActiveSupport::Concern
|
|
12
12
|
|
|
13
|
-
DEFAULT_NESTED_LIMIT =
|
|
13
|
+
DEFAULT_NESTED_LIMIT = RepeaterFieldStyles::DEFAULT_LIMIT
|
|
14
14
|
NESTED_OPTION_KEYS = [:allow_destroy, :update_only, :macro, :class].freeze
|
|
15
15
|
SINGULAR_MACROS = %i[belongs_to has_one].freeze
|
|
16
16
|
|
|
@@ -193,7 +193,7 @@ module Plutonium
|
|
|
193
193
|
def render_nested_fields_fieldset(nested, context)
|
|
194
194
|
fieldset(
|
|
195
195
|
data_new_record: !nested.object&.persisted?,
|
|
196
|
-
class:
|
|
196
|
+
class: RepeaterFieldStyles::FIELDSET_CLASS
|
|
197
197
|
) do
|
|
198
198
|
render_nested_fields_fieldset_content(nested, context)
|
|
199
199
|
render_nested_fields_delete_button(nested, context.options)
|
|
@@ -201,7 +201,7 @@ module Plutonium
|
|
|
201
201
|
end
|
|
202
202
|
|
|
203
203
|
def render_nested_fields_fieldset_content(nested, context)
|
|
204
|
-
div(class:
|
|
204
|
+
div(class: RepeaterFieldStyles::FIELD_GRID_CLASS) do
|
|
205
205
|
render_nested_fields_hidden_fields(nested, context)
|
|
206
206
|
render_nested_fields_visible_fields(nested, context)
|
|
207
207
|
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Concerns
|
|
7
|
+
# Renders classless structured inputs (single → hash via nest_one,
|
|
8
|
+
# repeater → array via nest_many). Field/namespace work is delegated to
|
|
9
|
+
# the form; this concern owns the structural markup.
|
|
10
|
+
# @api private
|
|
11
|
+
module RendersStructuredInputs
|
|
12
|
+
extend ActiveSupport::Concern
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def render_structured_input(name)
|
|
17
|
+
entry = resource_definition.defined_structured_inputs[name]
|
|
18
|
+
options = entry[:options] || {}
|
|
19
|
+
definition = structured_input_fields_definition(entry)
|
|
20
|
+
fields = options[:fields] || definition.defined_inputs.keys
|
|
21
|
+
repeat = options[:repeat]
|
|
22
|
+
|
|
23
|
+
if repeat
|
|
24
|
+
render_structured_repeater(name, definition, fields, repeat_limit(repeat))
|
|
25
|
+
else
|
|
26
|
+
render_structured_single(name, definition, fields)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def structured_input_fields_definition(entry)
|
|
31
|
+
return entry[:options][:using] if entry[:options]&.key?(:using)
|
|
32
|
+
|
|
33
|
+
holder = Plutonium::Definition::StructuredInputs::FieldsDefinition.new
|
|
34
|
+
entry[:block].call(holder)
|
|
35
|
+
holder
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def repeat_limit(repeat)
|
|
39
|
+
repeat.is_a?(Integer) ? repeat : RepeaterFieldStyles::DEFAULT_LIMIT
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- single -------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
def render_structured_single(name, definition, fields)
|
|
45
|
+
raw = structured_input_value(name)
|
|
46
|
+
value = raw.is_a?(Hash) ? raw.with_indifferent_access : {}
|
|
47
|
+
div(class: "col-span-full space-y-2 my-4") do
|
|
48
|
+
h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
|
|
49
|
+
nest_one(name, as: name, object: value) do |nested|
|
|
50
|
+
render_structured_fieldset(nested, definition, fields, removable: false)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- repeater -----------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def render_structured_repeater(name, definition, fields, limit)
|
|
58
|
+
rows = Array(structured_input_value(name)).map { |row| row.is_a?(Hash) ? row.with_indifferent_access : row }
|
|
59
|
+
existing_collection = rows.presence || {NEW_RECORD: {}}
|
|
60
|
+
div(
|
|
61
|
+
class: "col-span-full space-y-2 my-4",
|
|
62
|
+
data: {
|
|
63
|
+
controller: "nested-resource-form-fields",
|
|
64
|
+
nested_resource_form_fields_limit_value: limit
|
|
65
|
+
}
|
|
66
|
+
) do
|
|
67
|
+
h2(class: "text-lg font-semibold text-[var(--pu-text)]") { name.to_s.humanize }
|
|
68
|
+
template data_nested_resource_form_fields_target: "template" do
|
|
69
|
+
nest_many(name, as: name, collection: {NEW_RECORD: {}}, default: {NEW_RECORD: {}}, template: true) do |nested|
|
|
70
|
+
render_structured_fieldset(nested, definition, fields, removable: true)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
nest_many(name, as: name, collection: existing_collection) do |nested|
|
|
74
|
+
if nested.object.blank?
|
|
75
|
+
vanish { render_structured_fieldset(nested, definition, fields, removable: true) }
|
|
76
|
+
else
|
|
77
|
+
render_structured_fieldset(nested, definition, fields, removable: true)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
div(data_nested_resource_form_fields_target: :target, hidden: true)
|
|
81
|
+
render_structured_add_button(name)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- helpers ------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def structured_input_value(name)
|
|
88
|
+
obj = object
|
|
89
|
+
return nil unless obj.respond_to?(name)
|
|
90
|
+
obj.public_send(name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Single source of truth shared with RendersNestedResourceFields.
|
|
94
|
+
FIELDSET_CLASS = RepeaterFieldStyles::FIELDSET_CLASS
|
|
95
|
+
FIELD_GRID_CLASS = RepeaterFieldStyles::FIELD_GRID_CLASS
|
|
96
|
+
|
|
97
|
+
# @param removable [Boolean] repeater rows are removable; a single
|
|
98
|
+
# structured input is the one-and-only object, so it is not.
|
|
99
|
+
def render_structured_fieldset(nested, definition, fields, removable:)
|
|
100
|
+
unless removable
|
|
101
|
+
return fieldset(class: FIELDSET_CLASS) do
|
|
102
|
+
div(class: FIELD_GRID_CLASS) do
|
|
103
|
+
fields.each { |input| render_simple_resource_field(input, definition, nested) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Removable rows soft-delete by DISABLING the inner fieldset: a
|
|
109
|
+
# disabled <fieldset> is omitted from submission, so the server just
|
|
110
|
+
# receives the payload without this row and rebuilds the JSON column
|
|
111
|
+
# from what it gets (no _destroy marker). The row stays in the DOM,
|
|
112
|
+
# collapsed to a "Removed — Restore" bar, so it can be restored.
|
|
113
|
+
div(data_controller: "structured-input-row", class: FIELDSET_CLASS) do
|
|
114
|
+
fieldset(data_structured_input_row_target: "content", class: "space-y-4 border-0 p-0 m-0") do
|
|
115
|
+
div(class: FIELD_GRID_CLASS) do
|
|
116
|
+
fields.each { |input| render_simple_resource_field(input, definition, nested) }
|
|
117
|
+
end
|
|
118
|
+
render_structured_remove_button
|
|
119
|
+
end
|
|
120
|
+
render_structured_removed_bar
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_structured_remove_button
|
|
125
|
+
div(class: "flex items-center justify-end") do
|
|
126
|
+
button(
|
|
127
|
+
type: :button,
|
|
128
|
+
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
|
|
129
|
+
"text-danger-700 hover:bg-danger-50 dark:text-danger-400 dark:hover:bg-danger-950/30 " \
|
|
130
|
+
"focus:outline-none focus:ring-4 focus:ring-danger-200 dark:focus:ring-danger-900",
|
|
131
|
+
data_action: "structured-input-row#remove"
|
|
132
|
+
) do
|
|
133
|
+
render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
|
|
134
|
+
span { "Remove" }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Compact bar shown in place of the row once it's marked for removal.
|
|
140
|
+
def render_structured_removed_bar
|
|
141
|
+
div(
|
|
142
|
+
data_structured_input_row_target: "removed",
|
|
143
|
+
hidden: true,
|
|
144
|
+
class: "flex items-center justify-between gap-3 text-sm text-[var(--pu-text-muted)]"
|
|
145
|
+
) do
|
|
146
|
+
span(class: "italic") { "Removed" }
|
|
147
|
+
button(
|
|
148
|
+
type: :button,
|
|
149
|
+
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-lg cursor-pointer " \
|
|
150
|
+
"text-secondary-700 hover:bg-secondary-50 dark:text-secondary-300 dark:hover:bg-secondary-900/30 " \
|
|
151
|
+
"focus:outline-none focus:ring-4 focus:ring-secondary-200 dark:focus:ring-secondary-900",
|
|
152
|
+
data_action: "structured-input-row#restore"
|
|
153
|
+
) do
|
|
154
|
+
render Phlex::TablerIcons::ArrowBackUp.new(class: "w-4 h-4")
|
|
155
|
+
span { "Restore" }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def render_structured_add_button(name)
|
|
161
|
+
div do
|
|
162
|
+
button(
|
|
163
|
+
type: :button,
|
|
164
|
+
class: "inline-block",
|
|
165
|
+
data: {action: "nested-resource-form-fields#add", nested_resource_form_fields_target: "addButton"}
|
|
166
|
+
) do
|
|
167
|
+
span(class: "bg-secondary-700 text-white flex items-center justify-center px-4 py-1.5 text-sm font-medium rounded-lg") do
|
|
168
|
+
render Phlex::TablerIcons::Plus.new(class: "w-4 h-4 mr-1")
|
|
169
|
+
span { "Add #{name.to_s.singularize.humanize}" }
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Concerns
|
|
7
|
+
# Shared sizing + chrome for repeater-style field groups. Both
|
|
8
|
+
# RendersNestedResourceFields and RendersStructuredInputs render the
|
|
9
|
+
# same fieldset/grid markup and share a default row limit, so the
|
|
10
|
+
# values live here to keep the two concerns from drifting apart.
|
|
11
|
+
module RepeaterFieldStyles
|
|
12
|
+
# Default maximum number of rows a repeater renders/clones.
|
|
13
|
+
DEFAULT_LIMIT = 10
|
|
14
|
+
|
|
15
|
+
# Outer fieldset chrome for a single repeater row.
|
|
16
|
+
FIELDSET_CLASS = "nested-resource-form-fields border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] p-4 space-y-4 relative"
|
|
17
|
+
|
|
18
|
+
# Responsive grid the row's fields are laid out in.
|
|
19
|
+
FIELD_GRID_CLASS = "grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-4 grid-flow-row-dense"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -5,6 +5,7 @@ module Plutonium
|
|
|
5
5
|
module Form
|
|
6
6
|
class Resource < Base
|
|
7
7
|
include Plutonium::UI::Form::Concerns::RendersNestedResourceFields
|
|
8
|
+
include Plutonium::UI::Form::Concerns::RendersStructuredInputs
|
|
8
9
|
|
|
9
10
|
attr_reader :resource_fields, :resource_definition, :singular_resource
|
|
10
11
|
|
|
@@ -104,11 +105,18 @@ module Plutonium
|
|
|
104
105
|
end
|
|
105
106
|
|
|
106
107
|
def render_actions
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
108
|
+
# Only carry an *explicit* return_to. We deliberately do NOT fall
|
|
109
|
+
# back to request.original_url: for interactive-action forms that URL
|
|
110
|
+
# is the action's own (modal-only) path, and submitting it back would
|
|
111
|
+
# "return" to a bare standalone form — a blank page. When absent, the
|
|
112
|
+
# controller computes the right destination (redirect_url_after_submit
|
|
113
|
+
# / redirect_url_after_action_on, both → resource_url_for).
|
|
114
|
+
#
|
|
115
|
+
# capture-url grafts the live URL fragment (#tab-id) onto this value
|
|
116
|
+
# on connect (the server never sees fragments), but only when a base
|
|
117
|
+
# value is present.
|
|
110
118
|
input name: "return_to",
|
|
111
|
-
value: request.params[:return_to]
|
|
119
|
+
value: request.params[:return_to],
|
|
112
120
|
type: :hidden,
|
|
113
121
|
hidden: true,
|
|
114
122
|
data: {controller: "capture-url"}
|
|
@@ -164,7 +172,9 @@ module Plutonium
|
|
|
164
172
|
|
|
165
173
|
def render_resource_field(name)
|
|
166
174
|
when_permitted(name) do
|
|
167
|
-
if resource_definition.respond_to?(:
|
|
175
|
+
if resource_definition.respond_to?(:defined_structured_inputs) && resource_definition.defined_structured_inputs[name]
|
|
176
|
+
render_structured_input(name)
|
|
177
|
+
elsif resource_definition.respond_to?(:defined_nested_inputs) && resource_definition.defined_nested_inputs[name]
|
|
168
178
|
render_nested_resource_field(name)
|
|
169
179
|
else
|
|
170
180
|
render_simple_resource_field(name, resource_definition, self)
|
|
@@ -19,7 +19,10 @@ module Plutonium
|
|
|
19
19
|
form_errors_list: "mt-2 list-disc list-inside text-sm",
|
|
20
20
|
|
|
21
21
|
# Label themes
|
|
22
|
-
|
|
22
|
+
# The required marker is a `<abbr title="required">*</abbr>` which
|
|
23
|
+
# picks up the browser's default dotted underline — strip it and
|
|
24
|
+
# color the asterisk as a danger indicator instead.
|
|
25
|
+
label: "mt-2 block mb-2 text-base font-semibold [&_abbr]:no-underline [&_abbr]:border-0 [&_abbr]:cursor-default [&_abbr]:text-danger-500",
|
|
23
26
|
invalid_label: "text-danger-700 dark:text-danger-400",
|
|
24
27
|
valid_label: "text-success-700 dark:text-success-400",
|
|
25
28
|
neutral_label: "text-[var(--pu-text)]",
|
|
@@ -46,8 +49,16 @@ module Plutonium
|
|
|
46
49
|
valid_color: nil,
|
|
47
50
|
neutral_color: nil,
|
|
48
51
|
|
|
49
|
-
# File input
|
|
50
|
-
|
|
52
|
+
# File input — keep pu-input's h-9 so the field matches the height
|
|
53
|
+
# of text inputs and slim-selects. The native file-selector-button
|
|
54
|
+
# fills the *full* height of the control and sits flush against the
|
|
55
|
+
# left edge (px-0 on the wrapper, no vertical margin), reading as a
|
|
56
|
+
# segmented button. A right border divides it from the filename text,
|
|
57
|
+
# and only the left corners are rounded so it nests inside pu-input.
|
|
58
|
+
# NOTE: pu-input ships its px-3/h-9 padding *unlayered*, so plain
|
|
59
|
+
# pl-0/py-0 utilities (in @layer utilities) lose to it — the !
|
|
60
|
+
# important variants are required to flatten the wrapper padding.
|
|
61
|
+
file: "pu-input pl-0! py-0! [&::file-selector-button]:h-full [&::file-selector-button]:align-middle [&::file-selector-button]:m-0 [&::file-selector-button]:mr-4 [&::file-selector-button]:px-4 [&::file-selector-button]:leading-[1.1] [&::file-selector-button]:bg-[var(--pu-surface-alt)] [&::file-selector-button]:border-0 [&::file-selector-button]:border-r [&::file-selector-button]:border-[var(--pu-input-border)] [&::file-selector-button]:rounded-none [&::file-selector-button]:rounded-l-md [&::file-selector-button]:text-sm [&::file-selector-button]:font-semibold [&::file-selector-button]:text-[var(--pu-text-muted)] [&::file-selector-button]:hover:bg-[var(--pu-border)] [&::file-selector-button]:cursor-pointer [&::file-selector-button]:transition-colors",
|
|
51
62
|
|
|
52
63
|
# Hint themes
|
|
53
64
|
hint: "pu-hint whitespace-pre-wrap",
|
|
@@ -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-
|
|
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 " \
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -513,12 +513,17 @@
|
|
|
513
513
|
border-radius: var(--pu-radius-lg);
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
/* Dim + frosted blur for the whole time the dialog is in the top layer.
|
|
517
|
+
Deliberately NOT transitioned and NOT gated on [data-open]: a ::backdrop
|
|
518
|
+
that repaints every frame (a bg-color fade) while carrying a
|
|
519
|
+
backdrop-filter forces the browser to re-rasterise the blur per frame,
|
|
520
|
+
which stutters the panel's open/close animation — and doubly so when a
|
|
521
|
+
nested confirm animates over a parent modal whose blur is still live.
|
|
522
|
+
Keeping it static lets the GPU rasterise the blur once; only the panel
|
|
523
|
+
animates (opacity/scale/translate, cheaply composited). The backdrop
|
|
524
|
+
snaps in at showModal() and is removed when the dialog leaves the top
|
|
525
|
+
layer on close(), so the dim still covers the panel's exit. */
|
|
516
526
|
.pu-dialog::backdrop {
|
|
517
|
-
background-color: transparent;
|
|
518
|
-
transition: background-color 200ms ease-out;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
.pu-dialog[data-open]::backdrop {
|
|
522
527
|
background-color: rgb(0 0 0 / 0.6);
|
|
523
528
|
backdrop-filter: blur(4px);
|
|
524
529
|
}
|
|
@@ -649,6 +654,10 @@ body.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
649
654
|
box-shadow: var(--pu-shadow-lg);
|
|
650
655
|
padding: 6px;
|
|
651
656
|
animation: pu-rail-flyout-in 120ms ease-out;
|
|
657
|
+
/* Cap to the viewport so long menus scroll instead of overflowing. */
|
|
658
|
+
max-height: calc(100vh - 16px);
|
|
659
|
+
overflow-y: auto;
|
|
660
|
+
overscroll-behavior: contain;
|
|
652
661
|
}
|
|
653
662
|
|
|
654
663
|
@keyframes pu-rail-flyout-in {
|
|
@@ -679,3 +688,108 @@ body.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
679
688
|
background: var(--pu-surface-alt);
|
|
680
689
|
color: var(--pu-text);
|
|
681
690
|
}
|
|
691
|
+
|
|
692
|
+
/* ===================
|
|
693
|
+
FLASH — TOASTS & ALERTS
|
|
694
|
+
|
|
695
|
+
Self-contained component classes so flash colors render regardless of the
|
|
696
|
+
host app's Tailwind `content` scan. The partials build their class names
|
|
697
|
+
dynamically (bg-<color>-50, …), which Tailwind can only emit if it scans the
|
|
698
|
+
gem's views — it doesn't under v4's @import + @config source detection. By
|
|
699
|
+
baking the colors into Plutonium's own compiled stylesheet, every flash
|
|
700
|
+
variant works in any consuming app.
|
|
701
|
+
=================== */
|
|
702
|
+
|
|
703
|
+
/* --- Toast (fixed, dismissible banner) --- */
|
|
704
|
+
.pu-toast {
|
|
705
|
+
@apply fixed z-50 top-16 inset-x-0 mx-auto flex items-center w-full max-w-md p-4 rounded-lg shadow text-gray-500;
|
|
706
|
+
}
|
|
707
|
+
.dark .pu-toast {
|
|
708
|
+
@apply bg-gray-800 border border-gray-700 shadow-none;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.pu-toast-icon {
|
|
712
|
+
@apply inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.pu-toast-message {
|
|
716
|
+
@apply ms-3 text-sm font-normal;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.pu-toast-close {
|
|
720
|
+
@apply ms-auto -mx-1.5 -my-1.5 rounded-lg focus:ring-2 p-1.5 inline-flex items-center justify-center h-8 w-8;
|
|
721
|
+
}
|
|
722
|
+
.dark .pu-toast-close {
|
|
723
|
+
@apply bg-gray-800 hover:bg-gray-700;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/* --- Alert (inline, flow banner) --- */
|
|
727
|
+
.pu-alert {
|
|
728
|
+
@apply flex items-center p-4 mb-4 rounded-lg;
|
|
729
|
+
}
|
|
730
|
+
.dark .pu-alert {
|
|
731
|
+
@apply bg-stone-300;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.pu-alert-message {
|
|
735
|
+
@apply ms-3 text-sm font-normal;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
.pu-alert-close {
|
|
739
|
+
@apply ms-auto -mx-1.5 -my-1.5 rounded-lg focus:ring-2 p-1.5 inline-flex items-center justify-center h-8 w-8;
|
|
740
|
+
}
|
|
741
|
+
.dark .pu-alert-close {
|
|
742
|
+
@apply bg-stone-300 hover:bg-gray-700;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/* --- Variants: success / warning / danger / info ---
|
|
746
|
+
Each colors the container plus its icon and close button via descendants,
|
|
747
|
+
so the partial only sets one variant class on the wrapper. --- */
|
|
748
|
+
|
|
749
|
+
/* success */
|
|
750
|
+
.pu-toast-success { @apply bg-success-50; }
|
|
751
|
+
.dark .pu-toast-success { @apply text-success-400; }
|
|
752
|
+
.pu-toast-success .pu-toast-icon { @apply text-success-500 bg-success-100; }
|
|
753
|
+
.dark .pu-toast-success .pu-toast-icon { @apply bg-success-800 text-success-200; }
|
|
754
|
+
.pu-toast-success .pu-toast-close { @apply bg-success-50 text-success-400 hover:bg-success-200 focus:ring-success-400; }
|
|
755
|
+
.dark .pu-toast-success .pu-toast-close { @apply text-success-400; }
|
|
756
|
+
.pu-alert-success { @apply bg-success-50 text-success-800; }
|
|
757
|
+
.dark .pu-alert-success { @apply text-success-400; }
|
|
758
|
+
.pu-alert-success .pu-alert-close { @apply bg-success-50 text-success-500 hover:bg-success-200 focus:ring-success-400; }
|
|
759
|
+
.dark .pu-alert-success .pu-alert-close { @apply text-success-400; }
|
|
760
|
+
|
|
761
|
+
/* warning */
|
|
762
|
+
.pu-toast-warning { @apply bg-warning-50; }
|
|
763
|
+
.dark .pu-toast-warning { @apply text-warning-400; }
|
|
764
|
+
.pu-toast-warning .pu-toast-icon { @apply text-warning-500 bg-warning-100; }
|
|
765
|
+
.dark .pu-toast-warning .pu-toast-icon { @apply bg-warning-800 text-warning-200; }
|
|
766
|
+
.pu-toast-warning .pu-toast-close { @apply bg-warning-50 text-warning-400 hover:bg-warning-200 focus:ring-warning-400; }
|
|
767
|
+
.dark .pu-toast-warning .pu-toast-close { @apply text-warning-400; }
|
|
768
|
+
.pu-alert-warning { @apply bg-warning-50 text-warning-800; }
|
|
769
|
+
.dark .pu-alert-warning { @apply text-warning-400; }
|
|
770
|
+
.pu-alert-warning .pu-alert-close { @apply bg-warning-50 text-warning-500 hover:bg-warning-200 focus:ring-warning-400; }
|
|
771
|
+
.dark .pu-alert-warning .pu-alert-close { @apply text-warning-400; }
|
|
772
|
+
|
|
773
|
+
/* danger */
|
|
774
|
+
.pu-toast-danger { @apply bg-danger-50; }
|
|
775
|
+
.dark .pu-toast-danger { @apply text-danger-400; }
|
|
776
|
+
.pu-toast-danger .pu-toast-icon { @apply text-danger-500 bg-danger-100; }
|
|
777
|
+
.dark .pu-toast-danger .pu-toast-icon { @apply bg-danger-800 text-danger-200; }
|
|
778
|
+
.pu-toast-danger .pu-toast-close { @apply bg-danger-50 text-danger-400 hover:bg-danger-200 focus:ring-danger-400; }
|
|
779
|
+
.dark .pu-toast-danger .pu-toast-close { @apply text-danger-400; }
|
|
780
|
+
.pu-alert-danger { @apply bg-danger-50 text-danger-800; }
|
|
781
|
+
.dark .pu-alert-danger { @apply text-danger-400; }
|
|
782
|
+
.pu-alert-danger .pu-alert-close { @apply bg-danger-50 text-danger-500 hover:bg-danger-200 focus:ring-danger-400; }
|
|
783
|
+
.dark .pu-alert-danger .pu-alert-close { @apply text-danger-400; }
|
|
784
|
+
|
|
785
|
+
/* info (default / :notice) */
|
|
786
|
+
.pu-toast-info { @apply bg-info-50; }
|
|
787
|
+
.dark .pu-toast-info { @apply text-info-400; }
|
|
788
|
+
.pu-toast-info .pu-toast-icon { @apply text-info-500 bg-info-100; }
|
|
789
|
+
.dark .pu-toast-info .pu-toast-icon { @apply bg-info-800 text-info-200; }
|
|
790
|
+
.pu-toast-info .pu-toast-close { @apply bg-info-50 text-info-400 hover:bg-info-200 focus:ring-info-400; }
|
|
791
|
+
.dark .pu-toast-info .pu-toast-close { @apply text-info-400; }
|
|
792
|
+
.pu-alert-info { @apply bg-info-50 text-info-800; }
|
|
793
|
+
.dark .pu-alert-info { @apply text-info-400; }
|
|
794
|
+
.pu-alert-info .pu-alert-close { @apply bg-info-50 text-info-500 hover:bg-info-200 focus:ring-info-400; }
|
|
795
|
+
.dark .pu-alert-info .pu-alert-close { @apply text-info-400; }
|
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus"
|
|
2
2
|
|
|
3
3
|
// Connects to data-controller="capture-url"
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
//
|
|
5
|
+
// The server never sees URL fragments (#tab-id), so a server-rendered
|
|
6
|
+
// `return_to` can't include the one the user is currently on. On connect we
|
|
7
|
+
// graft the live fragment onto this element's EXISTING value — we do not
|
|
8
|
+
// replace the value's path/query.
|
|
9
|
+
//
|
|
10
|
+
// Replacing the whole value (an earlier approach) broke modals: the element
|
|
11
|
+
// already holds the correct return target (e.g. the resource page), while the
|
|
12
|
+
// live browser URL may be the modal/action URL. Overwriting it sent a
|
|
13
|
+
// successful submit "back" to the bare action form — a blank page. Keeping the
|
|
14
|
+
// server value and only contributing the fragment is correct in every case.
|
|
8
15
|
export default class extends Controller {
|
|
9
16
|
connect() {
|
|
10
|
-
if ("value" in this.element)
|
|
11
|
-
|
|
12
|
-
|
|
17
|
+
if (!("value" in this.element)) return
|
|
18
|
+
|
|
19
|
+
const base = this.element.value
|
|
20
|
+
if (!base) return // no explicit return target; let the controller decide
|
|
21
|
+
|
|
22
|
+
const { hash } = window.location
|
|
23
|
+
if (!hash) return // no fragment to recover; keep the server value as-is
|
|
24
|
+
|
|
25
|
+
this.element.value = base.split("#")[0] + hash
|
|
13
26
|
}
|
|
14
27
|
}
|
|
@@ -58,11 +58,20 @@ export default class extends Controller {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
discard() {
|
|
62
62
|
this.forceClose = true;
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
//
|
|
63
|
+
// Snap the confirm shut with no exit animation, then hand straight
|
|
64
|
+
// off to remote-modal so the parent modal animates out as a single,
|
|
65
|
+
// smooth motion.
|
|
66
|
+
//
|
|
67
|
+
// Animating the confirm out *first* (the old behaviour) stuttered:
|
|
68
|
+
// its fade played on top of the parent modal's still-live backdrop
|
|
69
|
+
// `backdrop-filter: blur()`, forcing the compositor to re-rasterise
|
|
70
|
+
// the blurred viewport every frame — and its display:none reflow
|
|
71
|
+
// landed partway through the modal's own close transition. We're
|
|
72
|
+
// tearing the whole modal down anyway, so the confirm doesn't need
|
|
73
|
+
// its own choreography.
|
|
74
|
+
this.#snapConfirmClosed();
|
|
66
75
|
this.dialog.dispatchEvent(new CustomEvent("modal:request-close"));
|
|
67
76
|
}
|
|
68
77
|
|
|
@@ -122,6 +131,10 @@ export default class extends Controller {
|
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
#onCancel(event) {
|
|
134
|
+
// `cancel` bubbles: a descendant's cancel (e.g. an <input type="file">
|
|
135
|
+
// whose picker was dismissed) reaches this listener. Only the dialog's
|
|
136
|
+
// own cancel (Escape) — target === the dialog — should prompt.
|
|
137
|
+
if (event.target !== this.dialog) return;
|
|
125
138
|
if (this.forceClose || this.submitting) return;
|
|
126
139
|
if (!this.#isDirty()) return;
|
|
127
140
|
event.preventDefault();
|
|
@@ -153,6 +166,17 @@ export default class extends Controller {
|
|
|
153
166
|
}
|
|
154
167
|
}
|
|
155
168
|
|
|
169
|
+
// Close the confirm immediately, skipping its exit transition. Used by
|
|
170
|
+
// discard(), where the parent modal is about to animate away and a
|
|
171
|
+
// separate confirm fade would only stutter against the modal's live
|
|
172
|
+
// backdrop blur.
|
|
173
|
+
#snapConfirmClosed() {
|
|
174
|
+
if (!this.hasConfirmDialogTarget) return;
|
|
175
|
+
const d = this.confirmDialogTarget;
|
|
176
|
+
d.removeAttribute("data-open");
|
|
177
|
+
if (d.open) d.close();
|
|
178
|
+
}
|
|
179
|
+
|
|
156
180
|
async #closeConfirm() {
|
|
157
181
|
if (!this.hasConfirmDialogTarget) return;
|
|
158
182
|
const d = this.confirmDialogTarget;
|
|
@@ -115,13 +115,16 @@ export default class extends Controller {
|
|
|
115
115
|
panel.style.left = `${triggerRect.right + 4}px`
|
|
116
116
|
panel.style.top = `${triggerRect.top}px`
|
|
117
117
|
|
|
118
|
-
// Shift up if the panel would overflow the viewport bottom
|
|
118
|
+
// Shift up if the panel would overflow the viewport bottom, but never
|
|
119
|
+
// past the top edge — the inner panel scrolls (max-height) when the
|
|
120
|
+
// menu is taller than the viewport.
|
|
119
121
|
requestAnimationFrame(() => {
|
|
120
122
|
const panelRect = panel.getBoundingClientRect()
|
|
121
123
|
const viewportH = window.innerHeight
|
|
122
124
|
if (panelRect.bottom > viewportH - 8) {
|
|
123
125
|
const overflow = panelRect.bottom - (viewportH - 8)
|
|
124
|
-
|
|
126
|
+
const top = Math.max(8, parseFloat(panel.style.top) - overflow)
|
|
127
|
+
panel.style.top = `${top}px`
|
|
125
128
|
}
|
|
126
129
|
})
|
|
127
130
|
}
|
|
@@ -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)
|
|
@@ -47,6 +47,11 @@ export default class extends Controller {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
#onCancel(event) {
|
|
50
|
+
// `cancel` bubbles, so a descendant firing it — most notably an
|
|
51
|
+
// <input type="file"> whose picker was dismissed — reaches this
|
|
52
|
+
// listener. That is not a request to close the modal; only the
|
|
53
|
+
// dialog's own cancel (Escape) targets the dialog element itself.
|
|
54
|
+
if (event.target !== this.element) return;
|
|
50
55
|
// Another listener (typically dirty-form-guard) already handled
|
|
51
56
|
// this — don't double-process.
|
|
52
57
|
if (event.defaultPrevented) return;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="structured-input-row"
|
|
4
|
+
//
|
|
5
|
+
// Soft-removes a classless structured-input row by DISABLING its fields — a
|
|
6
|
+
// disabled <fieldset> is omitted from form submission, so the server simply
|
|
7
|
+
// receives the payload without that row and rebuilds the JSON column from what
|
|
8
|
+
// it gets (no _destroy marker needed). The row stays in the DOM, collapsed to a
|
|
9
|
+
// "Removed — Restore" bar, so it can be restored (re-enabled) before saving.
|
|
10
|
+
export default class extends Controller {
|
|
11
|
+
static targets = ["content", "removed"]
|
|
12
|
+
|
|
13
|
+
remove(e) {
|
|
14
|
+
e.preventDefault()
|
|
15
|
+
this.contentTarget.disabled = true
|
|
16
|
+
this.contentTarget.hidden = true
|
|
17
|
+
this.removedTarget.hidden = false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
restore(e) {
|
|
21
|
+
e.preventDefault()
|
|
22
|
+
this.contentTarget.disabled = false
|
|
23
|
+
this.contentTarget.hidden = false
|
|
24
|
+
this.removedTarget.hidden = true
|
|
25
|
+
}
|
|
26
|
+
}
|