plutonium 0.59.0 → 0.60.1
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-auth/SKILL.md +8 -2
- data/.claude/skills/plutonium-ui/SKILL.md +12 -0
- data/CHANGELOG.md +15 -0
- data/app/assets/plutonium.css +1 -1
- data/docs/reference/auth/accounts.md +7 -0
- data/docs/reference/configuration.md +1 -1
- data/docs/reference/resource/definition.md +129 -0
- data/docs/reference/ui/forms.md +51 -21
- data/docs/reference/ui/layouts.md +37 -1
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +926 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md.tasks.json +40 -0
- data/docs/superpowers/plans/2026-06-14-railless-portal.md +761 -0
- data/docs/superpowers/plans/2026-06-14-railless-portal.md.tasks.json +51 -0
- data/docs/superpowers/specs/2026-06-14-form-sectioning-design.md +247 -0
- data/docs/superpowers/specs/2026-06-14-railless-portal-design.md +275 -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/core/install/templates/config/initializers/plutonium.rb +1 -0
- data/lib/generators/pu/rodauth/admin_generator.rb +5 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +1 -1
- data/lib/generators/pu/rodauth/templates/app/interactions/resend_admin_interaction.rb.tt +18 -0
- data/lib/generators/pu/rodauth/views_generator.rb +1 -1
- data/lib/plutonium/auth/rodauth.rb +2 -1
- data/lib/plutonium/configuration.rb +2 -1
- data/lib/plutonium/core/controller.rb +19 -0
- data/lib/plutonium/definition/base.rb +1 -0
- data/lib/plutonium/definition/form_layout.rb +143 -0
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +17 -7
- data/lib/plutonium/ui/form/components/section.rb +58 -0
- data/lib/plutonium/ui/form/components/sticky_footer.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +85 -7
- data/lib/plutonium/ui/layout/base.rb +5 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +22 -6
- data/lib/plutonium/ui/layout/topbar.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +9 -0
- data/src/css/slim_select.css +11 -2
- metadata +11 -2
|
@@ -13,7 +13,7 @@ module Pu
|
|
|
13
13
|
desc "Generate views for selected features\n\n" \
|
|
14
14
|
"Supported Features\n" \
|
|
15
15
|
"=========================================\n" \
|
|
16
|
-
"#{VIEW_CONFIG.keys.sort.
|
|
16
|
+
"#{VIEW_CONFIG.keys.sort.join "\n"}\n\n\n\n"
|
|
17
17
|
|
|
18
18
|
argument :plugin_name, type: :string, optional: true,
|
|
19
19
|
desc: "[CONFIG] Name of the configured rodauth app. Leave blank to use the primary account."
|
|
@@ -7,7 +7,7 @@ module Plutonium
|
|
|
7
7
|
extend ActiveSupport::Concern
|
|
8
8
|
|
|
9
9
|
included do
|
|
10
|
-
helper_method :current_user
|
|
10
|
+
helper_method :current_user, :current_#{name}
|
|
11
11
|
helper_method :logout_url
|
|
12
12
|
helper_method :profile_url
|
|
13
13
|
end
|
|
@@ -23,6 +23,7 @@ module Plutonium
|
|
|
23
23
|
def current_user
|
|
24
24
|
rodauth.rails_account
|
|
25
25
|
end
|
|
26
|
+
alias_method :current_#{name}, :current_user
|
|
26
27
|
|
|
27
28
|
def logout_url
|
|
28
29
|
rodauth.logout_path
|
|
@@ -27,7 +27,8 @@ module Plutonium
|
|
|
27
27
|
# @return [Float] the current defaults version
|
|
28
28
|
attr_reader :defaults_version
|
|
29
29
|
|
|
30
|
-
# @return [Symbol] :
|
|
30
|
+
# @return [Symbol] :modern (Topbar/IconRail, default), :plain (Topbar, no
|
|
31
|
+
# icon rail), or :classic (legacy Header/Sidebar).
|
|
31
32
|
attr_accessor :shell
|
|
32
33
|
|
|
33
34
|
# @return [String] host URL of the Navii avatar service (no path), used by
|
|
@@ -45,6 +45,25 @@ module Plutonium
|
|
|
45
45
|
append_view_path File.expand_path("app/views", Plutonium.root)
|
|
46
46
|
layout -> { turbo_frame_request? ? false : "resource" }
|
|
47
47
|
helper_method :registered_resources
|
|
48
|
+
|
|
49
|
+
class_attribute :_rail_enabled, instance_writer: false, default: nil
|
|
50
|
+
helper_method :rail?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class_methods do
|
|
54
|
+
# Enable or disable the modern icon rail for this controller and its
|
|
55
|
+
# subclasses. nil (the default) inherits the global shell default.
|
|
56
|
+
def rail(enabled)
|
|
57
|
+
self._rail_enabled = enabled
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether the modern icon rail is active for this request. Resolves the
|
|
62
|
+
# per-controller `rail` setting, falling back to the shell default.
|
|
63
|
+
# Public: the resource layout calls `controller.rail?`.
|
|
64
|
+
def rail?
|
|
65
|
+
return _rail_enabled unless _rail_enabled.nil?
|
|
66
|
+
Plutonium.configuration.shell == :modern
|
|
48
67
|
end
|
|
49
68
|
|
|
50
69
|
private
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Definition
|
|
5
|
+
# Declarative form sectioning. Mixed into both resource definitions and
|
|
6
|
+
# interactions (mirrors StructuredInputs). The layout references field KEYS
|
|
7
|
+
# only and carries section-level options; per-field config stays on `input`.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# form_layout do
|
|
11
|
+
# section :identity, :name, :email, label: "Your identification"
|
|
12
|
+
# section :address, :street, :city, collapsible: true, columns: 2,
|
|
13
|
+
# condition: -> { object.requires_address? }
|
|
14
|
+
# ungrouped label: "Other"
|
|
15
|
+
# end
|
|
16
|
+
module FormLayout
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
UNGROUPED_KEY = :ungrouped
|
|
20
|
+
|
|
21
|
+
# One declared section, or the implicit `ungrouped` bucket (empty `fields`).
|
|
22
|
+
Section = Struct.new(:key, :fields, :options) do
|
|
23
|
+
def ungrouped? = key == UNGROUPED_KEY
|
|
24
|
+
def label = options[:label] || key.to_s.humanize
|
|
25
|
+
def description = options[:description]
|
|
26
|
+
def collapsible? = !!options[:collapsible]
|
|
27
|
+
def collapsed? = !!options[:collapsed]
|
|
28
|
+
def columns = options[:columns]
|
|
29
|
+
def condition = options[:condition]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A section paired with the concrete fields it will render (after policy
|
|
33
|
+
# filtering). Produced by #resolve_form_sections (a later task).
|
|
34
|
+
ResolvedSection = Struct.new(:section, :fields)
|
|
35
|
+
|
|
36
|
+
# Collects section/ungrouped calls from a form_layout block in order.
|
|
37
|
+
class Builder
|
|
38
|
+
attr_reader :sections
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@sections = []
|
|
42
|
+
@ungrouped_seen = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def section(key, *fields, **options)
|
|
46
|
+
if key == UNGROUPED_KEY
|
|
47
|
+
raise ArgumentError,
|
|
48
|
+
"`section :#{UNGROUPED_KEY}` is reserved — use the `ungrouped` macro"
|
|
49
|
+
end
|
|
50
|
+
validate_columns!(options)
|
|
51
|
+
@sections << Section.new(key:, fields: fields.freeze, options: options.freeze)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ungrouped(**options)
|
|
55
|
+
raise ArgumentError, "`ungrouped` may only be declared once" if @ungrouped_seen
|
|
56
|
+
@ungrouped_seen = true
|
|
57
|
+
validate_columns!(options)
|
|
58
|
+
@sections << Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: options.freeze)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate_columns!(options)
|
|
64
|
+
return unless options.key?(:columns)
|
|
65
|
+
value = options[:columns]
|
|
66
|
+
unless Integer === value && value > 0
|
|
67
|
+
raise ArgumentError,
|
|
68
|
+
"form_layout :columns must be a positive Integer, got #{value.inspect}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class_methods do
|
|
74
|
+
# Declare the form layout. Re-declaring replaces it as a unit.
|
|
75
|
+
def form_layout(&block)
|
|
76
|
+
raise ArgumentError, "`form_layout` requires a block" unless block
|
|
77
|
+
builder = Builder.new
|
|
78
|
+
builder.instance_exec(&block)
|
|
79
|
+
@defined_form_layout = builder.sections.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Ordered Array<Section>, or nil when no layout was declared.
|
|
83
|
+
def defined_form_layout
|
|
84
|
+
@defined_form_layout
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inherited(subclass)
|
|
88
|
+
super
|
|
89
|
+
subclass.instance_variable_set(:@defined_form_layout, defined_form_layout&.dup)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Instance access — the form render path holds a definition/interaction
|
|
94
|
+
# instance (mirrors the defineable_prop convention).
|
|
95
|
+
def defined_form_layout
|
|
96
|
+
self.class.defined_form_layout
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Resolve the policy-filtered field list into ordered ResolvedSections.
|
|
100
|
+
# Returns nil when no layout is declared (caller falls back to one grid).
|
|
101
|
+
def resolve_form_sections(resource_fields)
|
|
102
|
+
layout = defined_form_layout
|
|
103
|
+
return nil unless layout
|
|
104
|
+
|
|
105
|
+
resource_fields = resource_fields.map(&:to_sym)
|
|
106
|
+
known = resource_fields.to_set
|
|
107
|
+
|
|
108
|
+
# First-section-wins assignment: each field is claimed by the first
|
|
109
|
+
# section that lists it. A field a section lists but that isn't in the
|
|
110
|
+
# currently-permitted set (policy, per-action, entity scoping, nesting)
|
|
111
|
+
# is simply skipped — it never renders and is never an error.
|
|
112
|
+
owner = {}
|
|
113
|
+
layout.each do |section|
|
|
114
|
+
next if section.ungrouped?
|
|
115
|
+
section.fields.each do |f|
|
|
116
|
+
owner[f] ||= section.key if known.include?(f)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
leftovers = resource_fields.reject { |f| owner.key?(f) }
|
|
120
|
+
|
|
121
|
+
resolved = layout.map do |section|
|
|
122
|
+
fields =
|
|
123
|
+
if section.ungrouped?
|
|
124
|
+
leftovers
|
|
125
|
+
else
|
|
126
|
+
section.fields.select { |f| owner[f] == section.key }
|
|
127
|
+
end
|
|
128
|
+
ResolvedSection.new(section:, fields:)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
unless layout.any?(&:ungrouped?)
|
|
132
|
+
implicit = ResolvedSection.new(
|
|
133
|
+
section: Section.new(key: UNGROUPED_KEY, fields: [].freeze, options: {}.freeze),
|
|
134
|
+
fields: leftovers
|
|
135
|
+
)
|
|
136
|
+
resolved.push(implicit)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
resolved
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -26,6 +26,7 @@ module Plutonium
|
|
|
26
26
|
include Plutonium::Definition::ConfigAttr
|
|
27
27
|
include Plutonium::Definition::Presentable
|
|
28
28
|
include Plutonium::Definition::StructuredInputs
|
|
29
|
+
include Plutonium::Definition::FormLayout
|
|
29
30
|
|
|
30
31
|
# On interactions, declaring a structured input also declares the backing
|
|
31
32
|
# ActiveModel attribute so the value survives `attributes=` and appears in
|
|
@@ -4,14 +4,24 @@ module Plutonium
|
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
6
|
included do
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
# Prevent this package's app/views from being appended to the global
|
|
8
|
+
# ActionController/ActionMailer view lookup — Plutonium resolves package
|
|
9
|
+
# views at the controller level (see Plutonium::Core::Controllers::Bootable,
|
|
10
|
+
# which reads current_engine.paths["app/views"]). We neutralize the
|
|
11
|
+
# engine's built-in `add_view_paths` initializer rather than clearing
|
|
12
|
+
# config.paths["app/views"], which that controller-level resolver needs.
|
|
13
|
+
#
|
|
14
|
+
# This MUST run as a real initializer (before :add_view_paths), NOT in
|
|
15
|
+
# before_configuration: that hook can fire before sibling package engines
|
|
16
|
+
# are loaded (it does in development, where :before_configuration has
|
|
17
|
+
# already run by the time config/packages.rb loads). Touching
|
|
18
|
+
# Rails.application.initializers there forces Rails.application.railties
|
|
19
|
+
# to memoize early — with only the packages loaded so far — permanently
|
|
20
|
+
# dropping the rest from the autoload paths (e.g. `uninitialized constant
|
|
21
|
+
# Blogging::Post`). By initializer-run time, railties is fully populated.
|
|
22
|
+
initializer :plutonium_neutralize_add_view_paths, before: :add_view_paths do
|
|
13
23
|
add_view_paths_initializer = Rails.application.initializers.find do |a|
|
|
14
|
-
a.context_class == self && a.name.to_s == "add_view_paths"
|
|
24
|
+
a.context_class == self.class && a.name.to_s == "add_view_paths"
|
|
15
25
|
end
|
|
16
26
|
add_view_paths_initializer&.instance_variable_set(:@block, ->(app) {})
|
|
17
27
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Form
|
|
6
|
+
module Components
|
|
7
|
+
# Renders a form section's chrome (heading/description, optional native
|
|
8
|
+
# <details> collapsible, and a fields grid) and yields to a block that
|
|
9
|
+
# renders the section's fields (the form supplies render_resource_field).
|
|
10
|
+
class Section < Plutonium::UI::Component::Base
|
|
11
|
+
def initialize(resolved, grid_class:)
|
|
12
|
+
@section = resolved.section
|
|
13
|
+
@grid_class = grid_class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
SECTION_CLASS = "space-y-4 border-t border-[var(--pu-border-muted)] pt-6 first:border-t-0 first:pt-0"
|
|
17
|
+
HEADING_CLASS = "text-base font-semibold text-[var(--pu-text)]"
|
|
18
|
+
SUMMARY_CLASS = "#{HEADING_CLASS} cursor-pointer select-none"
|
|
19
|
+
DESCRIPTION_CLASS = "text-sm text-[var(--pu-text-muted)]"
|
|
20
|
+
|
|
21
|
+
def view_template(&fields_block)
|
|
22
|
+
if @section.collapsible?
|
|
23
|
+
details(open: !@section.collapsed?, class: SECTION_CLASS) do
|
|
24
|
+
summary(class: SUMMARY_CLASS) { heading_text }
|
|
25
|
+
describe
|
|
26
|
+
grid(&fields_block)
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
div(class: SECTION_CLASS) do
|
|
30
|
+
header_block
|
|
31
|
+
grid(&fields_block)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def header_block
|
|
39
|
+
return if @section.ungrouped? && @section.options[:label].nil?
|
|
40
|
+
h3(class: HEADING_CLASS) { heading_text }
|
|
41
|
+
describe
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def heading_text = @section.label
|
|
45
|
+
|
|
46
|
+
def describe
|
|
47
|
+
return unless @section.description
|
|
48
|
+
p(class: DESCRIPTION_CLASS) { @section.description }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def grid(&fields_block)
|
|
52
|
+
div(class: @grid_class, &fields_block)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -6,7 +6,7 @@ module Plutonium
|
|
|
6
6
|
module Components
|
|
7
7
|
class StickyFooter < Plutonium::UI::Component::Base
|
|
8
8
|
def view_template(&block)
|
|
9
|
-
div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
|
|
9
|
+
div(class: "pu-sticky-footer fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
|
|
10
10
|
"h-14 bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
|
|
11
11
|
"px-6 flex items-center justify-end gap-2", &block)
|
|
12
12
|
end
|
|
@@ -98,11 +98,81 @@ module Plutonium
|
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def render_fields
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
sections = resolve_form_layout
|
|
102
|
+
if sections.nil?
|
|
103
|
+
fields_wrapper {
|
|
104
|
+
resource_fields.each { |name| render_resource_field name }
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
+
else
|
|
107
|
+
sections.each { |rs| render_form_section(rs) }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Resolve the whole form layout for THIS render, in one pass: drop
|
|
112
|
+
# condition-hidden sections and evaluate any proc-valued options, all in
|
|
113
|
+
# the form instance context (where `object`, `current_user`, `params` and
|
|
114
|
+
# helpers live — same context as input/section `condition:`). Returns nil
|
|
115
|
+
# when no form_layout is declared (caller falls back to a single grid).
|
|
116
|
+
def resolve_form_layout
|
|
117
|
+
sections = resource_definition.resolve_form_sections(resource_fields)
|
|
118
|
+
return nil if sections.nil?
|
|
119
|
+
|
|
120
|
+
sections.filter_map do |resolved|
|
|
121
|
+
section = resolved.section
|
|
122
|
+
condition = section.condition
|
|
123
|
+
next if condition && !instance_exec(&condition)
|
|
124
|
+
|
|
125
|
+
# `columns` stays a validated literal; everything else may be a proc.
|
|
126
|
+
options = section.options.to_h do |key, value|
|
|
127
|
+
[key, (key != :condition && value.is_a?(Proc)) ? instance_exec(&value) : value]
|
|
128
|
+
end
|
|
129
|
+
Plutonium::Definition::FormLayout::ResolvedSection.new(
|
|
130
|
+
section: Plutonium::Definition::FormLayout::Section.new(
|
|
131
|
+
key: section.key, fields: section.fields, options: options.freeze
|
|
132
|
+
),
|
|
133
|
+
fields: resolved.fields
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Pure presentation — the section is already resolved (visible, options
|
|
139
|
+
# evaluated) by resolve_form_layout.
|
|
140
|
+
def render_form_section(resolved)
|
|
141
|
+
section = resolved.section
|
|
142
|
+
render Plutonium::UI::Form::Components::Section.new(
|
|
143
|
+
resolved, grid_class: section_grid_class(section.columns)
|
|
144
|
+
) do
|
|
145
|
+
# Inside a multi-column section, let fields flow into the grid cells
|
|
146
|
+
# instead of forcing each to span the full row (see col-span default
|
|
147
|
+
# in render_simple_resource_field).
|
|
148
|
+
previous = @section_columns
|
|
149
|
+
@section_columns = section.columns
|
|
150
|
+
begin
|
|
151
|
+
resolved.fields.each { |name| render_resource_field name }
|
|
152
|
+
ensure
|
|
153
|
+
@section_columns = previous
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# True while rendering fields inside a section that declared an explicit
|
|
159
|
+
# column count > 1. Such fields default to a single grid cell rather than
|
|
160
|
+
# `col-span-full`, so the section's grid actually lays out in columns.
|
|
161
|
+
def in_multi_column_section?
|
|
162
|
+
@section_columns.to_i > 1
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# nil → the form's default responsive grid; an Integer overrides columns.
|
|
166
|
+
def section_grid_class(columns)
|
|
167
|
+
return themed(:fields_wrapper, nil) if columns.nil?
|
|
168
|
+
|
|
169
|
+
base = "grid gap-6 grid-flow-row-dense grid-cols-1"
|
|
170
|
+
case columns.to_i
|
|
171
|
+
when 1 then base
|
|
172
|
+
when 2 then "#{base} md:grid-cols-2"
|
|
173
|
+
when 3 then "#{base} md:grid-cols-2 lg:grid-cols-3"
|
|
174
|
+
else "#{base} md:grid-cols-2 2xl:grid-cols-#{columns.to_i}"
|
|
175
|
+
end
|
|
106
176
|
end
|
|
107
177
|
|
|
108
178
|
def render_actions
|
|
@@ -232,10 +302,18 @@ module Plutonium
|
|
|
232
302
|
end
|
|
233
303
|
else
|
|
234
304
|
wrapper_options = input_options[:wrapper] || {}
|
|
305
|
+
# Only supply a default column span when the field hasn't declared its
|
|
306
|
+
# own (via `wrapper: {class: "col-span-..."}`). A field-level col-span
|
|
307
|
+
# ALWAYS wins — including inside a section with `columns:` — so authors
|
|
308
|
+
# can opt a single field back to full width in a multi-column section,
|
|
309
|
+
# or vice versa.
|
|
310
|
+
# TODO: remove the string check once theming supports class merges.
|
|
235
311
|
if !wrapper_options[:class] || !wrapper_options[:class].include?("col-span")
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
|
|
312
|
+
# In a multi-column section the field flows into a single grid cell
|
|
313
|
+
# (no col-span), so the declared `columns:` actually takes effect.
|
|
314
|
+
# Everywhere else fields span the full row.
|
|
315
|
+
default_span = in_multi_column_section? ? nil : "col-span-full"
|
|
316
|
+
wrapper_options[:class] = tokens(default_span, wrapper_options[:class])
|
|
239
317
|
end
|
|
240
318
|
|
|
241
319
|
render form.field(name, **field_options).wrapped(
|
|
@@ -48,7 +48,11 @@ module Plutonium
|
|
|
48
48
|
# on <html> based on whether the incoming body contains an icon-rail,
|
|
49
49
|
# preventing layout shift on Turbo navigations between rail and non-rail
|
|
50
50
|
# pages. Initial-load rail-pinned is handled by ResourceLayout.
|
|
51
|
+
# The same listener also keeps `pu-no-rail` in sync (inverse of the rail
|
|
52
|
+
# signal) for non-classic shells, since Turbo Drive does not update
|
|
53
|
+
# <html> attributes across cross-URL visits.
|
|
51
54
|
def render_pre_paint_scripts
|
|
55
|
+
manage_no_rail = Plutonium.configuration.shell != :classic
|
|
52
56
|
script do
|
|
53
57
|
raw(safe(<<~JS))
|
|
54
58
|
(function () {
|
|
@@ -68,6 +72,7 @@ module Plutonium
|
|
|
68
72
|
} else {
|
|
69
73
|
document.documentElement.classList.remove("pu-rail-pinned");
|
|
70
74
|
}
|
|
75
|
+
#{'document.documentElement.classList.toggle("pu-no-rail", !hasRail);' if manage_no_rail}
|
|
71
76
|
});
|
|
72
77
|
} catch (e) {}
|
|
73
78
|
})();
|
|
@@ -4,11 +4,18 @@ module Plutonium
|
|
|
4
4
|
class ResourceLayout < Base
|
|
5
5
|
private
|
|
6
6
|
|
|
7
|
+
# Whether the modern icon rail is active for this request. Delegates to
|
|
8
|
+
# the controller's resolution (shell default + per-controller `rail`).
|
|
9
|
+
def rail? = controller.rail?
|
|
10
|
+
|
|
7
11
|
# Sets pu-rail-pinned immediately on initial page load so the rail
|
|
8
12
|
# renders in its pinned state from the first frame. Turbo navigations
|
|
9
|
-
# are handled by the turbo:before-render listener in Base.
|
|
13
|
+
# are handled by the turbo:before-render listener in Base. Skipped
|
|
14
|
+
# entirely when the layout renders no rail.
|
|
10
15
|
def render_pre_paint_scripts
|
|
11
16
|
super
|
|
17
|
+
return unless rail?
|
|
18
|
+
|
|
12
19
|
script do
|
|
13
20
|
raw(safe(<<~JS))
|
|
14
21
|
(function () {
|
|
@@ -22,12 +29,21 @@ module Plutonium
|
|
|
22
29
|
end
|
|
23
30
|
end
|
|
24
31
|
|
|
32
|
+
# Adds `pu-no-rail` to <html> (server-side, no FOUC) so decoupled fixed
|
|
33
|
+
# chrome (Topbar, form StickyFooter) can cancel their rail insets.
|
|
34
|
+
# Scoped to the modern family — :classic keeps its own offsets.
|
|
35
|
+
def html_attributes
|
|
36
|
+
attrs = super
|
|
37
|
+
return attrs if Plutonium.configuration.shell == :classic
|
|
38
|
+
|
|
39
|
+
rail? ? attrs : mix(attrs, {class: "pu-no-rail"})
|
|
40
|
+
end
|
|
41
|
+
|
|
25
42
|
def main_attributes
|
|
26
|
-
classes =
|
|
27
|
-
when :modern
|
|
28
|
-
"pt-16 pb-6 px-6 lg:pl-20"
|
|
29
|
-
else
|
|
43
|
+
classes = if Plutonium.configuration.shell == :classic
|
|
30
44
|
"pt-20 lg:ml-64"
|
|
45
|
+
else
|
|
46
|
+
rail? ? "pt-16 pb-6 px-6 lg:pl-20" : "pt-16 pb-6 px-6"
|
|
31
47
|
end
|
|
32
48
|
|
|
33
49
|
mix(super, {class: classes})
|
|
@@ -43,7 +59,7 @@ module Plutonium
|
|
|
43
59
|
super
|
|
44
60
|
|
|
45
61
|
render partial("resource_header")
|
|
46
|
-
render partial("resource_sidebar")
|
|
62
|
+
render partial("resource_sidebar") if rail?
|
|
47
63
|
end
|
|
48
64
|
end
|
|
49
65
|
end
|
|
@@ -33,7 +33,7 @@ module Plutonium
|
|
|
33
33
|
|
|
34
34
|
def view_template
|
|
35
35
|
nav(
|
|
36
|
-
class: "fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
|
|
36
|
+
class: "pu-topbar fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
|
|
37
37
|
"bg-[var(--pu-surface)] border-b border-[var(--pu-border)] " \
|
|
38
38
|
"flex items-center gap-3 px-4",
|
|
39
39
|
data: {
|
data/lib/plutonium/version.rb
CHANGED
data/package.json
CHANGED
data/src/css/components.css
CHANGED
|
@@ -757,6 +757,15 @@ html.pu-rail-pinned .icon-rail-pin-expand {
|
|
|
757
757
|
}
|
|
758
758
|
}
|
|
759
759
|
|
|
760
|
+
/* Rail-less layouts (html.pu-no-rail): cancel the icon-rail inset on fixed
|
|
761
|
+
chrome so the topbar and form sticky footer span the full width. */
|
|
762
|
+
@media (min-width: 1024px) {
|
|
763
|
+
html.pu-no-rail .pu-topbar,
|
|
764
|
+
html.pu-no-rail .pu-sticky-footer {
|
|
765
|
+
left: 0 !important;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
760
769
|
/* Flyout: visibility controlled by Stimulus (data-flyout-open) via position:fixed */
|
|
761
770
|
.icon-rail-parent {
|
|
762
771
|
position: relative;
|
data/src/css/slim_select.css
CHANGED
|
@@ -43,9 +43,11 @@
|
|
|
43
43
|
border-radius: var(--pu-radius-md);
|
|
44
44
|
color: var(--pu-text);
|
|
45
45
|
}
|
|
46
|
+
|
|
46
47
|
.ss-main:hover {
|
|
47
48
|
border-color: var(--pu-border-strong);
|
|
48
49
|
}
|
|
50
|
+
|
|
49
51
|
.ss-main:focus,
|
|
50
52
|
.ss-main[aria-expanded="true"] {
|
|
51
53
|
border-color: var(--pu-input-focus-ring);
|
|
@@ -196,9 +198,11 @@
|
|
|
196
198
|
font-size: inherit;
|
|
197
199
|
line-height: inherit;
|
|
198
200
|
}
|
|
201
|
+
|
|
199
202
|
.ss-content .ss-search input::placeholder {
|
|
200
203
|
color: var(--pu-input-placeholder);
|
|
201
204
|
}
|
|
205
|
+
|
|
202
206
|
.ss-content .ss-search input:focus {
|
|
203
207
|
border-color: var(--pu-input-focus-ring);
|
|
204
208
|
box-shadow: 0 0 0 3px theme(colors.primary.500 / 15%);
|
|
@@ -225,7 +229,7 @@
|
|
|
225
229
|
|
|
226
230
|
/* List */
|
|
227
231
|
.ss-content .ss-list {
|
|
228
|
-
@apply flex-auto h-auto overflow-x-hidden overflow-y-auto;
|
|
232
|
+
@apply flex-auto h-auto overflow-x-hidden overflow-y-auto mb-2;
|
|
229
233
|
}
|
|
230
234
|
|
|
231
235
|
.ss-content .ss-list .ss-error {
|
|
@@ -345,10 +349,12 @@
|
|
|
345
349
|
.ss-main.ss-invalid {
|
|
346
350
|
@apply border-danger-500 bg-danger-50/50 text-danger-900;
|
|
347
351
|
}
|
|
352
|
+
|
|
348
353
|
.ss-main.ss-invalid:focus,
|
|
349
354
|
.ss-main.ss-invalid[aria-expanded="true"] {
|
|
350
355
|
box-shadow: 0 0 0 3px theme(colors.danger.500 / 15%);
|
|
351
356
|
}
|
|
357
|
+
|
|
352
358
|
.dark .ss-main.ss-invalid {
|
|
353
359
|
@apply bg-danger-950/20 border-danger-500/70 text-danger-200;
|
|
354
360
|
}
|
|
@@ -360,10 +366,12 @@
|
|
|
360
366
|
.ss-main.ss-valid {
|
|
361
367
|
@apply border-success-500 bg-success-50/50 text-success-900;
|
|
362
368
|
}
|
|
369
|
+
|
|
363
370
|
.ss-main.ss-valid:focus,
|
|
364
371
|
.ss-main.ss-valid[aria-expanded="true"] {
|
|
365
372
|
box-shadow: 0 0 0 3px theme(colors.success.500 / 15%);
|
|
366
373
|
}
|
|
374
|
+
|
|
367
375
|
.dark .ss-main.ss-valid {
|
|
368
376
|
@apply bg-success-950/20 border-success-500/70 text-success-200;
|
|
369
377
|
}
|
|
@@ -385,7 +393,8 @@
|
|
|
385
393
|
width: 100% !important;
|
|
386
394
|
border-radius: 0 !important;
|
|
387
395
|
margin: 0 !important;
|
|
388
|
-
pointer-events: none !important;
|
|
396
|
+
pointer-events: none !important;
|
|
397
|
+
/* Disabled by default */
|
|
389
398
|
}
|
|
390
399
|
|
|
391
400
|
/* When active (dropdown is expanded), enable pointer events */
|