plutonium 0.53.1 → 0.54.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/CHANGELOG.md +24 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +22 -6
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +8 -8
- data/app/assets/plutonium.min.js.map +3 -3
- data/app/views/plutonium/_flash_alerts.html.erb +8 -17
- data/app/views/plutonium/_flash_toasts.html.erb +9 -18
- data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +178 -0
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/plutonium/engine/validator.rb +11 -4
- data/lib/plutonium/ui/form/resource.rb +11 -4
- data/lib/plutonium/ui/form/theme.rb +14 -3
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +109 -0
- 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/remote_modal_controller.js +5 -0
- metadata +4 -3
|
@@ -3,25 +3,16 @@
|
|
|
3
3
|
<%
|
|
4
4
|
next unless msg.present?
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
# bg-yellow-50 text-yellow-800 text-yellow-500 text-yellow-400 ring-yellow-400
|
|
12
|
-
"yellow"
|
|
13
|
-
when :alert, :danger, :error
|
|
14
|
-
# bg-red-50 text-red-800 text-red-500 text-red-400 ring-red-400
|
|
15
|
-
"red"
|
|
16
|
-
else
|
|
17
|
-
# bg-blue-50 text-blue-800 text-blue-500 text-blue-400 ring-blue-400
|
|
18
|
-
"blue"
|
|
19
|
-
end
|
|
6
|
+
variant = {
|
|
7
|
+
success: "success",
|
|
8
|
+
warning: "warning",
|
|
9
|
+
alert: "danger", danger: "danger", error: "danger"
|
|
10
|
+
}.fetch(type.to_sym, "info")
|
|
20
11
|
%>
|
|
21
12
|
|
|
22
13
|
<div data-controller="resource-dismiss"
|
|
23
14
|
data-resource-dismiss-after-value="6000"
|
|
24
|
-
class="
|
|
15
|
+
class="pu-alert pu-alert-<%= variant %>"
|
|
25
16
|
role="alert">
|
|
26
17
|
<% case type.to_sym %>
|
|
27
18
|
<% when :success %>
|
|
@@ -41,9 +32,9 @@
|
|
|
41
32
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.147 15.085a7.159 7.159 0 0 1-6.189 3.307A6.713 6.713 0 0 1 3.1 15.444c-2.679-4.513.287-8.737.888-9.548A4.373 4.373 0 0 0 5 1.608c1.287.953 6.445 3.218 5.537 10.5 1.5-1.122 2.706-3.01 2.853-6.14 1.433 1.049 3.993 5.395 1.757 9.117Z"/>
|
|
42
33
|
</svg>
|
|
43
34
|
<% end %>
|
|
44
|
-
<div class="
|
|
35
|
+
<div class="pu-alert-message"><%= msg %></div>
|
|
45
36
|
<button type="button"
|
|
46
|
-
class="
|
|
37
|
+
class="pu-alert-close"
|
|
47
38
|
data-action="click->resource-dismiss#dismiss"
|
|
48
39
|
aria-label="Close">
|
|
49
40
|
<span class="sr-only">Close</span>
|
|
@@ -2,28 +2,19 @@
|
|
|
2
2
|
<%
|
|
3
3
|
next unless msg.present?
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
# text-yellow-500 bg-yellow-100 bg-yellow-800 text-yellow-200 bg-yellow-50 text-yellow-400 ring-yellow-400
|
|
11
|
-
"yellow"
|
|
12
|
-
when :alert, :danger, :error
|
|
13
|
-
# text-red-500 bg-red-100 bg-red-800 text-red-200 bg-red-50 text-red-400 ring-red-400
|
|
14
|
-
"red"
|
|
15
|
-
else
|
|
16
|
-
# text-blue-500 bg-blue-100 bg-blue-800 text-blue-200 bg-blue-50 text-blue-400 ring-blue-400
|
|
17
|
-
"blue"
|
|
18
|
-
end
|
|
5
|
+
variant = {
|
|
6
|
+
success: "success",
|
|
7
|
+
warning: "warning",
|
|
8
|
+
alert: "danger", danger: "danger", error: "danger"
|
|
9
|
+
}.fetch(type.to_sym, "info")
|
|
19
10
|
%>
|
|
20
11
|
|
|
21
12
|
<div data-controller="resource-dismiss"
|
|
22
13
|
data-resource-dismiss-after-value="6000"
|
|
23
|
-
class="
|
|
14
|
+
class="pu-toast pu-toast-<%= variant %>"
|
|
24
15
|
role="alert">
|
|
25
16
|
|
|
26
|
-
<div class="
|
|
17
|
+
<div class="pu-toast-icon">
|
|
27
18
|
<% case type.to_sym %>
|
|
28
19
|
<% when :success %>
|
|
29
20
|
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20">
|
|
@@ -43,9 +34,9 @@
|
|
|
43
34
|
</svg>
|
|
44
35
|
<% end %>
|
|
45
36
|
</div>
|
|
46
|
-
<div class="
|
|
37
|
+
<div class="pu-toast-message"><%= msg %></div>
|
|
47
38
|
<button type="button"
|
|
48
|
-
class="
|
|
39
|
+
class="pu-toast-close"
|
|
49
40
|
data-action="click->resource-dismiss#dismiss"
|
|
50
41
|
aria-label="Close">
|
|
51
42
|
<span class="sr-only">Close</span>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Interaction Repeater Inputs
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-06-01
|
|
4
|
+
**Status:** Design — pending review
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
Interactions (`Plutonium::Resource::Interaction`) collect scalar inputs via
|
|
9
|
+
`attribute`, but have no first-class way to collect a **repeating group of
|
|
10
|
+
fields** — e.g. a variable-length list of `{label, phone_number}` contacts —
|
|
11
|
+
as structured input the interaction can validate and use in `execute`.
|
|
12
|
+
|
|
13
|
+
The repeater UX already exists for resource forms (the
|
|
14
|
+
`nested-resource-form-fields` Stimulus controller: add/remove rows, `<template>`
|
|
15
|
+
cloning). But it is **model-backed**: the renderer
|
|
16
|
+
(`Plutonium::UI::Form::Concerns::RendersNestedResourceFields` →
|
|
17
|
+
`NestedFieldContext`) sources its row metadata (`:class`, `:macro`, `:limit`,
|
|
18
|
+
multiplicity) from `resource_class.all_nested_attributes_options`, which only
|
|
19
|
+
reflects ActiveRecord associations on the acted-on resource.
|
|
20
|
+
|
|
21
|
+
For an interaction collecting a classless list, there is no association and no
|
|
22
|
+
class. The current behaviour (characterized in
|
|
23
|
+
`test/plutonium/ui/form/interaction_nested_input_test.rb`):
|
|
24
|
+
|
|
25
|
+
- `nested_input` is registered on interactions and param coercion works, **but**
|
|
26
|
+
- `nested_attribute_options` resolves to `{}`, so `blank_object` is `nil` →
|
|
27
|
+
`nest_one`/`nest_many` have no template object → the nested UI renders nothing.
|
|
28
|
+
- The one case that *does* work is when the interaction's nested attribute
|
|
29
|
+
happens to mirror a real association on the acted-on resource (the README's
|
|
30
|
+
`CreateUserInteraction` building a `User` with `has_many :contacts`).
|
|
31
|
+
|
|
32
|
+
So interactions have the *param* half of nested inputs but no working *rendering*
|
|
33
|
+
half for the classless case, and the failure is silent.
|
|
34
|
+
|
|
35
|
+
## Goal
|
|
36
|
+
|
|
37
|
+
Let an interaction declare a **classless repeating field group** that:
|
|
38
|
+
|
|
39
|
+
- renders with the existing repeater UX (add/remove rows, template cloning,
|
|
40
|
+
delete checkbox),
|
|
41
|
+
- collects into the interaction as an **array of plain hashes**
|
|
42
|
+
(`contacts => [{label:, phone_number:}, …]`),
|
|
43
|
+
- needs **no backing class** — just a fields definition.
|
|
44
|
+
|
|
45
|
+
Non-goals (explicitly out of scope):
|
|
46
|
+
|
|
47
|
+
- A single (non-repeater) variant. Repeater only; value is always an array.
|
|
48
|
+
- Type coercion of row values. Rows are plain hashes; the interaction validates
|
|
49
|
+
them itself.
|
|
50
|
+
- Reusing/extending model-backed `nested_input` semantics for the classless case.
|
|
51
|
+
|
|
52
|
+
## Feasibility anchor
|
|
53
|
+
|
|
54
|
+
Phlexi already supports hash-backed rendering. `Phlexi::Field::Support::Value.from`:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
return object[key] if object.is_a?(Hash)
|
|
58
|
+
object.public_send(key) if object.respond_to?(key)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
So a row can be a plain `Hash`, and the blank/template row can be `{}` (every
|
|
62
|
+
field reads `nil` → empty). No synthesized classes are required.
|
|
63
|
+
|
|
64
|
+
## Design
|
|
65
|
+
|
|
66
|
+
### 1. DSL — `repeater`
|
|
67
|
+
|
|
68
|
+
A new, self-contained DSL on the interaction base. One call declares everything:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class CreateUserInteraction < Plutonium::Resource::Interaction
|
|
72
|
+
attribute :first_name, :string
|
|
73
|
+
|
|
74
|
+
repeater :contacts do |f|
|
|
75
|
+
f.input :label
|
|
76
|
+
f.input :phone_number
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# or, reusing an existing fields definition:
|
|
80
|
+
# repeater :addresses, using: AddressFields, fields: %i[label map_url]
|
|
81
|
+
|
|
82
|
+
# options: limit: (default 10)
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`repeater :name` will:
|
|
87
|
+
|
|
88
|
+
1. declare `attribute :name` defaulting to `[]`,
|
|
89
|
+
2. register a **classless** `name_attributes=` collector (see §2),
|
|
90
|
+
3. register render config (the fields definition, `multiple: true`, `limit`).
|
|
91
|
+
|
|
92
|
+
A distinct name (not `nested_input`) is intentional: it signals different
|
|
93
|
+
semantics (classless, collects hashes) and keeps a clean, conditional-free
|
|
94
|
+
implementation path separate from model-backed `nested_input`.
|
|
95
|
+
|
|
96
|
+
### 2. Param handling
|
|
97
|
+
|
|
98
|
+
`name_attributes=` accepts what the form submits — either an `Array` of row
|
|
99
|
+
hashes or a `Hash` keyed by index (`{"0" => {...}, "1" => {...}}`, Rails' nested
|
|
100
|
+
form shape). For each row:
|
|
101
|
+
|
|
102
|
+
- skip if `_destroy` is truthy (`1`/`"1"`/`true`/`"true"`),
|
|
103
|
+
- skip if every value is blank (the empty trailing/blank rows),
|
|
104
|
+
- otherwise keep the row as a symbolized hash with `_destroy` removed.
|
|
105
|
+
|
|
106
|
+
Store the result as `name` = `Array<Hash>`. The `name` reader returns that array
|
|
107
|
+
(used both by `execute` and by the renderer to repopulate on re-render).
|
|
108
|
+
|
|
109
|
+
### 3. Rendering
|
|
110
|
+
|
|
111
|
+
Reuse the existing repeater chrome. Add a **classless render path** that does not
|
|
112
|
+
touch `all_nested_attributes_options`:
|
|
113
|
+
|
|
114
|
+
- config (fields, `multiple: true`, `limit`) comes from the `repeater`
|
|
115
|
+
declaration,
|
|
116
|
+
- the template/blank row object is `{}`,
|
|
117
|
+
- existing rows come from the array of hashes already on the attribute (so a
|
|
118
|
+
validation-failed re-render repopulates),
|
|
119
|
+
- field naming via `nest_many(:contacts, as: :contacts_attributes, …)` →
|
|
120
|
+
`interaction[contacts_attributes][N][label]`.
|
|
121
|
+
|
|
122
|
+
The resource (model-backed) render path is left untouched and stays covered by
|
|
123
|
+
the existing characterization tests
|
|
124
|
+
(`test/integration/admin_portal/nested_form_rendering_test.rb`).
|
|
125
|
+
|
|
126
|
+
> Implementation detail deferred to the plan: whether the classless path is a
|
|
127
|
+
> sibling context to `NestedFieldContext` or a generalization of it, and how the
|
|
128
|
+
> `repeater` fields block maps onto the existing `NestedInputsDefinition`. The
|
|
129
|
+
> design constraint is only that the resource path does not change behaviour.
|
|
130
|
+
|
|
131
|
+
### 4. `nested_input` raises when no class is resolvable
|
|
132
|
+
|
|
133
|
+
Convert the silent classless failure into a guiding error. When the nested-field
|
|
134
|
+
renderer cannot resolve a class to build rows (no `object_class`, and no `:class`
|
|
135
|
+
from association metadata), raise:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
`nested_input :contacts` could not resolve a class to build its rows.
|
|
139
|
+
If this is an interaction collecting plain inputs, use `repeater :contacts` instead.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
This keeps the legitimate model-backed `nested_input` working (class present →
|
|
143
|
+
renders) and fails loudly only where it is actually broken. It also guards
|
|
144
|
+
genuinely misconfigured resource nested inputs.
|
|
145
|
+
|
|
146
|
+
### 5. `accepts_nested_attributes_for` is unchanged
|
|
147
|
+
|
|
148
|
+
It stays available on interactions for the deliberate case of building real
|
|
149
|
+
model instances in `execute`. `repeater` does not use or replace it.
|
|
150
|
+
|
|
151
|
+
### 6. Documentation
|
|
152
|
+
|
|
153
|
+
Update the interaction README: document `repeater` for classless repeating
|
|
154
|
+
input; note that `nested_input` is model-backed and now errors without a
|
|
155
|
+
resolvable class.
|
|
156
|
+
|
|
157
|
+
## Testing
|
|
158
|
+
|
|
159
|
+
- **Param round-trip** (unit): `contacts_attributes=` with an `Array` and with a
|
|
160
|
+
Rails index-keyed `Hash` → `contacts` is an array of symbolized hashes;
|
|
161
|
+
`_destroy` and all-blank rows dropped.
|
|
162
|
+
- **Render** (integration): a dummy interaction with a `repeater`, wired to an
|
|
163
|
+
interactive action, GET its form → assert the repeater HTML — controller
|
|
164
|
+
container + limit, `<template>`, fieldset, `interaction[contacts_attributes]
|
|
165
|
+
[NEW_RECORD][label]` naming, add button, delete checkbox. Fixture built via
|
|
166
|
+
the `pu:*` generators per project convention.
|
|
167
|
+
- **Guard** (unit/integration): a classless `nested_input` on an interaction
|
|
168
|
+
raises the guiding error. Update the existing characterization test
|
|
169
|
+
(`interaction_nested_input_test.rb`) from "blank_object is nil" to "raises".
|
|
170
|
+
- **Regression**: resource nested fields unchanged — existing characterization
|
|
171
|
+
tests stay green.
|
|
172
|
+
|
|
173
|
+
## Risk / breaking change
|
|
174
|
+
|
|
175
|
+
Making `nested_input` raise without a resolvable class is a (small) breaking
|
|
176
|
+
change for any interaction relying on the silently-broken classless path — but
|
|
177
|
+
that path renders nothing today, so there is no working behaviour to break. The
|
|
178
|
+
model-backed path is preserved.
|
|
@@ -13,16 +13,23 @@ module Plutonium
|
|
|
13
13
|
def validate_engine!(engine)
|
|
14
14
|
return if supported_engine?(engine)
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
raise ArgumentError,
|
|
17
|
+
"#{engine} must include Plutonium::Engine to register resources. " \
|
|
18
|
+
"See https://radioactive-labs.github.io/plutonium-core/reference/app/packages " \
|
|
19
|
+
"for how to make an engine Plutonium-aware."
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
# Checks if the current engine supports Plutonium features.
|
|
21
23
|
#
|
|
22
24
|
# @return [Boolean] True if the engine includes Plutonium::Engine, false otherwise.
|
|
23
25
|
def supported_engine?(engine)
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
+
# Match by module name rather than object identity. In development the
|
|
27
|
+
# framework is reloaded, so `Plutonium::Engine` is reassigned to a
|
|
28
|
+
# fresh module object while already-loaded engines still include the
|
|
29
|
+
# previous one — making an `include?(Plutonium::Engine)` identity check
|
|
30
|
+
# spuriously false. A module's name survives the reassignment, so this
|
|
31
|
+
# stays correct in both development and production without a branch.
|
|
32
|
+
engine.ancestors.any? { |ancestor| ancestor.name == "Plutonium::Engine" }
|
|
26
33
|
end
|
|
27
34
|
end
|
|
28
35
|
end
|
|
@@ -104,11 +104,18 @@ module Plutonium
|
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
def render_actions
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
107
|
+
# Only carry an *explicit* return_to. We deliberately do NOT fall
|
|
108
|
+
# back to request.original_url: for interactive-action forms that URL
|
|
109
|
+
# is the action's own (modal-only) path, and submitting it back would
|
|
110
|
+
# "return" to a bare standalone form — a blank page. When absent, the
|
|
111
|
+
# controller computes the right destination (redirect_url_after_submit
|
|
112
|
+
# / redirect_url_after_action_on, both → resource_url_for).
|
|
113
|
+
#
|
|
114
|
+
# capture-url grafts the live URL fragment (#tab-id) onto this value
|
|
115
|
+
# on connect (the server never sees fragments), but only when a base
|
|
116
|
+
# value is present.
|
|
110
117
|
input name: "return_to",
|
|
111
|
-
value: request.params[:return_to]
|
|
118
|
+
value: request.params[:return_to],
|
|
112
119
|
type: :hidden,
|
|
113
120
|
hidden: true,
|
|
114
121
|
data: {controller: "capture-url"}
|
|
@@ -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",
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -649,6 +649,10 @@ body.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
649
649
|
box-shadow: var(--pu-shadow-lg);
|
|
650
650
|
padding: 6px;
|
|
651
651
|
animation: pu-rail-flyout-in 120ms ease-out;
|
|
652
|
+
/* Cap to the viewport so long menus scroll instead of overflowing. */
|
|
653
|
+
max-height: calc(100vh - 16px);
|
|
654
|
+
overflow-y: auto;
|
|
655
|
+
overscroll-behavior: contain;
|
|
652
656
|
}
|
|
653
657
|
|
|
654
658
|
@keyframes pu-rail-flyout-in {
|
|
@@ -679,3 +683,108 @@ body.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
679
683
|
background: var(--pu-surface-alt);
|
|
680
684
|
color: var(--pu-text);
|
|
681
685
|
}
|
|
686
|
+
|
|
687
|
+
/* ===================
|
|
688
|
+
FLASH — TOASTS & ALERTS
|
|
689
|
+
|
|
690
|
+
Self-contained component classes so flash colors render regardless of the
|
|
691
|
+
host app's Tailwind `content` scan. The partials build their class names
|
|
692
|
+
dynamically (bg-<color>-50, …), which Tailwind can only emit if it scans the
|
|
693
|
+
gem's views — it doesn't under v4's @import + @config source detection. By
|
|
694
|
+
baking the colors into Plutonium's own compiled stylesheet, every flash
|
|
695
|
+
variant works in any consuming app.
|
|
696
|
+
=================== */
|
|
697
|
+
|
|
698
|
+
/* --- Toast (fixed, dismissible banner) --- */
|
|
699
|
+
.pu-toast {
|
|
700
|
+
@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;
|
|
701
|
+
}
|
|
702
|
+
.dark .pu-toast {
|
|
703
|
+
@apply bg-gray-800 border border-gray-700 shadow-none;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.pu-toast-icon {
|
|
707
|
+
@apply inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
.pu-toast-message {
|
|
711
|
+
@apply ms-3 text-sm font-normal;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.pu-toast-close {
|
|
715
|
+
@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;
|
|
716
|
+
}
|
|
717
|
+
.dark .pu-toast-close {
|
|
718
|
+
@apply bg-gray-800 hover:bg-gray-700;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/* --- Alert (inline, flow banner) --- */
|
|
722
|
+
.pu-alert {
|
|
723
|
+
@apply flex items-center p-4 mb-4 rounded-lg;
|
|
724
|
+
}
|
|
725
|
+
.dark .pu-alert {
|
|
726
|
+
@apply bg-stone-300;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.pu-alert-message {
|
|
730
|
+
@apply ms-3 text-sm font-normal;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.pu-alert-close {
|
|
734
|
+
@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;
|
|
735
|
+
}
|
|
736
|
+
.dark .pu-alert-close {
|
|
737
|
+
@apply bg-stone-300 hover:bg-gray-700;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/* --- Variants: success / warning / danger / info ---
|
|
741
|
+
Each colors the container plus its icon and close button via descendants,
|
|
742
|
+
so the partial only sets one variant class on the wrapper. --- */
|
|
743
|
+
|
|
744
|
+
/* success */
|
|
745
|
+
.pu-toast-success { @apply bg-success-50; }
|
|
746
|
+
.dark .pu-toast-success { @apply text-success-400; }
|
|
747
|
+
.pu-toast-success .pu-toast-icon { @apply text-success-500 bg-success-100; }
|
|
748
|
+
.dark .pu-toast-success .pu-toast-icon { @apply bg-success-800 text-success-200; }
|
|
749
|
+
.pu-toast-success .pu-toast-close { @apply bg-success-50 text-success-400 hover:bg-success-200 focus:ring-success-400; }
|
|
750
|
+
.dark .pu-toast-success .pu-toast-close { @apply text-success-400; }
|
|
751
|
+
.pu-alert-success { @apply bg-success-50 text-success-800; }
|
|
752
|
+
.dark .pu-alert-success { @apply text-success-400; }
|
|
753
|
+
.pu-alert-success .pu-alert-close { @apply bg-success-50 text-success-500 hover:bg-success-200 focus:ring-success-400; }
|
|
754
|
+
.dark .pu-alert-success .pu-alert-close { @apply text-success-400; }
|
|
755
|
+
|
|
756
|
+
/* warning */
|
|
757
|
+
.pu-toast-warning { @apply bg-warning-50; }
|
|
758
|
+
.dark .pu-toast-warning { @apply text-warning-400; }
|
|
759
|
+
.pu-toast-warning .pu-toast-icon { @apply text-warning-500 bg-warning-100; }
|
|
760
|
+
.dark .pu-toast-warning .pu-toast-icon { @apply bg-warning-800 text-warning-200; }
|
|
761
|
+
.pu-toast-warning .pu-toast-close { @apply bg-warning-50 text-warning-400 hover:bg-warning-200 focus:ring-warning-400; }
|
|
762
|
+
.dark .pu-toast-warning .pu-toast-close { @apply text-warning-400; }
|
|
763
|
+
.pu-alert-warning { @apply bg-warning-50 text-warning-800; }
|
|
764
|
+
.dark .pu-alert-warning { @apply text-warning-400; }
|
|
765
|
+
.pu-alert-warning .pu-alert-close { @apply bg-warning-50 text-warning-500 hover:bg-warning-200 focus:ring-warning-400; }
|
|
766
|
+
.dark .pu-alert-warning .pu-alert-close { @apply text-warning-400; }
|
|
767
|
+
|
|
768
|
+
/* danger */
|
|
769
|
+
.pu-toast-danger { @apply bg-danger-50; }
|
|
770
|
+
.dark .pu-toast-danger { @apply text-danger-400; }
|
|
771
|
+
.pu-toast-danger .pu-toast-icon { @apply text-danger-500 bg-danger-100; }
|
|
772
|
+
.dark .pu-toast-danger .pu-toast-icon { @apply bg-danger-800 text-danger-200; }
|
|
773
|
+
.pu-toast-danger .pu-toast-close { @apply bg-danger-50 text-danger-400 hover:bg-danger-200 focus:ring-danger-400; }
|
|
774
|
+
.dark .pu-toast-danger .pu-toast-close { @apply text-danger-400; }
|
|
775
|
+
.pu-alert-danger { @apply bg-danger-50 text-danger-800; }
|
|
776
|
+
.dark .pu-alert-danger { @apply text-danger-400; }
|
|
777
|
+
.pu-alert-danger .pu-alert-close { @apply bg-danger-50 text-danger-500 hover:bg-danger-200 focus:ring-danger-400; }
|
|
778
|
+
.dark .pu-alert-danger .pu-alert-close { @apply text-danger-400; }
|
|
779
|
+
|
|
780
|
+
/* info (default / :notice) */
|
|
781
|
+
.pu-toast-info { @apply bg-info-50; }
|
|
782
|
+
.dark .pu-toast-info { @apply text-info-400; }
|
|
783
|
+
.pu-toast-info .pu-toast-icon { @apply text-info-500 bg-info-100; }
|
|
784
|
+
.dark .pu-toast-info .pu-toast-icon { @apply bg-info-800 text-info-200; }
|
|
785
|
+
.pu-toast-info .pu-toast-close { @apply bg-info-50 text-info-400 hover:bg-info-200 focus:ring-info-400; }
|
|
786
|
+
.dark .pu-toast-info .pu-toast-close { @apply text-info-400; }
|
|
787
|
+
.pu-alert-info { @apply bg-info-50 text-info-800; }
|
|
788
|
+
.dark .pu-alert-info { @apply text-info-400; }
|
|
789
|
+
.pu-alert-info .pu-alert-close { @apply bg-info-50 text-info-500 hover:bg-info-200 focus:ring-info-400; }
|
|
790
|
+
.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
|
}
|
|
@@ -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;
|