plutonium 0.49.1 → 0.50.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-definition/SKILL.md +87 -2
  3. data/.claude/skills/plutonium-installation/SKILL.md +6 -0
  4. data/.claude/skills/plutonium-views/SKILL.md +59 -0
  5. data/CHANGELOG.md +12 -0
  6. data/app/assets/plutonium.css +2 -2
  7. data/app/assets/plutonium.js +369 -25
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +45 -45
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/app/views/plutonium/_resource_header.html.erb +4 -4
  12. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  13. data/app/views/resource/_resource_grid.html.erb +1 -0
  14. data/config/brakeman.ignore +25 -2
  15. data/docs/reference/definition/actions.md +14 -1
  16. data/docs/reference/definition/index.md +58 -0
  17. data/docs/reference/views/index.md +43 -0
  18. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  19. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  20. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  23. data/lib/generators/pu/core/update/update_generator.rb +20 -0
  24. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  25. data/lib/plutonium/action/base.rb +44 -1
  26. data/lib/plutonium/action/interactive.rb +1 -1
  27. data/lib/plutonium/configuration.rb +4 -0
  28. data/lib/plutonium/definition/actions.rb +3 -0
  29. data/lib/plutonium/definition/base.rb +8 -0
  30. data/lib/plutonium/definition/metadata.rb +40 -0
  31. data/lib/plutonium/definition/views.rb +94 -0
  32. data/lib/plutonium/helpers/turbo_helper.rb +1 -1
  33. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  34. data/lib/plutonium/query/base.rb +8 -0
  35. data/lib/plutonium/query/filters/association.rb +30 -8
  36. data/lib/plutonium/query/filters/boolean.rb +5 -0
  37. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  38. data/lib/plutonium/resource/definition.rb +42 -0
  39. data/lib/plutonium/resource/query_object.rb +64 -6
  40. data/lib/plutonium/testing/resource_definition.rb +2 -2
  41. data/lib/plutonium/ui/action_button.rb +4 -2
  42. data/lib/plutonium/ui/component/kit.rb +12 -0
  43. data/lib/plutonium/ui/display/base.rb +3 -1
  44. data/lib/plutonium/ui/display/resource.rb +109 -25
  45. data/lib/plutonium/ui/display/theme.rb +2 -1
  46. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  47. data/lib/plutonium/ui/empty_card.rb +1 -1
  48. data/lib/plutonium/ui/form/base.rb +29 -1
  49. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  50. data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
  51. data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
  52. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  53. data/lib/plutonium/ui/form/resource.rb +48 -9
  54. data/lib/plutonium/ui/form/theme.rb +1 -1
  55. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  56. data/lib/plutonium/ui/grid/card.rb +235 -0
  57. data/lib/plutonium/ui/grid/resource.rb +149 -0
  58. data/lib/plutonium/ui/layout/base.rb +37 -1
  59. data/lib/plutonium/ui/layout/header.rb +1 -2
  60. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  61. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  62. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  63. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  64. data/lib/plutonium/ui/modal/base.rb +109 -0
  65. data/lib/plutonium/ui/modal/centered.rb +21 -0
  66. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  67. data/lib/plutonium/ui/page/base.rb +25 -6
  68. data/lib/plutonium/ui/page/edit.rb +13 -1
  69. data/lib/plutonium/ui/page/index.rb +40 -1
  70. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  71. data/lib/plutonium/ui/page/new.rb +13 -1
  72. data/lib/plutonium/ui/page/show.rb +8 -1
  73. data/lib/plutonium/ui/page_header.rb +8 -13
  74. data/lib/plutonium/ui/panel.rb +10 -19
  75. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  76. data/lib/plutonium/ui/tab_list.rb +29 -7
  77. data/lib/plutonium/ui/table/base.rb +106 -0
  78. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  79. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  80. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  81. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  82. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  83. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  84. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  85. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  86. data/lib/plutonium/ui/table/resource.rb +158 -89
  87. data/lib/plutonium/ui/table/theme.rb +14 -5
  88. data/lib/plutonium/version.rb +1 -1
  89. data/lib/plutonium.rb +6 -0
  90. data/package.json +1 -1
  91. data/src/css/components.css +304 -131
  92. data/src/css/tokens.css +101 -85
  93. data/src/js/controllers/autosubmit_controller.js +24 -0
  94. data/src/js/controllers/bulk_actions_controller.js +15 -16
  95. data/src/js/controllers/capture_url_controller.js +14 -0
  96. data/src/js/controllers/filter_panel_controller.js +77 -19
  97. data/src/js/controllers/frame_navigator_controller.js +34 -6
  98. data/src/js/controllers/icon_rail_controller.js +22 -0
  99. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  100. data/src/js/controllers/register_controllers.js +16 -0
  101. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  102. data/src/js/controllers/row_click_controller.js +21 -0
  103. data/src/js/controllers/table_column_menu_controller.js +43 -0
  104. data/src/js/controllers/table_header_controller.js +16 -0
  105. data/src/js/controllers/view_switcher_controller.js +29 -0
  106. metadata +31 -3
@@ -8,7 +8,8 @@ module Plutonium
8
8
  super.merge({
9
9
  base: "",
10
10
  value_wrapper: "max-h-[300px] overflow-y-auto",
11
- fields_wrapper: "p-8 grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-8 grid-flow-row-dense",
11
+ fields_wrapper: "pu-card",
12
+ fields_inner: "pu-card-body grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
12
13
 
13
14
  # Labels and descriptions
14
15
  label: "text-sm font-semibold uppercase tracking-wide text-[var(--pu-text-muted)] mb-2",
@@ -1,29 +1,23 @@
1
1
  module Plutonium
2
2
  module UI
3
3
  module DynaFrame
4
+ # Conditionally wraps its content in a turbo-frame matching the inbound
5
+ # request's `Turbo-Frame` header. In frame mode adds the flash partial
6
+ # so toast/alert messages still surface inside frames; in non-frame
7
+ # mode renders the block as-is.
4
8
  class Content < Plutonium::UI::Component::Base
5
9
  include Phlex::Rails::Helpers::TurboFrameTag
6
10
 
7
- def initialize(content = nil)
8
- @content = content
9
- end
10
-
11
- def view_template
11
+ def view_template(&block)
12
12
  if current_turbo_frame.present?
13
- # Frame request: render only the turbo-frame with content
14
13
  turbo_frame_tag(current_turbo_frame) do
15
14
  render partial("flash")
16
- @content&.call
15
+ yield if block_given?
17
16
  end
18
- else
19
- # Regular request: yield self so caller can call frame.render_content
20
- yield(self)
17
+ elsif block_given?
18
+ yield
21
19
  end
22
20
  end
23
-
24
- def render_content
25
- @content&.call
26
- end
27
21
  end
28
22
  end
29
23
  end
@@ -8,7 +8,7 @@ module Plutonium
8
8
  end
9
9
 
10
10
  def view_template
11
- div(class: "pu-card") do
11
+ div(class: "pu-card mt-4") do
12
12
  div(class: "pu-empty-state") do
13
13
  p(class: "pu-empty-state-description") { message }
14
14
  yield if block_given?
@@ -10,6 +10,28 @@ module Plutonium
10
10
  include Phlexi::Field::Common::Tokens
11
11
  include Plutonium::UI::Form::Options::InferredTypes
12
12
 
13
+ # Consume `:as` here so it doesn't land in Phlexi's `@options` —
14
+ # `:as` is a Plutonium-internal concept (it picks the tag method),
15
+ # not a Phlexi field option.
16
+ def initialize(*args, as: nil, **kwargs, &block)
17
+ @as = as
18
+ super(*args, **kwargs, &block)
19
+ end
20
+
21
+ attr_reader :as
22
+
23
+ def hidden?
24
+ as.to_s == "hidden"
25
+ end
26
+
27
+ # Hidden fields (`form.field(name, as: :hidden)`) skip the label /
28
+ # hint / error chrome and render inside a `<div hidden>` so they're
29
+ # also excluded from CSS Grid / Flex layout.
30
+ def wrapped(**, &)
31
+ return Plutonium::UI::Form::Components::HiddenWrapper.new(self, &) if hidden?
32
+ super
33
+ end
34
+
13
35
  def textarea_tag(**attributes, &)
14
36
  attributes[:data_controller] = tokens(attributes[:data_controller], "textarea-autogrow")
15
37
  super
@@ -45,7 +67,13 @@ module Plutonium
45
67
  end
46
68
 
47
69
  def resource_select_tag(**attributes, &)
48
- create_component(Components::ResourceSelect, :select, **attributes, &)
70
+ attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
71
+ # class!: "" clears the underlying <select>'s themed classes
72
+ # (pu-input etc.) — the visible element is slim-select's
73
+ # generated .ss-main, so leaving Tailwind input chrome on the
74
+ # native select can leak into chip layout (e.g. forcing
75
+ # flex-direction: column or w-full on multi-mode chips).
76
+ create_component(Components::ResourceSelect, :select, class!: "", **attributes, &)
49
77
  end
50
78
 
51
79
  def secure_association_tag(**attributes, &)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Wrapper for fields configured as `as: :hidden`. Emits a hidden div
8
+ # containing only the input — no label, no hint, no error chrome.
9
+ # `hidden: true` (HTML5) sets `display: none` so the wrapper is
10
+ # excluded from CSS Grid / Flex layout, not just visually hidden.
11
+ class HiddenWrapper < Phlexi::Form::Components::Base
12
+ def view_template(&block)
13
+ div(hidden: true) do
14
+ yield(field) if block
15
+ end
16
+ end
17
+
18
+ # No id needed for a layout-suppressed wrapper.
19
+ def build_attributes
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -6,18 +6,96 @@ module Plutonium
6
6
  module Components
7
7
  # Select for choosing a resource record
8
8
  class ResourceSelect < Phlexi::Form::Components::Select
9
+ include Plutonium::UI::Component::Methods
10
+
11
+ # Cap on the number of records the dropdown materialises. Keeps
12
+ # very large association tables from rendering thousands of
13
+ # options into the page; consumers needing more should pair this
14
+ # with a typeahead control later.
15
+ DEFAULT_CHOICE_LIMIT = 100
16
+
9
17
  protected
10
18
 
11
19
  def choices
12
20
  @choices ||= begin
13
- collection = @raw_choices || @association_class&.all || []
21
+ collection = if @raw_choices
22
+ @raw_choices
23
+ elsif @association_class.nil?
24
+ []
25
+ else
26
+ relation = @association_class.all
27
+ relation = relation.limit(@choice_limit) if relation.respond_to?(:limit) && @choice_limit
28
+ if @skip_authorization
29
+ relation
30
+ else
31
+ authorized_resource_scope(@association_class, relation: relation)
32
+ end
33
+ end
14
34
  build_choice_mapper(collection)
15
35
  end
16
36
  end
17
37
 
18
38
  def build_attributes
39
+ # Defaults must land BEFORE super — AcceptsChoices.build_attributes
40
+ # consumes :value_method / :label_method off `attributes` into
41
+ # its own ivars, so anything we set after super has no effect.
42
+ attributes[:value_method] ||= :to_signed_global_id
43
+ attributes[:label_method] ||= :to_label
44
+
19
45
  super
46
+
20
47
  @association_class = attributes.delete(:association_class)
48
+ @skip_authorization = attributes.delete(:skip_authorization)
49
+ @choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
50
+ attributes.delete(:choice_limit)
51
+ end
52
+
53
+ # SGIDs include a timestamp + signature, so the SGID in the URL
54
+ # (generated when the user submitted) won't string-equal the
55
+ # SGID we just generated for the same record. Compare by the
56
+ # decoded model id instead, falling back to raw string equality
57
+ # for non-SGID values (legacy URLs / explicit raw choices).
58
+ def selected?(option)
59
+ if attributes[:multiple]
60
+ Array(field.value).any? { |v| same_record?(v, option) }
61
+ else
62
+ same_record?(field.value, option)
63
+ end
64
+ end
65
+
66
+ # AcceptsChoices.normalize_simple_input rejects any submitted
67
+ # value that doesn't string-match an option's value. With SGIDs
68
+ # the URL value (signed/timestamped at submit time) never
69
+ # string-equals a freshly generated option SGID for the same
70
+ # record, so the value gets silently dropped — no WHERE clause
71
+ # is built and the filter behaves as if it weren't applied.
72
+ # Match by decoded model id so the input survives.
73
+ def normalize_simple_input(input_value)
74
+ return nil if input_value.blank?
75
+ choices.values.find { |opt| same_record?(input_value, opt) } && input_value
76
+ end
77
+
78
+ # Two values point at the same record when both decode to the
79
+ # same SGID (class + id). For explicit non-SGID `@raw_choices`,
80
+ # both sides are plain strings and string-equality is the only
81
+ # sensible answer. Mixed-format (one SGID, one raw) returns
82
+ # false — no cross-format guessing.
83
+ def same_record?(a, b)
84
+ return false if a.blank? || b.blank?
85
+
86
+ a_pair = decode_class_and_id(a)
87
+ b_pair = decode_class_and_id(b)
88
+ return a_pair == b_pair if a_pair && b_pair
89
+ return false if a_pair || b_pair
90
+
91
+ a.to_s == b.to_s
92
+ end
93
+
94
+ def decode_class_and_id(value)
95
+ gid = SignedGlobalID.parse(value)
96
+ gid && [gid.model_class.name, gid.model_id]
97
+ rescue
98
+ nil
21
99
  end
22
100
 
23
101
  # Use include_blank string as blank option text (Phlexi default uses placeholder)
@@ -7,6 +7,8 @@ module Plutonium
7
7
  class SecureAssociation < Phlexi::Form::Components::AssociationBase
8
8
  include Plutonium::UI::Component::Methods
9
9
 
10
+ DEFAULT_CHOICE_LIMIT = Plutonium::UI::Form::Components::ResourceSelect::DEFAULT_CHOICE_LIMIT
11
+
10
12
  def view_template
11
13
  div(class: "flex items-center space-x-1") do
12
14
  super
@@ -23,9 +25,9 @@ module Plutonium
23
25
 
24
26
  a(
25
27
  href: add_url,
26
- class: "bg-[var(--pu-surface-alt)] hover:bg-[var(--pu-border)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] px-4 py-3 focus:ring-2 focus:ring-[var(--pu-border)] focus:outline-none text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors"
28
+ class: "inline-flex items-center justify-center w-9 h-9 shrink-0 bg-[var(--pu-surface-alt)] hover:bg-[var(--pu-border)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] focus:ring-2 focus:ring-[var(--pu-border)] focus:outline-none text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors"
27
29
  ) do
28
- render Phlex::TablerIcons::Plus.new(class: "w-6 h-6")
30
+ render Phlex::TablerIcons::Plus.new(class: "w-4 h-4")
29
31
  end
30
32
  end
31
33
 
@@ -51,6 +53,7 @@ module Plutonium
51
53
  else
52
54
  authorized_resource_scope(association_reflection.klass, relation: choices_from_association(association_reflection.klass))
53
55
  end
56
+ collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
54
57
  build_choice_mapper(collection)
55
58
  end
56
59
  end
@@ -63,6 +66,8 @@ module Plutonium
63
66
  def build_association_attributes
64
67
  @skip_authorization = attributes.delete(:skip_authorization)
65
68
  @add_action = attributes.delete(:add_action)
69
+ @choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
70
+ attributes.delete(:choice_limit)
66
71
 
67
72
  attributes.fetch(:value_method) { attributes[:value_method] = :to_signed_global_id }
68
73
 
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ class StickyFooter < Plutonium::UI::Component::Base
8
+ def view_template(&block)
9
+ div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
10
+ "h-14 bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
11
+ "px-6 flex items-center justify-end gap-2", &block)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -18,10 +18,23 @@ module Plutonium
18
18
  end
19
19
 
20
20
  def form_template
21
- render_fields
21
+ if in_modal?
22
+ # In modal: form is the flex container that fills the modal
23
+ # body. Fields region scrolls; action strip sits flush at the
24
+ # bottom edge of the modal.
25
+ div(class: "flex-1 min-h-0 overflow-y-auto px-6 py-5") do
26
+ render_fields
27
+ end
28
+ else
29
+ render_fields
30
+ end
22
31
  render_actions
23
32
  end
24
33
 
34
+ def form_class
35
+ in_modal? ? "flex-1 flex flex-col min-h-0" : super
36
+ end
37
+
25
38
  private
26
39
 
27
40
  def render_fields
@@ -33,18 +46,43 @@ module Plutonium
33
46
  end
34
47
 
35
48
  def render_actions
36
- input name: "return_to", value: request.params[:return_to], type: :hidden, hidden: true
37
-
38
- actions_wrapper {
39
- render_submit_and_continue_button if show_submit_and_continue?
49
+ # capture-url controller sets this element's value to
50
+ # window.location.href on connect, so URL fragments (#tab-id)
51
+ # survive the redirect after submit (the server never sees them).
52
+ input name: "return_to",
53
+ value: request.params[:return_to] || request.original_url,
54
+ type: :hidden,
55
+ hidden: true,
56
+ data: {controller: "capture-url"}
57
+
58
+ if in_modal?
59
+ div(class: "shrink-0 px-6 py-3 " \
60
+ "bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
61
+ "flex items-center justify-end gap-2") do
62
+ render_submit_and_continue_button if show_submit_and_continue?
63
+ render submit_button
64
+ end
65
+ else
66
+ render Plutonium::UI::Form::Components::StickyFooter.new do
67
+ render_submit_and_continue_button if show_submit_and_continue?
68
+ render submit_button
69
+ end
70
+ end
71
+ end
40
72
 
41
- render submit_button
42
- }
73
+ def in_modal?
74
+ current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
43
75
  end
44
76
 
45
77
  def show_submit_and_continue?
46
78
  return false unless object.respond_to?(:new_record?)
47
79
 
80
+ # Continue / add-another lands on the form's standalone URL —
81
+ # which breaks the experience when the form is inside a frame
82
+ # (modal or association tab) since the redirect can't keep the
83
+ # user in that frame context.
84
+ return false if current_turbo_frame.present?
85
+
48
86
  # Check explicit configuration first
49
87
  configured = resource_definition.submit_and_continue
50
88
  return configured unless configured.nil?
@@ -60,7 +98,7 @@ module Plutonium
60
98
  type: :submit,
61
99
  name: "return_to",
62
100
  value: request.url,
63
- class: "px-4 py-2 bg-secondary-600 text-white rounded-md hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-secondary-500"
101
+ class: "pu-btn pu-btn-md pu-btn-outline"
64
102
  ) { label }
65
103
  end
66
104
 
@@ -116,7 +154,8 @@ module Plutonium
116
154
  end
117
155
  end
118
156
 
119
- field_options = field_options.except(:as, :condition)
157
+ # Keep `:as` so the Builder can detect hidden fields via `options[:as]`.
158
+ field_options = field_options.except(:condition)
120
159
 
121
160
  condition = input_options[:condition] || field_options[:condition]
122
161
  conditionally_hidden = condition && !instance_exec(&condition)
@@ -49,7 +49,7 @@ module Plutonium
49
49
  hint: "pu-hint whitespace-pre",
50
50
 
51
51
  # Error themes
52
- error: "pu-error",
52
+ error: "text-xs text-danger-600 mt-1",
53
53
 
54
54
  # Button themes
55
55
  button: "pu-btn pu-btn-md pu-btn-primary",
@@ -10,12 +10,14 @@ module Plutonium
10
10
 
11
11
  def view_template
12
12
  button(
13
+ type: "button",
13
14
  title: @label,
15
+ aria: {label: @label},
14
16
  style: "display: none",
15
- class: "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors",
17
+ class: "inline-flex items-center justify-center w-7 h-7 rounded text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors",
16
18
  **@attributes
17
19
  ) {
18
- render @icon.new(class: "w-6 h-6")
20
+ render @icon.new(class: "w-4 h-4")
19
21
  }
20
22
  end
21
23
  end
@@ -31,11 +33,12 @@ module Plutonium
31
33
  def view_template
32
34
  a(
33
35
  title: @label,
34
- class: "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors",
36
+ aria: {label: @label},
35
37
  href: @href,
38
+ class: "inline-flex items-center justify-center w-7 h-7 rounded text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors",
36
39
  **@attributes
37
40
  ) {
38
- render @icon.new(class: "w-6 h-6")
41
+ render @icon.new(class: "w-4 h-4")
39
42
  }
40
43
  end
41
44
  end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Grid
6
+ # Renders a single record as a card built from semantic slots
7
+ # (image / header / subheader / body / meta / footer) declared via
8
+ # `grid_fields` on the resource definition. Each slot is optional;
9
+ # `header` falls back to `record.to_label` when undeclared.
10
+ class Card < Plutonium::UI::Component::Base
11
+ attr_reader :record, :resource_definition, :resource_fields
12
+
13
+ def initialize(record, resource_definition:, resource_fields: nil)
14
+ @record = record
15
+ @resource_definition = resource_definition
16
+ @resource_fields = resource_fields
17
+ end
18
+
19
+ def view_template
20
+ article(
21
+ class: card_class,
22
+ data: {controller: "row-click", action: "click->row-click#click"}
23
+ ) do
24
+ render_show_link if can_show?
25
+ render_actions_dropdown
26
+ case resource_definition.defined_grid_layout
27
+ when :media then render_media_layout
28
+ else render_compact_layout
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def slots = resource_definition.defined_grid_fields
36
+
37
+ # ---------------------------------------------------------------
38
+ # Layout shells
39
+ # ---------------------------------------------------------------
40
+
41
+ def render_compact_layout
42
+ div(class: "flex items-start gap-3 p-4") do
43
+ render_image_slot(size: :sm) if slots[:image]
44
+ div(class: "min-w-0 flex-1 flex flex-col gap-1") do
45
+ render_header_slot
46
+ render_subheader_slot if slots[:subheader]
47
+ render_body_slot if slots[:body]
48
+ render_meta_slot if slots[:meta]
49
+ render_footer_slot if footer_field
50
+ end
51
+ end
52
+ end
53
+
54
+ def render_media_layout
55
+ render_image_slot(size: :cover) if slots[:image]
56
+ div(class: "p-4 flex flex-col gap-1") do
57
+ render_header_slot
58
+ render_subheader_slot if slots[:subheader]
59
+ render_body_slot if slots[:body]
60
+ render_meta_slot if slots[:meta]
61
+ render_footer_slot if footer_field
62
+ end
63
+ end
64
+
65
+ # Footer falls back to `:created_at` when the slot is unset and
66
+ # the record has a created_at column. Gives cards a sensible
67
+ # second line without forcing every grid_fields call to repeat it.
68
+ def footer_field
69
+ slots[:footer] || (record.respond_to?(:created_at) ? :created_at : nil)
70
+ end
71
+
72
+ # ---------------------------------------------------------------
73
+ # Slot renderers
74
+ # ---------------------------------------------------------------
75
+
76
+ def render_image_slot(size:)
77
+ value = field_value(slots[:image])
78
+ return unless value
79
+ src = image_src_for(value)
80
+ return unless src
81
+
82
+ if size == :cover
83
+ div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
84
+ img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
85
+ end
86
+ 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
+ )
92
+ end
93
+ end
94
+
95
+ def render_header_slot
96
+ h3(class: "text-sm font-semibold text-[var(--pu-text)] truncate") do
97
+ plain header_text
98
+ end
99
+ end
100
+
101
+ def render_subheader_slot
102
+ value = field_value(slots[:subheader])
103
+ return if value.blank?
104
+ p(class: "text-xs text-[var(--pu-text-muted)] truncate") { plain helpers.display_name_of(value) }
105
+ end
106
+
107
+ def render_body_slot
108
+ value = field_value(slots[:body])
109
+ return if value.blank?
110
+ p(class: "text-sm text-[var(--pu-text)] line-clamp-3") { plain helpers.display_name_of(value) }
111
+ end
112
+
113
+ def render_meta_slot
114
+ fields = Array(slots[:meta])
115
+ values = fields.map { |f| field_value(f) }.reject(&:blank?)
116
+ return if values.empty?
117
+
118
+ div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
119
+ values.each do |v|
120
+ span(class: "inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium " \
121
+ "bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)]") do
122
+ plain helpers.display_name_of(v)
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ def render_footer_slot
129
+ value = field_value(footer_field)
130
+ return if value.blank?
131
+ p(class: "text-xs text-[var(--pu-text-subtle)] mt-1") do
132
+ if value.respond_to?(:strftime)
133
+ # display_datetime_value returns HTML-safe <time> markup
134
+ # rendered by the timeago Stimulus controller.
135
+ raw safe(helpers.display_datetime_value(value))
136
+ else
137
+ plain helpers.display_name_of(value)
138
+ end
139
+ end
140
+ end
141
+
142
+ # ---------------------------------------------------------------
143
+ # Card chrome — selection, actions, show
144
+ # ---------------------------------------------------------------
145
+
146
+ def render_actions_dropdown
147
+ # Cards have limited surface area, so all collection-record
148
+ # actions (including primary ones like Edit) live in the
149
+ # dropdown rather than splitting between buttons and a menu
150
+ # like the table view does.
151
+ actions = row_actions.reject { |a| a.name == :show }
152
+ return if actions.empty?
153
+ div(class: "absolute top-2 right-2 z-10") do
154
+ RowActionsDropdown(actions: actions, record:)
155
+ end
156
+ end
157
+
158
+ # Hidden link the `row-click` controller delegates to when the
159
+ # user clicks anywhere on the card body. Mirrors how the show
160
+ # action button works in the Table view.
161
+ def render_show_link
162
+ show = resource_definition.defined_actions[:show]
163
+ url = route_options_to_url(show.route_options, record)
164
+ a(
165
+ href: url,
166
+ data: {row_click_target: "show", turbo_frame: show.turbo_frame},
167
+ class: "sr-only",
168
+ tabindex: "-1",
169
+ "aria-label": "Open #{header_text}"
170
+ ) { plain "Open" }
171
+ end
172
+
173
+ # ---------------------------------------------------------------
174
+ # Helpers
175
+ # ---------------------------------------------------------------
176
+
177
+ def header_text
178
+ @header_text ||= helpers.display_name_of(field_value(slots[:header]) || record)
179
+ end
180
+
181
+ def field_value(name)
182
+ return nil unless name
183
+ # Skip fields the user's policy doesn't permit. nil collapses
184
+ # the slot in render_*_slot guards above.
185
+ return nil if resource_fields && !resource_fields.include?(name.to_sym)
186
+ unless record.respond_to?(name)
187
+ raise ArgumentError,
188
+ "grid_fields slot points at `:#{name}` but " \
189
+ "#{record.class.name} doesn't respond to it. " \
190
+ "Define the method on the model or remove the slot."
191
+ end
192
+ record.public_send(name)
193
+ end
194
+
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
+ def row_actions
213
+ @row_actions ||= resource_definition.defined_actions.values.select { |a|
214
+ a.collection_record_action? && a.permitted_by?(record_policy)
215
+ }
216
+ end
217
+
218
+ def can_show?
219
+ resource_definition.defined_actions[:show]&.permitted_by?(record_policy)
220
+ end
221
+
222
+ def record_policy
223
+ @record_policy ||= policy_for(record:)
224
+ end
225
+
226
+ def card_class
227
+ tokens(
228
+ "pu-card relative overflow-hidden transition-shadow",
229
+ -> { can_show? } => "cursor-pointer hover:shadow-md focus-within:ring-2 focus-within:ring-primary-500"
230
+ )
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end