plutonium 0.52.0 → 0.53.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-resource/SKILL.md +6 -4
- data/.claude/skills/plutonium-tenancy/SKILL.md +9 -4
- data/.claude/skills/plutonium-ui/SKILL.md +29 -5
- data/CHANGELOG.md +16 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +257 -11
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +39 -39
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +2 -1
- data/docs/.vitepress/config.ts +1 -0
- data/docs/guides/authentication.md +1 -1
- data/docs/guides/custom-actions.md +2 -1
- data/docs/guides/customizing-ui.md +6 -5
- data/docs/guides/multi-tenancy.md +6 -6
- data/docs/guides/theming.md +1 -1
- data/docs/public/images/components/avatar.png +0 -0
- data/docs/reference/auth/accounts.md +1 -1
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/configuration.md +61 -0
- data/docs/reference/resource/actions.md +2 -1
- data/docs/reference/resource/definition.md +4 -3
- data/docs/reference/tenancy/entity-scoping.md +12 -13
- data/docs/reference/ui/components.md +53 -0
- data/docs/reference/ui/forms.md +1 -1
- data/docs/reference/ui/pages.md +6 -5
- data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
- data/lib/plutonium/action/base.rb +43 -63
- data/lib/plutonium/configuration.rb +7 -0
- data/lib/plutonium/definition/actions.rb +10 -11
- data/lib/plutonium/definition/base.rb +29 -0
- data/lib/plutonium/helpers/assets_helper.rb +0 -30
- data/lib/plutonium/helpers/content_helper.rb +0 -44
- data/lib/plutonium/helpers/display_helper.rb +0 -62
- data/lib/plutonium/helpers/turbo_helper.rb +0 -4
- data/lib/plutonium/helpers.rb +0 -2
- data/lib/plutonium/resource/definition.rb +0 -42
- data/lib/plutonium/ui/action_button.rb +4 -3
- data/lib/plutonium/ui/avatar.rb +182 -0
- data/lib/plutonium/ui/component/kit.rb +2 -0
- data/lib/plutonium/ui/form/base.rb +16 -2
- data/lib/plutonium/ui/form/components/secure_association.rb +3 -2
- data/lib/plutonium/ui/form/resource.rb +58 -0
- data/lib/plutonium/ui/form/theme.rb +7 -3
- data/lib/plutonium/ui/grid/card.rb +10 -26
- data/lib/plutonium/ui/modal/base.rb +36 -1
- data/lib/plutonium/ui/modal/centered.rb +24 -6
- data/lib/plutonium/ui/modal/slideover.rb +26 -11
- data/lib/plutonium/ui/nav_user.rb +3 -23
- data/lib/plutonium/ui/page/edit.rb +6 -3
- data/lib/plutonium/ui/page/interactive_action.rb +5 -3
- data/lib/plutonium/ui/page/new.rb +6 -3
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +38 -1
- data/src/css/slim_select.css +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +165 -0
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +53 -19
- data/src/js/turbo/index.js +1 -0
- data/src/js/turbo/turbo_confirm.js +128 -0
- metadata +10 -6
- data/lib/plutonium/helpers/attachment_helper.rb +0 -73
- data/lib/plutonium/helpers/table_helper.rb +0 -35
- /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "cgi"
|
|
5
|
+
|
|
6
|
+
module Plutonium
|
|
7
|
+
module UI
|
|
8
|
+
# Renders a profile/avatar image for a subject.
|
|
9
|
+
#
|
|
10
|
+
# Avatar(user) # Navii fallback seeded from the record
|
|
11
|
+
# Avatar(user, src: :photo) # user.photo if present, else Navii fallback
|
|
12
|
+
# Avatar(user, src: user.photo) # pass the attachment/uploader/URL directly
|
|
13
|
+
# Avatar("acme-team") # a String subject is a deterministic seed
|
|
14
|
+
# Avatar("https://.../p.png") # a URL-shaped subject is shown as the image
|
|
15
|
+
# Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
|
|
16
|
+
#
|
|
17
|
+
# The positional +subject+ is the identity the fallback is derived from: a
|
|
18
|
+
# record or a String, hashed to an opaque, PII-free seed. As a convenience, a
|
|
19
|
+
# URL-shaped String subject is treated as +src+ (the image) instead.
|
|
20
|
+
# +src+ is the image to show and may be:
|
|
21
|
+
# - a Symbol naming a method on the subject (e.g. +:avatar+ -> +subject.avatar+).
|
|
22
|
+
# This is a contract: the subject must respond to it (raises NoMethodError
|
|
23
|
+
# otherwise), so only use a Symbol +src+ with a record subject.
|
|
24
|
+
# - an ActiveStorage attachment, active_shrine/Shrine uploader, or URL String
|
|
25
|
+
#
|
|
26
|
+
# Resolution order: the resolved +src+, then a Navii avatar seeded from the
|
|
27
|
+
# subject, then a generic user icon when there is nothing to show.
|
|
28
|
+
class Avatar < Plutonium::UI::Component::Base
|
|
29
|
+
# Pixel dimensions per semantic size, plus the matching Tailwind width/height
|
|
30
|
+
# utilities (needed because the preflight resets `img { height: auto }`, so
|
|
31
|
+
# width/height attributes alone don't pin the rendered size).
|
|
32
|
+
SIZES = {xs: 24, sm: 32, md: 40, lg: 48, xl: 64}.freeze
|
|
33
|
+
SIZE_CLASSES = {xs: "w-6 h-6", sm: "w-8 h-8", md: "w-10 h-10", lg: "w-12 h-12", xl: "w-16 h-16"}.freeze
|
|
34
|
+
|
|
35
|
+
# Resolve an image value to a URL string. Supports:
|
|
36
|
+
# - ActiveStorage attachments -> helpers.url_for (they aren't routable via #url)
|
|
37
|
+
# - active_shrine / other ActiveStorage-style wrappers -> value.url
|
|
38
|
+
# - Bare Shrine::UploadedFile, CarrierWave, etc. (respond to :url) -> value.url
|
|
39
|
+
# - Plain URL strings ("https://..." or "/uploads/...")
|
|
40
|
+
#
|
|
41
|
+
# Exposed as a module method so collaborators (e.g. Grid::Card) can reuse
|
|
42
|
+
# the resolution without instantiating the component.
|
|
43
|
+
def self.resolve_image_src(value, helpers = nil)
|
|
44
|
+
return nil if value.nil?
|
|
45
|
+
|
|
46
|
+
# ActiveStorage is the only supported source that must go through Rails
|
|
47
|
+
# routing rather than its own #url. It has to be matched *before* the
|
|
48
|
+
# generic attached?/url checks, because ActiveStorage-compatible wrappers
|
|
49
|
+
# (e.g. active_shrine) respond to BOTH attached? and url, and those should
|
|
50
|
+
# resolve via their own #url instead.
|
|
51
|
+
if active_storage_attachment?(value)
|
|
52
|
+
return value.attached? ? helpers&.url_for(value) : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if value.respond_to?(:attached?) # active_shrine & other AS-style wrappers
|
|
56
|
+
value.attached? ? value.url : nil
|
|
57
|
+
elsif value.respond_to?(:url) # bare Shrine::UploadedFile, CarrierWave, ...
|
|
58
|
+
value.url
|
|
59
|
+
elsif value.is_a?(String) && value.start_with?("http", "/")
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
rescue ArgumentError, URI::InvalidURIError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.active_storage_attachment?(value)
|
|
67
|
+
defined?(ActiveStorage::Attached) && value.is_a?(ActiveStorage::Attached)
|
|
68
|
+
end
|
|
69
|
+
private_class_method :active_storage_attachment?
|
|
70
|
+
|
|
71
|
+
def initialize(subject = nil, src: nil, size: :md, alt: nil, **attributes)
|
|
72
|
+
# A URL-shaped positional subject is really an image, not an identity:
|
|
73
|
+
# route it to src so Avatar("https://…/p.png") shows the image rather
|
|
74
|
+
# than hashing the URL into a seed.
|
|
75
|
+
if src.nil? && subject.is_a?(String) && subject.start_with?("http", "/")
|
|
76
|
+
src = subject
|
|
77
|
+
subject = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@subject = subject
|
|
81
|
+
@src = src
|
|
82
|
+
@size = size
|
|
83
|
+
@alt = alt
|
|
84
|
+
@attributes = attributes
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def view_template
|
|
88
|
+
url = resolved_src || navii_url
|
|
89
|
+
|
|
90
|
+
if url
|
|
91
|
+
img(
|
|
92
|
+
src: url, alt: alt_text.to_s, width: pixel_size, height: pixel_size, loading: "lazy",
|
|
93
|
+
**sized_attributes("rounded-full object-cover bg-[var(--pu-surface-alt)] shrink-0")
|
|
94
|
+
)
|
|
95
|
+
else
|
|
96
|
+
div(**sized_attributes("rounded-full bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)] flex items-center justify-center shrink-0")) do
|
|
97
|
+
render Phlex::TablerIcons::User.new(class: "w-2/3 h-2/3")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Merge the component's base classes, the size class, and the caller's class;
|
|
105
|
+
# add an inline dimension style for raw-pixel (Integer) sizes.
|
|
106
|
+
def sized_attributes(base)
|
|
107
|
+
attrs = @attributes.dup
|
|
108
|
+
attrs[:class] = tokens(base, size_class, attrs.delete(:class))
|
|
109
|
+
attrs[:style] = [size_style, attrs[:style]].compact.join("; ") if size_style
|
|
110
|
+
attrs
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def pixel_size
|
|
114
|
+
@size.is_a?(Symbol) ? SIZES.fetch(@size) : @size
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def size_class
|
|
118
|
+
@size.is_a?(Symbol) ? SIZE_CLASSES.fetch(@size) : nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def size_style
|
|
122
|
+
"width: #{@size}px; height: #{@size}px" unless @size.is_a?(Symbol)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolved_src
|
|
126
|
+
value = image_src_value
|
|
127
|
+
return nil if value.nil?
|
|
128
|
+
|
|
129
|
+
# Only reach for the Rails helper proxy when we have an attachment-style
|
|
130
|
+
# source (ActiveStorage needs helpers.url_for; the resolver ignores it
|
|
131
|
+
# for active_shrine and other #url-bearing sources).
|
|
132
|
+
resolver_helpers = value.respond_to?(:attached?) ? helpers : nil
|
|
133
|
+
self.class.resolve_image_src(value, resolver_helpers)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A Symbol src names a method on the subject (e.g. :avatar -> subject.avatar);
|
|
137
|
+
# anything else is the attachment/uploader/URL itself.
|
|
138
|
+
def image_src_value
|
|
139
|
+
@src.is_a?(Symbol) ? @subject&.public_send(@src) : @src
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def navii_url
|
|
143
|
+
seed = navii_seed
|
|
144
|
+
return nil unless seed
|
|
145
|
+
|
|
146
|
+
host = Plutonium.configuration.navii_host_url
|
|
147
|
+
"#{host}/avatar/#{CGI.escape(seed)}?size=#{pixel_size}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The value sent to Navii is ALWAYS a hash of the subject's identity, so no
|
|
151
|
+
# plaintext (model names, ids, emails, or caller-provided seed strings) ever
|
|
152
|
+
# reaches the external service. Determinism is preserved: same identity ->
|
|
153
|
+
# same hash -> same avatar.
|
|
154
|
+
def navii_seed
|
|
155
|
+
identity = subject_identity
|
|
156
|
+
return nil unless identity
|
|
157
|
+
|
|
158
|
+
Digest::SHA256.hexdigest(identity)[0, 16]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Stable identity string for the subject: a String subject verbatim, or
|
|
162
|
+
# "Class:id" for a record. Hashed by #navii_seed before it leaves the app.
|
|
163
|
+
def subject_identity
|
|
164
|
+
case @subject
|
|
165
|
+
when nil then nil
|
|
166
|
+
when String then @subject
|
|
167
|
+
else "#{@subject.class.name}:#{@subject.id}" if @subject.respond_to?(:id) && @subject.id.present?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def alt_text
|
|
172
|
+
return @alt if @alt
|
|
173
|
+
|
|
174
|
+
case @subject
|
|
175
|
+
when nil then nil
|
|
176
|
+
when String then @subject
|
|
177
|
+
else helpers&.display_name_of(@subject)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -50,6 +50,8 @@ module Plutonium
|
|
|
50
50
|
self.class.method_defined?(build_method) || super
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def BuildAvatar(...) = Plutonium::UI::Avatar.new(...)
|
|
54
|
+
|
|
53
55
|
def BuildBreadcrumbs(...) = Plutonium::UI::Breadcrumbs.new(...)
|
|
54
56
|
|
|
55
57
|
def BuildSkeletonTable(...) = Plutonium::UI::SkeletonTable.new(...)
|
|
@@ -149,8 +149,22 @@ module Plutonium
|
|
|
149
149
|
def initialize_attributes
|
|
150
150
|
super
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
attributes[
|
|
152
|
+
# Only fall back to :resource_form when the caller didn't already
|
|
153
|
+
# name the form. Phlexi moves an explicit `attributes[:id]` onto
|
|
154
|
+
# `@dom_id` before this runs, so a blind `||=` here would clobber
|
|
155
|
+
# things like the filter slideover's `id: "filter-form"` —
|
|
156
|
+
# producing two `<form id="resource-form">` on the page and
|
|
157
|
+
# silently breaking the modal pre_submit re-render (Turbo's
|
|
158
|
+
# `getElementById` finds the filter form first).
|
|
159
|
+
attributes[:id] ||= "resource-form" if @dom_id.nil?
|
|
160
|
+
attributes["data-controller"] = form_data_controller
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# `dirty-form-guard` is attached unconditionally — it self-disables
|
|
164
|
+
# outside a <dialog>. Branching on `in_modal?` here would fail:
|
|
165
|
+
# Phlex forbids view-context access before rendering begins.
|
|
166
|
+
def form_data_controller
|
|
167
|
+
"form dirty-form-guard"
|
|
154
168
|
end
|
|
155
169
|
|
|
156
170
|
# Scope the form id to the current turbo frame at render time (we
|
|
@@ -70,12 +70,13 @@ module Plutonium
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
return unless registered_resources.include?(klass)
|
|
73
|
-
|
|
73
|
+
target_definition = resource_definition(klass)
|
|
74
|
+
action = target_definition.defined_actions[:new]
|
|
74
75
|
return unless action
|
|
75
76
|
return unless @skip_authorization || action.permitted_by?(policy_for(record: klass))
|
|
76
77
|
|
|
77
78
|
url = route_options_to_url(action.route_options, klass)
|
|
78
|
-
[with_return_to(url), action.turbo_frame]
|
|
79
|
+
[with_return_to(url), action.turbo_frame(target_definition)]
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
def with_return_to(url)
|
|
@@ -31,12 +31,70 @@ module Plutonium
|
|
|
31
31
|
render_actions
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Mirrors Phlexi::Form::Base#view_template (phlexi-form ~> 0.14)
|
|
35
|
+
# — keep these in sync if upgrading. We override so the guard
|
|
36
|
+
# dialog renders inside the <form> tag (where the JS controller
|
|
37
|
+
# looks for it via `dirty-form-guard-target`) even when a
|
|
38
|
+
# subclass overrides `form_template`. Without this, the
|
|
39
|
+
# controller silently falls back to `window.confirm`.
|
|
40
|
+
def view_template(&block)
|
|
41
|
+
captured_body = capture { form_template(&block) }
|
|
42
|
+
captured_guard = capture { render_dirty_form_guard_dialog if in_modal? }
|
|
43
|
+
form_tag do
|
|
44
|
+
form_errors
|
|
45
|
+
raw(safe(captured_body))
|
|
46
|
+
raw(safe(captured_guard))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
def form_class
|
|
35
51
|
in_modal? ? "flex-1 flex flex-col min-h-0" : super
|
|
36
52
|
end
|
|
37
53
|
|
|
38
54
|
private
|
|
39
55
|
|
|
56
|
+
# Nested inside the form so showModal() stacks it in the browser's
|
|
57
|
+
# top layer above the surrounding slideover/centered modal — no
|
|
58
|
+
# z-index juggling required.
|
|
59
|
+
def render_dirty_form_guard_dialog
|
|
60
|
+
dialog(
|
|
61
|
+
class:
|
|
62
|
+
"pu-dialog " \
|
|
63
|
+
"top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
|
|
64
|
+
"w-full max-w-md p-0 " \
|
|
65
|
+
"open:flex flex-col " \
|
|
66
|
+
"opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
|
|
67
|
+
"transition-[opacity,transform] duration-200 ease-out",
|
|
68
|
+
data: {"dirty-form-guard-target": "confirmDialog"},
|
|
69
|
+
# Modern Chrome refuses user-agent close requests (Esc, backdrop);
|
|
70
|
+
# older browsers fall back to the JS controller's interception.
|
|
71
|
+
closedby: "none",
|
|
72
|
+
"aria-labelledby": "pu-dirty-guard-title",
|
|
73
|
+
"aria-describedby": "pu-dirty-guard-desc"
|
|
74
|
+
) do
|
|
75
|
+
div(class: "px-6 pt-5 pb-4 border-b border-[var(--pu-border)]") do
|
|
76
|
+
h2(id: "pu-dirty-guard-title", class: "text-lg font-semibold text-[var(--pu-text)]") do
|
|
77
|
+
"Discard changes?"
|
|
78
|
+
end
|
|
79
|
+
p(id: "pu-dirty-guard-desc", class: "mt-1 text-sm text-[var(--pu-text-muted)]") do
|
|
80
|
+
"You have unsaved changes. Closing this form now will lose them."
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
div(class: "flex items-center justify-end gap-2 px-6 py-4") do
|
|
84
|
+
button(
|
|
85
|
+
type: "button",
|
|
86
|
+
class: "pu-btn pu-btn-md pu-btn-outline",
|
|
87
|
+
data: {action: "dirty-form-guard#keepEditing"}
|
|
88
|
+
) { "Keep editing" }
|
|
89
|
+
button(
|
|
90
|
+
type: "button",
|
|
91
|
+
class: "pu-btn pu-btn-md pu-btn-danger",
|
|
92
|
+
data: {action: "dirty-form-guard#discard"}
|
|
93
|
+
) { "Discard changes" }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
40
98
|
def render_fields
|
|
41
99
|
fields_wrapper {
|
|
42
100
|
resource_fields.each { |name|
|
|
@@ -30,11 +30,15 @@ module Plutonium
|
|
|
30
30
|
valid_input: "pu-input pu-input-valid",
|
|
31
31
|
neutral_input: "",
|
|
32
32
|
|
|
33
|
-
# Checkbox
|
|
33
|
+
# Checkbox / Boolean
|
|
34
34
|
checkbox: "pu-checkbox",
|
|
35
|
+
boolean: "pu-checkbox",
|
|
36
|
+
valid_boolean: "pu-checkbox",
|
|
37
|
+
invalid_boolean: "pu-checkbox pu-input-invalid",
|
|
35
38
|
|
|
36
39
|
# Radio buttons
|
|
37
|
-
radio_button: "pu-
|
|
40
|
+
radio_button: "pu-radio",
|
|
41
|
+
collection_radio_buttons: "flex flex-col gap-2",
|
|
38
42
|
|
|
39
43
|
# Color
|
|
40
44
|
color: "pu-color-input appearance-none bg-transparent border-none cursor-pointer w-12 h-12 rounded-lg",
|
|
@@ -46,7 +50,7 @@ module Plutonium
|
|
|
46
50
|
file: "pu-input py-2 [&::file-selector-button]:mr-4 [&::file-selector-button]:px-4 [&::file-selector-button]:py-2 [&::file-selector-button]:bg-[var(--pu-surface-alt)] [&::file-selector-button]:border-0 [&::file-selector-button]:rounded-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",
|
|
47
51
|
|
|
48
52
|
# Hint themes
|
|
49
|
-
hint: "pu-hint whitespace-pre",
|
|
53
|
+
hint: "pu-hint whitespace-pre-wrap",
|
|
50
54
|
|
|
51
55
|
# Error themes
|
|
52
56
|
error: "text-xs text-danger-600 mt-1",
|
|
@@ -75,20 +75,21 @@ module Plutonium
|
|
|
75
75
|
|
|
76
76
|
def render_image_slot(size:)
|
|
77
77
|
value = field_value(slots[:image])
|
|
78
|
-
return unless value
|
|
79
|
-
src = image_src_for(value)
|
|
80
|
-
return unless src
|
|
81
78
|
|
|
82
79
|
if size == :cover
|
|
80
|
+
# Cover is a full-width banner, not an avatar: only render when an
|
|
81
|
+
# actual image resolves (no deterministic fallback).
|
|
82
|
+
src = Plutonium::UI::Avatar.resolve_image_src(value, helpers)
|
|
83
|
+
return unless src
|
|
84
|
+
|
|
83
85
|
div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
|
|
84
86
|
img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
|
|
85
87
|
end
|
|
86
88
|
else
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
89
|
+
# Small avatar slot: render the resolved image, or Avatar's generic
|
|
90
|
+
# icon fallback. No subject is passed, so image-less cards fall back
|
|
91
|
+
# to the local icon rather than a per-card request to Navii.
|
|
92
|
+
Avatar(src: value, size: :lg, alt: header_text.to_s)
|
|
92
93
|
end
|
|
93
94
|
end
|
|
94
95
|
|
|
@@ -163,7 +164,7 @@ module Plutonium
|
|
|
163
164
|
url = route_options_to_url(show.route_options, record)
|
|
164
165
|
a(
|
|
165
166
|
href: url,
|
|
166
|
-
data: {row_click_target: "show", turbo_frame: show.turbo_frame},
|
|
167
|
+
data: {row_click_target: "show", turbo_frame: show.turbo_frame(resource_definition)},
|
|
167
168
|
class: "sr-only",
|
|
168
169
|
tabindex: "-1",
|
|
169
170
|
"aria-label": "Open #{header_text}"
|
|
@@ -192,23 +193,6 @@ module Plutonium
|
|
|
192
193
|
record.public_send(name)
|
|
193
194
|
end
|
|
194
195
|
|
|
195
|
-
# Resolves a slot value to an image URL. Supports:
|
|
196
|
-
# - ActiveStorage attachments (`record.avatar` -> Attached::One/Many)
|
|
197
|
-
# - Shrine uploaders (`record.avatar` -> UploadedFile, responds to :url)
|
|
198
|
-
# - Plain URL strings ("https://..." or "/uploads/...")
|
|
199
|
-
def image_src_for(value)
|
|
200
|
-
return nil if value.nil?
|
|
201
|
-
if value.respond_to?(:attached?)
|
|
202
|
-
value.attached? ? helpers.url_for(value) : nil
|
|
203
|
-
elsif value.respond_to?(:url)
|
|
204
|
-
value.url
|
|
205
|
-
elsif value.is_a?(String) && value.start_with?("http", "/")
|
|
206
|
-
value
|
|
207
|
-
end
|
|
208
|
-
rescue ArgumentError, URI::InvalidURIError
|
|
209
|
-
nil
|
|
210
|
-
end
|
|
211
|
-
|
|
212
196
|
def row_actions
|
|
213
197
|
@row_actions ||= resource_definition.defined_actions.values.select { |a|
|
|
214
198
|
a.collection_record_action? && a.permitted_by?(record_policy)
|
|
@@ -9,9 +9,26 @@ module Plutonium
|
|
|
9
9
|
slot :close
|
|
10
10
|
slot :footer
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
# Sizes that all modal subclasses must implement entries for in
|
|
13
|
+
# their SIZE_CLASSES table. `:auto` is content-driven (`w-fit`
|
|
14
|
+
# with a viewport cap and a sensible floor) and is the only way
|
|
15
|
+
# to avoid clipping forms whose natural width exceeds the
|
|
16
|
+
# default. Sizes intentionally mirror Tailwind's max-w-* scale
|
|
17
|
+
# so a definition that says `size: :xl` reads predictably.
|
|
18
|
+
VALID_SIZES = [:sm, :md, :lg, :xl, :auto, :full].freeze
|
|
19
|
+
|
|
20
|
+
# Resolves the concrete modal class for a definition's `modal_mode`
|
|
21
|
+
# symbol. Unknown / `false` modes fall back to `Slideover` so call
|
|
22
|
+
# sites can stay branchless.
|
|
23
|
+
def self.class_for_mode(mode)
|
|
24
|
+
(mode == :centered) ? Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(title: nil, description: nil, size: :md)
|
|
13
28
|
@title = title
|
|
14
29
|
@description = description
|
|
30
|
+
@size = size
|
|
31
|
+
validate_size!
|
|
15
32
|
end
|
|
16
33
|
|
|
17
34
|
def view_template(&block)
|
|
@@ -54,9 +71,27 @@ module Plutonium
|
|
|
54
71
|
end
|
|
55
72
|
|
|
56
73
|
def dialog_classes
|
|
74
|
+
"#{base_dialog_classes} #{size_classes}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Positioning, backdrop, transitions — everything that does
|
|
78
|
+
# not vary with `size`. Width/height tokens live in
|
|
79
|
+
# `size_classes` so size keys can fully replace them
|
|
80
|
+
# (notably `:auto`, which needs `w-fit` instead of `w-full`).
|
|
81
|
+
def base_dialog_classes
|
|
57
82
|
raise NotImplementedError
|
|
58
83
|
end
|
|
59
84
|
|
|
85
|
+
def size_classes
|
|
86
|
+
self.class::SIZE_CLASSES.fetch(@size)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_size!
|
|
90
|
+
return if VALID_SIZES.include?(@size)
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"modal size must be one of #{VALID_SIZES.inspect}, got #{@size.inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
60
95
|
def inner_classes
|
|
61
96
|
"flex flex-col h-full max-h-[inherit] min-h-0"
|
|
62
97
|
end
|
|
@@ -4,16 +4,34 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Modal
|
|
6
6
|
class Centered < Plutonium::UI::Modal::Base
|
|
7
|
+
# Width tokens for each VALID_SIZES key. `:md` reproduces the
|
|
8
|
+
# historical default (`w-full max-w-xl`); `:auto` drops `w-full`
|
|
9
|
+
# so the dialog hugs its content, with a floor that keeps tiny
|
|
10
|
+
# confirm dialogs from collapsing and a cap to stay on-screen.
|
|
11
|
+
SIZE_CLASSES = {
|
|
12
|
+
sm: "w-full max-w-md",
|
|
13
|
+
md: "w-full max-w-xl",
|
|
14
|
+
lg: "w-full max-w-2xl",
|
|
15
|
+
xl: "w-full max-w-4xl",
|
|
16
|
+
auto: "w-fit max-w-[90vw] min-w-[400px]",
|
|
17
|
+
full: "w-full max-w-[95vw]"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
7
20
|
protected
|
|
8
21
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
22
|
+
# Surface (bg, border, radius, backdrop) lives in `.pu-dialog` so
|
|
23
|
+
# the centered modal, dirty-form-guard prompt, and Turbo confirm
|
|
24
|
+
# can't drift on design tokens. The remaining utilities are
|
|
25
|
+
# positioning, sizing, and the open/close transform animation —
|
|
26
|
+
# driven by [data-open] (set on the frame after showModal() by
|
|
27
|
+
# remote_modal_controller); avoids the @starting-style spec dance.
|
|
28
|
+
def base_dialog_classes
|
|
29
|
+
"pu-dialog " \
|
|
13
30
|
"top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
|
|
14
31
|
"max-h-[80vh] " \
|
|
15
|
-
"
|
|
16
|
-
"opacity-0 open:opacity-100
|
|
32
|
+
"open:flex flex-col p-0 " \
|
|
33
|
+
"opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
|
|
34
|
+
"transition-[opacity,transform] duration-200 ease-out"
|
|
17
35
|
end
|
|
18
36
|
end
|
|
19
37
|
end
|
|
@@ -4,21 +4,36 @@ module Plutonium
|
|
|
4
4
|
module UI
|
|
5
5
|
module Modal
|
|
6
6
|
class Slideover < Plutonium::UI::Modal::Base
|
|
7
|
+
# Width tokens for each VALID_SIZES key. Mobile always takes
|
|
8
|
+
# full width (the slideover is pinned to the right edge, so
|
|
9
|
+
# anything narrower than the viewport looks awkward on phones);
|
|
10
|
+
# the `sm:` token controls the desktop width. `:md` reproduces
|
|
11
|
+
# the historical default. `:auto` switches to `sm:w-auto` with
|
|
12
|
+
# a viewport cap so the panel grows to fit the form.
|
|
13
|
+
SIZE_CLASSES = {
|
|
14
|
+
sm: "w-full sm:w-[400px]",
|
|
15
|
+
md: "w-full sm:w-[480px]",
|
|
16
|
+
lg: "w-full sm:w-[640px]",
|
|
17
|
+
xl: "w-full sm:w-[800px]",
|
|
18
|
+
auto: "w-full sm:w-auto sm:max-w-[90vw] sm:min-w-[480px]",
|
|
19
|
+
full: "w-full sm:w-[95vw]"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
7
22
|
protected
|
|
8
23
|
|
|
9
|
-
|
|
10
|
-
|
|
24
|
+
# Animation is driven by `data-open`, toggled by the remote-modal
|
|
25
|
+
# controller on the frame after showModal(). Mirrors the filter
|
|
26
|
+
# slideover's pattern — see Centered for the same rationale.
|
|
27
|
+
def base_dialog_classes
|
|
28
|
+
"fixed top-0 right-0 bottom-0 left-auto m-0 h-screen max-w-full max-h-screen " \
|
|
11
29
|
"bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
|
|
12
|
-
"backdrop:bg-
|
|
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 " \
|
|
13
33
|
"rounded-none p-0 " \
|
|
14
|
-
"
|
|
15
|
-
"translate-x-full open:translate-x-0 " \
|
|
16
|
-
"transition-
|
|
17
|
-
"[transition-behavior:allow-discrete] " \
|
|
18
|
-
"starting:open:translate-x-full " \
|
|
19
|
-
"backdrop:transition-[display,overlay,background-color] backdrop:duration-300 " \
|
|
20
|
-
"backdrop:[transition-behavior:allow-discrete] " \
|
|
21
|
-
"starting:open:backdrop:bg-transparent"
|
|
34
|
+
"open:flex flex-col " \
|
|
35
|
+
"translate-x-full data-[open]:translate-x-0 " \
|
|
36
|
+
"transition-transform duration-300 ease-out"
|
|
22
37
|
end
|
|
23
38
|
end
|
|
24
39
|
end
|
|
@@ -52,10 +52,11 @@ module Plutonium
|
|
|
52
52
|
|
|
53
53
|
slot :section, Section, collection: true
|
|
54
54
|
|
|
55
|
-
def initialize(email:, name: nil, avatar_url: nil)
|
|
55
|
+
def initialize(email:, name: nil, avatar_url: nil, record: nil)
|
|
56
56
|
@email = email
|
|
57
57
|
@name = name
|
|
58
58
|
@avatar_url = avatar_url
|
|
59
|
+
@record = record
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def view_template
|
|
@@ -68,14 +69,6 @@ module Plutonium
|
|
|
68
69
|
private
|
|
69
70
|
|
|
70
71
|
def render_trigger_button
|
|
71
|
-
if @avatar_url.present?
|
|
72
|
-
render_avatar_button
|
|
73
|
-
else
|
|
74
|
-
render_default_button
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def render_avatar_button
|
|
79
72
|
button(
|
|
80
73
|
type: "button",
|
|
81
74
|
class: "flex mx-3 text-sm rounded-full md:mr-0 focus:ring-2 focus:ring-[var(--pu-border)] focus:ring-offset-2 transition-shadow",
|
|
@@ -84,20 +77,7 @@ module Plutonium
|
|
|
84
77
|
data: {resource_drop_down_target: "trigger"}
|
|
85
78
|
) do
|
|
86
79
|
span(class: "sr-only") { "Open user menu" }
|
|
87
|
-
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def render_default_button
|
|
92
|
-
button(
|
|
93
|
-
type: "button",
|
|
94
|
-
class: "flex mx-3 p-1 text-sm border border-[var(--pu-border)] text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-full md:mr-0 focus:ring-2 focus:ring-[var(--pu-border)] focus:ring-offset-2 transition-colors",
|
|
95
|
-
aria: {expanded: "false"},
|
|
96
|
-
id: "user-nav-dropdown-toggle",
|
|
97
|
-
data: {resource_drop_down_target: "trigger"}
|
|
98
|
-
) do
|
|
99
|
-
span(class: "sr-only") { "Open user menu" }
|
|
100
|
-
render Phlex::TablerIcons::User.new(class: "w-6 h-6")
|
|
80
|
+
Avatar(@record, src: @avatar_url, size: :sm, alt: "avatar", class: "ring-2 ring-[var(--pu-border)]")
|
|
101
81
|
end
|
|
102
82
|
end
|
|
103
83
|
|
|
@@ -23,9 +23,12 @@ module Plutonium
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def render_modal_form
|
|
26
|
-
modal_class = (current_definition.
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
modal_class = Plutonium::UI::Modal::Base.class_for_mode(current_definition.modal_mode)
|
|
27
|
+
render modal_class.new(
|
|
28
|
+
title: page_title,
|
|
29
|
+
description: page_description,
|
|
30
|
+
size: current_definition.modal_size
|
|
31
|
+
) do
|
|
29
32
|
render partial("resource_form")
|
|
30
33
|
end
|
|
31
34
|
end
|
|
@@ -18,12 +18,14 @@ module Plutonium
|
|
|
18
18
|
|
|
19
19
|
def render_default_content
|
|
20
20
|
if in_modal?
|
|
21
|
-
modal_class = (
|
|
22
|
-
|
|
21
|
+
modal_class = Plutonium::UI::Modal::Base.class_for_mode(
|
|
22
|
+
current_interactive_action.modal_mode(current_definition)
|
|
23
|
+
)
|
|
23
24
|
|
|
24
25
|
render modal_class.new(
|
|
25
26
|
title: page_title,
|
|
26
|
-
description: page_description
|
|
27
|
+
description: page_description,
|
|
28
|
+
size: current_interactive_action.modal_size(current_definition)
|
|
27
29
|
) do
|
|
28
30
|
render partial("interactive_action_form")
|
|
29
31
|
end
|
|
@@ -23,9 +23,12 @@ module Plutonium
|
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def render_modal_form
|
|
26
|
-
modal_class = (current_definition.
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
modal_class = Plutonium::UI::Modal::Base.class_for_mode(current_definition.modal_mode)
|
|
27
|
+
render modal_class.new(
|
|
28
|
+
title: page_title,
|
|
29
|
+
description: page_description,
|
|
30
|
+
size: current_definition.modal_size
|
|
31
|
+
) do
|
|
29
32
|
render partial("resource_form")
|
|
30
33
|
end
|
|
31
34
|
end
|
data/lib/plutonium/version.rb
CHANGED