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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-resource/SKILL.md +6 -4
  3. data/.claude/skills/plutonium-tenancy/SKILL.md +9 -4
  4. data/.claude/skills/plutonium-ui/SKILL.md +29 -5
  5. data/CHANGELOG.md +16 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +257 -11
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +39 -39
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_resource_header.html.erb +2 -1
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/guides/authentication.md +1 -1
  14. data/docs/guides/custom-actions.md +2 -1
  15. data/docs/guides/customizing-ui.md +6 -5
  16. data/docs/guides/multi-tenancy.md +6 -6
  17. data/docs/guides/theming.md +1 -1
  18. data/docs/public/images/components/avatar.png +0 -0
  19. data/docs/reference/auth/accounts.md +1 -1
  20. data/docs/reference/behavior/policies.md +1 -1
  21. data/docs/reference/configuration.md +61 -0
  22. data/docs/reference/resource/actions.md +2 -1
  23. data/docs/reference/resource/definition.md +4 -3
  24. data/docs/reference/tenancy/entity-scoping.md +12 -13
  25. data/docs/reference/ui/components.md +53 -0
  26. data/docs/reference/ui/forms.md +1 -1
  27. data/docs/reference/ui/pages.md +6 -5
  28. data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
  33. data/lib/plutonium/action/base.rb +43 -63
  34. data/lib/plutonium/configuration.rb +7 -0
  35. data/lib/plutonium/definition/actions.rb +10 -11
  36. data/lib/plutonium/definition/base.rb +29 -0
  37. data/lib/plutonium/helpers/assets_helper.rb +0 -30
  38. data/lib/plutonium/helpers/content_helper.rb +0 -44
  39. data/lib/plutonium/helpers/display_helper.rb +0 -62
  40. data/lib/plutonium/helpers/turbo_helper.rb +0 -4
  41. data/lib/plutonium/helpers.rb +0 -2
  42. data/lib/plutonium/resource/definition.rb +0 -42
  43. data/lib/plutonium/ui/action_button.rb +4 -3
  44. data/lib/plutonium/ui/avatar.rb +182 -0
  45. data/lib/plutonium/ui/component/kit.rb +2 -0
  46. data/lib/plutonium/ui/form/base.rb +16 -2
  47. data/lib/plutonium/ui/form/components/secure_association.rb +3 -2
  48. data/lib/plutonium/ui/form/resource.rb +58 -0
  49. data/lib/plutonium/ui/form/theme.rb +7 -3
  50. data/lib/plutonium/ui/grid/card.rb +10 -26
  51. data/lib/plutonium/ui/modal/base.rb +36 -1
  52. data/lib/plutonium/ui/modal/centered.rb +24 -6
  53. data/lib/plutonium/ui/modal/slideover.rb +26 -11
  54. data/lib/plutonium/ui/nav_user.rb +3 -23
  55. data/lib/plutonium/ui/page/edit.rb +6 -3
  56. data/lib/plutonium/ui/page/interactive_action.rb +5 -3
  57. data/lib/plutonium/ui/page/new.rb +6 -3
  58. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
  59. data/lib/plutonium/version.rb +1 -1
  60. data/package.json +1 -1
  61. data/src/css/components.css +38 -1
  62. data/src/css/slim_select.css +3 -2
  63. data/src/js/controllers/dirty_form_guard_controller.js +165 -0
  64. data/src/js/controllers/register_controllers.js +2 -0
  65. data/src/js/controllers/remote_modal_controller.js +53 -19
  66. data/src/js/turbo/index.js +1 -0
  67. data/src/js/turbo/turbo_confirm.js +128 -0
  68. metadata +10 -6
  69. data/lib/plutonium/helpers/attachment_helper.rb +0 -73
  70. data/lib/plutonium/helpers/table_helper.rb +0 -35
  71. /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
- attributes[:id] ||= "resource-form"
153
- attributes["data-controller"] = "form"
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
- action = resource_definition(klass).defined_actions[:new]
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-checkbox",
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
- img(
88
- src: src,
89
- alt: header_text.to_s,
90
- class: "w-12 h-12 rounded-full object-cover bg-[var(--pu-surface-alt)] shrink-0"
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
- def initialize(title: nil, description: nil)
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
- def dialog_classes
10
- "rounded-[var(--pu-radius-lg)] w-full max-w-xl " \
11
- "bg-[var(--pu-surface)] border border-[var(--pu-border)] " \
12
- "backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
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
- "hidden open:flex flex-col p-0 " \
16
- "opacity-0 open:opacity-100 transition-opacity duration-200 ease-in-out"
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
- def dialog_classes
10
- "fixed top-0 right-0 bottom-0 left-auto m-0 h-screen w-full sm:w-[480px] max-w-full max-h-screen " \
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-black/60 backdrop:backdrop-blur-sm " \
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
- "hidden open:flex flex-col " \
15
- "translate-x-full open:translate-x-0 " \
16
- "transition-[transform,display,overlay] duration-300 ease-out " \
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
- img(class: "w-8 h-8 rounded-full ring-2 ring-[var(--pu-border)]", src: @avatar_url, alt: "avatar")
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.modal == :centered) ?
27
- Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
28
- render modal_class.new(title: page_title, description: page_description) do
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 = (current_interactive_action.modal == :slideover) ?
22
- Plutonium::UI::Modal::Slideover : Plutonium::UI::Modal::Centered
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.modal == :centered) ?
27
- Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
28
- render modal_class.new(title: page_title, description: page_description) do
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
@@ -68,7 +68,7 @@ module Plutonium
68
68
  bulk_actions_target: "actionButton",
69
69
  bulk_action_name: action.name,
70
70
  bulk_action_url: url,
71
- turbo_frame: action.turbo_frame
71
+ turbo_frame: action.turbo_frame(current_definition)
72
72
  }
73
73
  ) do
74
74
  if action.icon
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.52.0"
2
+ VERSION = "0.53.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0