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
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Grid
6
+ # Renders a paginated collection of records as a responsive grid of
7
+ # Card components. Mirrors the structure of Table::Resource (filter
8
+ # panel, scopes pills, bulk actions, footer) so view-switching is
9
+ # purely a render-shape change.
10
+ class Resource < Plutonium::UI::Component::Base
11
+ attr_reader :collection, :resource_fields, :resource_definition
12
+
13
+ def initialize(collection, resource_fields:, resource_definition:)
14
+ @collection = collection
15
+ @resource_fields = resource_fields
16
+ @resource_definition = resource_definition
17
+ end
18
+
19
+ def view_template
20
+ div(data: filter_panel_controller_data) do
21
+ render_scopes_pills
22
+ render_toolbar
23
+
24
+ div(data: bulk_actions_controller_data) do
25
+ render_filter_pills
26
+ render_bulk_actions_toolbar
27
+ collection.empty? ? render_empty_card : render_grid
28
+ end
29
+
30
+ render_filter_slideover if current_query_object.filter_definitions.present?
31
+ end
32
+
33
+ render_footer
34
+ end
35
+
36
+ private
37
+
38
+ def render_scopes_pills
39
+ TableScopesPills() if current_query_object.scope_definitions.any?
40
+ end
41
+
42
+ def render_toolbar
43
+ TableToolbar(
44
+ query: current_query_object,
45
+ search_url: request.path,
46
+ search_value: params.dig(:q, :search) || params[:search],
47
+ views: resource_definition.defined_views,
48
+ current_view: :grid,
49
+ view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
50
+ view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
51
+ )
52
+ end
53
+
54
+ def render_filter_pills
55
+ TableFilterPills(query: current_query_object, total_count: pagy_instance&.count)
56
+ end
57
+
58
+ def render_bulk_actions_toolbar
59
+ return unless bulk_actions.any?
60
+ BulkActionsToolbar(bulk_actions:)
61
+ end
62
+
63
+ def render_empty_card
64
+ EmptyCard("No #{resource_name_plural(resource_class).downcase} available") {
65
+ action = resource_definition.defined_actions[:new]
66
+ if action&.permitted_by?(current_policy)
67
+ url = route_options_to_url(action.route_options, resource_class)
68
+ ActionButton(action, url:)
69
+ end
70
+ }
71
+ end
72
+
73
+ def render_grid
74
+ div(class: grid_class) do
75
+ collection.each do |record|
76
+ render Plutonium::UI::Grid::Card.new(record, resource_definition:, resource_fields:)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Default responsive: 1 / 2 / 3 / 4 columns at sm/md/lg/xl. When
82
+ # the definition pins a fixed `grid_columns N`, use that on lg+ so
83
+ # mobile still gets sensible single-column.
84
+ def grid_class
85
+ if resource_definition.defined_grid_columns
86
+ n = resource_definition.defined_grid_columns
87
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-#{n} gap-4 mt-4"
88
+ else
89
+ "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-4"
90
+ end
91
+ end
92
+
93
+ def bulk_actions
94
+ @bulk_actions ||= resource_definition.defined_actions
95
+ .select { |k, a| a.bulk_action? }
96
+ .values
97
+ end
98
+
99
+ def bulk_actions_controller_data
100
+ {controller: "bulk-actions"}
101
+ end
102
+
103
+ def filter_panel_controller_data
104
+ {controller: "filter-panel"}
105
+ end
106
+
107
+ def render_filter_slideover
108
+ div(
109
+ class: "fixed inset-0 z-40 bg-black/40 opacity-0 pointer-events-none " \
110
+ "transition-opacity duration-200 " \
111
+ "data-[open]:opacity-100 data-[open]:pointer-events-auto",
112
+ data: {filter_panel_target: "backdrop", action: "click->filter-panel#close"}
113
+ )
114
+ aside(
115
+ class: "fixed top-0 right-0 bottom-0 z-50 w-full sm:w-[420px] max-w-full " \
116
+ "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
117
+ "translate-x-full transition-transform duration-300 ease-out " \
118
+ "data-[open]:translate-x-0 " \
119
+ "flex flex-col",
120
+ role: "dialog",
121
+ aria: {label: "Filters", hidden: "true", modal: "true"},
122
+ data: {filter_panel_target: "panel"}
123
+ ) do
124
+ render Plutonium::UI::Table::Components::FilterForm.new(
125
+ filter_form_values,
126
+ query_object: current_query_object,
127
+ search_url: request.path,
128
+ search_value: params.dig(:q, :search) || params[:search]
129
+ )
130
+ end
131
+ end
132
+
133
+ def filter_form_values
134
+ raw = params[:q]
135
+ return {} unless raw
136
+ hash = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw.to_h
137
+ hash.deep_symbolize_keys.except(:search, :scope, :sort_fields, :sort_directions)
138
+ end
139
+
140
+ def render_footer
141
+ div(class: "lg:sticky bottom-[-2px] mt-1 p-4 pb-6 w-full z-30 bg-[var(--pu-body)]") {
142
+ TableInfo(pagy_instance)
143
+ TablePagination(pagy_instance)
144
+ }
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -36,9 +36,45 @@ module Plutonium
36
36
  render_title
37
37
  render_metatags
38
38
  render_assets
39
+ render_pre_paint_scripts
39
40
  }
40
41
  end
41
42
 
43
+ # Inline scripts that run before paint to prevent FOUC on user
44
+ # preferences read from localStorage:
45
+ # - Color mode: applies `dark` class on <html> so dark theme renders
46
+ # from the first frame instead of flashing light.
47
+ # - Rail-pin: applies `pu-rail-pinned` on <body> (when present) and
48
+ # on every incoming body via turbo:before-render, so a
49
+ # Turbo.visit (e.g. the redirect after a form submit) doesn't
50
+ # flash the rail into its collapsed state before the
51
+ # icon-rail Stimulus controller can restore it.
52
+ def render_pre_paint_scripts
53
+ script do
54
+ raw(safe(<<~JS))
55
+ (function () {
56
+ try {
57
+ var theme = localStorage.getItem("theme");
58
+ var dark = theme === "dark" ||
59
+ ((theme !== "light") &&
60
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
61
+ document.documentElement.classList.toggle("dark", dark);
62
+ } catch (e) {}
63
+
64
+ try {
65
+ if (localStorage.getItem("pu_rail_pinned") !== "true") return;
66
+ if (document.body) document.body.classList.add("pu-rail-pinned");
67
+ document.addEventListener("turbo:before-render", function (event) {
68
+ if (localStorage.getItem("pu_rail_pinned") === "true") {
69
+ event.detail.newBody.classList.add("pu-rail-pinned");
70
+ }
71
+ });
72
+ } catch (e) {}
73
+ })();
74
+ JS
75
+ end
76
+ end
77
+
42
78
  def render_body(&)
43
79
  body(**body_attributes) {
44
80
  render_before_main
@@ -68,7 +104,7 @@ module Plutonium
68
104
  end
69
105
 
70
106
  def render_after_main
71
- turbo_frame_tag("remote_modal")
107
+ turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME)
72
108
  end
73
109
 
74
110
  def render_content(&)
@@ -36,8 +36,7 @@ module Plutonium
36
36
  # @yield The block containing each action's content
37
37
  slot :action, collection: true
38
38
 
39
- # Renders the header component with all its configured elements
40
- # @note The header is fixed positioned and includes responsive design considerations
39
+ # Renders the classic full-width header.
41
40
  # @return [void]
42
41
  def view_template
43
42
  nav(
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlexi-menu"
4
+ require "phlex/slotable"
5
+
6
+ module Plutonium
7
+ module UI
8
+ module Layout
9
+ # A fixed 56px-wide icon-only navigation rail for the app shell.
10
+ # Renders nav items as icon buttons with tooltips; falls back to a 2-letter
11
+ # abbreviation when an item has no icon.
12
+ #
13
+ # When items have children:
14
+ # - Collapsed (default): hovering the parent shows a CSS flyout to the right
15
+ # - Pinned (body.pu-rail-pinned): rail expands to 220px, children collapse inline
16
+ #
17
+ # @example Basic usage
18
+ # render IconRail.new(menu: @menu) do |rail|
19
+ # rail.with_brand { image_tag("logo.svg", class: "w-8 h-8") }
20
+ # end
21
+ class IconRail < Plutonium::UI::Component::Base
22
+ include Phlex::Slotable
23
+
24
+ # @!method brand
25
+ # Slot for the brand mark rendered at the top of the rail.
26
+ slot :brand
27
+
28
+ DEFAULT_MAX_DEPTH = 2
29
+
30
+ # @param menu [Phlexi::Menu::Builder, nil] Menu structure (same shape as SidebarMenu)
31
+ # @param max_depth [Integer] Maximum rendering depth (depth 2 supports parent+children)
32
+ def initialize(menu: nil, max_depth: DEFAULT_MAX_DEPTH)
33
+ @menu = menu
34
+ @max_depth = max_depth
35
+ end
36
+
37
+ def view_template
38
+ aside(
39
+ id: "sidebar-navigation",
40
+ data: {controller: "sidebar icon-rail"},
41
+ aria: {label: "Sidebar Navigation"},
42
+ class: "fixed top-0 left-0 z-40 h-screen " \
43
+ "bg-[var(--pu-surface)] border-r border-[var(--pu-border)] " \
44
+ "flex flex-col transition-[width] duration-200 overflow-x-hidden " \
45
+ "-translate-x-full lg:translate-x-0"
46
+ ) do
47
+ render_brand_section
48
+ render_nav_section
49
+ render_footer_section
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def render_brand_section
56
+ div(class: "h-12 flex items-center justify-center border-b border-[var(--pu-border)] shrink-0") do
57
+ render brand_slot if brand_slot?
58
+ end
59
+ end
60
+
61
+ def render_nav_section
62
+ div(
63
+ id: "sidebar-navigation-content",
64
+ data: {sidebar_target: "scroll"},
65
+ class: "flex-1 overflow-y-auto py-3 flex flex-col items-center gap-1"
66
+ ) do
67
+ render_items(@menu.items, 0) if @menu&.items
68
+ end
69
+ end
70
+
71
+ def render_footer_section
72
+ div(class: "h-14 flex items-center justify-center border-t border-[var(--pu-border)] shrink-0") do
73
+ render_pin_button
74
+ end
75
+ end
76
+
77
+ def render_pin_button
78
+ button(
79
+ type: "button",
80
+ title: "Toggle sidebar",
81
+ aria: {label: "Toggle sidebar pin"},
82
+ data: {action: "icon-rail#togglePin"},
83
+ class: "flex items-center justify-center w-10 h-10 rounded-md transition-colors " \
84
+ "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
85
+ ) do
86
+ # Collapse icon: shown when pinned (body.pu-rail-pinned)
87
+ span(class: "icon-rail-pin-collapse hidden") do
88
+ render Phlex::TablerIcons::LayoutSidebarLeftCollapse.new(class: "w-5 h-5")
89
+ end
90
+ # Expand icon: shown when collapsed (default)
91
+ span(class: "icon-rail-pin-expand") do
92
+ render Phlex::TablerIcons::LayoutSidebarLeftExpand.new(class: "w-5 h-5")
93
+ end
94
+ end
95
+ end
96
+
97
+ # Renders nav items up to @max_depth.
98
+ def render_items(items, depth = 0)
99
+ return if depth >= @max_depth || items.nil? || items.empty?
100
+
101
+ items.each { |item| render_item_link(item, depth) }
102
+ end
103
+
104
+ def render_item_link(item, depth)
105
+ if item.items.any?
106
+ render_parent_item(item, depth)
107
+ else
108
+ render_leaf_item(item, depth)
109
+ end
110
+ end
111
+
112
+ def render_leaf_item(item, depth)
113
+ a(
114
+ href: item.url,
115
+ title: item.label,
116
+ aria: {label: item.label},
117
+ class: "icon-rail-leaf #{leaf_classes(item, depth)}"
118
+ ) do
119
+ render_item_icon(item)
120
+ span(class: "icon-rail-label hidden") { item.label }
121
+ end
122
+ end
123
+
124
+ def render_parent_item(item, depth)
125
+ div(
126
+ class: "icon-rail-parent relative w-full flex flex-col items-center",
127
+ data: {
128
+ controller: "icon-rail-flyout",
129
+ action:
130
+ "mouseenter->icon-rail-flyout#open " \
131
+ "mouseleave->icon-rail-flyout#scheduleClose " \
132
+ "focusin->icon-rail-flyout#open " \
133
+ "focusout->icon-rail-flyout#scheduleClose " \
134
+ "keydown.esc@window->icon-rail-flyout#closeOnEsc"
135
+ }
136
+ ) do
137
+ a(
138
+ href: item.url || "#",
139
+ title: item.label,
140
+ aria: {label: item.label, haspopup: "menu", expanded: "false"},
141
+ data: {
142
+ "icon-rail-flyout-target": "trigger",
143
+ action: "click->icon-rail-flyout#toggle"
144
+ },
145
+ class: "icon-rail-parent-trigger #{parent_trigger_classes(item, depth)}"
146
+ ) do
147
+ render_item_icon(item)
148
+ span(class: "icon-rail-label") { item.label }
149
+ span(class: "icon-rail-chevron", aria_hidden: "true") do
150
+ render Phlex::TablerIcons::ChevronRight.new(class: "w-full h-full")
151
+ end
152
+ end
153
+
154
+ div(
155
+ class: "icon-rail-flyout",
156
+ role: "menu",
157
+ data: {"icon-rail-flyout-target": "panel"}
158
+ ) do
159
+ div(class: "icon-rail-flyout-inner") do
160
+ div(class: "icon-rail-flyout-label") { item.label }
161
+ item.items.each do |child|
162
+ a(href: child.url, class: "icon-rail-flyout-item", role: "menuitem") { child.label }
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ def render_item_icon(item)
170
+ if item.icon
171
+ render item.icon.new(class: "w-5 h-5 shrink-0")
172
+ else
173
+ span(class: "text-xs font-semibold leading-none shrink-0") { abbreviate(item.label) }
174
+ end
175
+ end
176
+
177
+ def leaf_classes(item, depth = 0)
178
+ base = "flex items-center justify-center w-10 h-10 rounded-md transition-colors"
179
+ if active?(item)
180
+ "#{base} bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
181
+ else
182
+ "#{base} text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
183
+ end
184
+ end
185
+
186
+ def parent_trigger_classes(item = nil, depth = 0)
187
+ base = "relative flex items-center justify-center w-10 h-10 rounded-md transition-colors"
188
+ if item && parent_active?(item)
189
+ "#{base} bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
190
+ else
191
+ "#{base} text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
192
+ end
193
+ end
194
+
195
+ # A parent item is "active" if itself or any descendant is active —
196
+ # so the highlight follows the user into nested children.
197
+ def parent_active?(item)
198
+ active?(item) || item.items.any? { |child| active?(child) }
199
+ end
200
+
201
+ # Returns the first 2 letters of the label (letters only, capitalised).
202
+ def abbreviate(label)
203
+ label.to_s.gsub(/[^a-zA-Z]/, "").first(2).capitalize
204
+ end
205
+
206
+ def active?(item)
207
+ item.active?(self)
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -4,9 +4,16 @@ module Plutonium
4
4
  class ResourceLayout < Base
5
5
  private
6
6
 
7
- def main_attributes = mix(super, {
8
- class: "pt-20 lg:ml-64"
9
- })
7
+ def main_attributes
8
+ classes = case Plutonium.configuration.shell
9
+ when :modern
10
+ "pt-16 pb-6 px-6 lg:pl-20"
11
+ else
12
+ "pt-20 lg:ml-64"
13
+ end
14
+
15
+ mix(super, {class: classes})
16
+ end
10
17
 
11
18
  def page_title
12
19
  make_page_title(
@@ -3,42 +3,30 @@
3
3
  module Plutonium
4
4
  module UI
5
5
  module Layout
6
- # A sidebar navigation component that provides a responsive layout with light/dark mode toggle
6
+ # A classic sidebar navigation component that provides a wide, labelled navigation panel.
7
+ #
7
8
  # @example Basic usage with navigation content
8
9
  # render Sidebar.new do
9
- # ...
10
+ # render SidebarMenu.new(menu)
10
11
  # end
11
12
  class Sidebar < Base
12
13
  # Renders the sidebar navigation template
13
14
  # @yield [void] The block containing sidebar content
14
15
  # @return [void]
15
16
  def view_template(&)
16
- render_sidebar_container do
17
- render_content(&) if block_given?
18
- end
19
- end
20
-
21
- private
22
-
23
- # @private
24
- def render_sidebar_container(&)
25
17
  aside(
26
18
  data: {controller: "sidebar"},
27
19
  id: "sidebar-navigation",
28
20
  aria: {label: "Sidebar Navigation"},
29
- class: "fixed top-0 left-0 z-40 w-64 h-screen pt-14 transition-transform -translate-x-full lg:translate-x-0",
30
- &
31
- )
32
- end
33
-
34
- # @private
35
- def render_content(&)
36
- div(
37
- id: "sidebar-navigation-content",
38
- data: {turbo_permanent: true, sidebar_target: "scroll"},
39
- class: "overflow-y-auto py-5 px-3 h-full bg-[var(--pu-surface)] border-r border-[var(--pu-border)]",
40
- &
41
- )
21
+ class: "fixed top-0 left-0 z-40 w-64 h-screen pt-14 transition-transform -translate-x-full lg:translate-x-0"
22
+ ) do
23
+ div(
24
+ id: "sidebar-navigation-content",
25
+ data: {turbo_permanent: true, sidebar_target: "scroll"},
26
+ class: "overflow-y-auto py-5 px-3 h-full bg-[var(--pu-surface)] border-r border-[var(--pu-border)]",
27
+ &
28
+ )
29
+ end
42
30
  end
43
31
  end
44
32
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex/slotable"
4
+
5
+ module Plutonium
6
+ module UI
7
+ module Layout
8
+ # A sticky 48px topbar with breadcrumbs (left), search (center), and actions (right).
9
+ # Pairs with IconRail — offset by `lg:left-14` on desktop to clear the rail.
10
+ # The brand mark lives in IconRail's `with_brand` slot, not here.
11
+ #
12
+ # @example
13
+ # render Topbar.new do |bar|
14
+ # bar.with_breadcrumbs { render BreadcrumbComponent.new }
15
+ # bar.with_search { render SearchComponent.new }
16
+ # bar.with_action { render UserMenuComponent.new }
17
+ # end
18
+ class Topbar < Plutonium::UI::Component::Base
19
+ include Phlex::Slotable
20
+ include Phlex::Rails::Helpers::Routes
21
+
22
+ # @!method breadcrumbs
23
+ # Slot for breadcrumb navigation rendered on the left.
24
+ slot :breadcrumbs
25
+
26
+ # @!method search
27
+ # Slot for a search widget rendered in the center (max-w-[360px]).
28
+ slot :search
29
+
30
+ # @!method action
31
+ # Collection slot for icon buttons / dropdowns rendered on the right.
32
+ slot :action, collection: true
33
+
34
+ def view_template
35
+ nav(
36
+ class: "fixed top-0 right-0 left-0 lg:left-14 z-30 h-12 " \
37
+ "bg-[var(--pu-surface)] border-b border-[var(--pu-border)] " \
38
+ "flex items-center gap-3 px-4",
39
+ data: {
40
+ controller: "resource-header",
41
+ resource_header_sidebar_outlet: "#sidebar-navigation"
42
+ }
43
+ ) do
44
+ render_hamburger
45
+ render_breadcrumbs_section
46
+ render_search_section
47
+ render_actions_section
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def render_hamburger
54
+ button(
55
+ type: "button",
56
+ data_action: "resource-header#toggleDrawer",
57
+ aria_controls: "#sidebar-navigation",
58
+ aria_label: "Toggle sidebar",
59
+ class: "p-1.5 -ml-1.5 text-[var(--pu-text-muted)] rounded-md " \
60
+ "hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] " \
61
+ "lg:hidden transition-colors"
62
+ ) do
63
+ render_hamburger_icons
64
+ end
65
+ end
66
+
67
+ def render_hamburger_icons
68
+ span(data_resource_header_target: "openIcon") do
69
+ render Phlex::TablerIcons::Menu.new(class: "w-5 h-5")
70
+ end
71
+ span(data_resource_header_target: "closeIcon", class: "hidden", aria_hidden: "true") do
72
+ render Phlex::TablerIcons::X.new(class: "w-5 h-5")
73
+ end
74
+ span(class: "sr-only") { "Toggle sidebar" }
75
+ end
76
+
77
+ def render_breadcrumbs_section
78
+ return unless breadcrumbs_slot?
79
+ div(class: "flex items-center min-w-0 flex-shrink") do
80
+ render breadcrumbs_slot
81
+ end
82
+ end
83
+
84
+ def render_search_section
85
+ return unless search_slot?
86
+ div(class: "flex-1 flex justify-center") do
87
+ div(class: "w-full max-w-[360px]") { render search_slot }
88
+ end
89
+ end
90
+
91
+ def render_actions_section
92
+ return unless action_slots?
93
+ div(class: "ml-auto flex items-center gap-1.5") do
94
+ action_slots.each { |action| render action }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end