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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-definition/SKILL.md +87 -2
- data/.claude/skills/plutonium-installation/SKILL.md +6 -0
- data/.claude/skills/plutonium-views/SKILL.md +59 -0
- data/CHANGELOG.md +12 -0
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.js +369 -25
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +45 -45
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +4 -4
- data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
- data/app/views/resource/_resource_grid.html.erb +1 -0
- data/config/brakeman.ignore +25 -2
- data/docs/reference/definition/actions.md +14 -1
- data/docs/reference/definition/index.md +58 -0
- data/docs/reference/views/index.md +43 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
- data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
- data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
- 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/core/update/update_generator.rb +20 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
- data/lib/plutonium/action/base.rb +44 -1
- data/lib/plutonium/action/interactive.rb +1 -1
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/definition/actions.rb +3 -0
- data/lib/plutonium/definition/base.rb +8 -0
- data/lib/plutonium/definition/metadata.rb +40 -0
- data/lib/plutonium/definition/views.rb +94 -0
- data/lib/plutonium/helpers/turbo_helper.rb +1 -1
- data/lib/plutonium/interaction/response/redirect.rb +1 -1
- data/lib/plutonium/query/base.rb +8 -0
- data/lib/plutonium/query/filters/association.rb +30 -8
- data/lib/plutonium/query/filters/boolean.rb +5 -0
- data/lib/plutonium/resource/controllers/presentable.rb +11 -2
- data/lib/plutonium/resource/definition.rb +42 -0
- data/lib/plutonium/resource/query_object.rb +64 -6
- data/lib/plutonium/testing/resource_definition.rb +2 -2
- data/lib/plutonium/ui/action_button.rb +4 -2
- data/lib/plutonium/ui/component/kit.rb +12 -0
- data/lib/plutonium/ui/display/base.rb +3 -1
- data/lib/plutonium/ui/display/resource.rb +109 -25
- data/lib/plutonium/ui/display/theme.rb +2 -1
- data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
- data/lib/plutonium/ui/empty_card.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +29 -1
- data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
- data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
- data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
- data/lib/plutonium/ui/form/resource.rb +48 -9
- data/lib/plutonium/ui/form/theme.rb +1 -1
- data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
- data/lib/plutonium/ui/grid/card.rb +235 -0
- data/lib/plutonium/ui/grid/resource.rb +149 -0
- data/lib/plutonium/ui/layout/base.rb +37 -1
- data/lib/plutonium/ui/layout/header.rb +1 -2
- data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
- data/lib/plutonium/ui/layout/sidebar.rb +12 -24
- data/lib/plutonium/ui/layout/topbar.rb +100 -0
- data/lib/plutonium/ui/modal/base.rb +109 -0
- data/lib/plutonium/ui/modal/centered.rb +21 -0
- data/lib/plutonium/ui/modal/slideover.rb +26 -0
- data/lib/plutonium/ui/page/base.rb +25 -6
- data/lib/plutonium/ui/page/edit.rb +13 -1
- data/lib/plutonium/ui/page/index.rb +40 -1
- data/lib/plutonium/ui/page/interactive_action.rb +8 -39
- data/lib/plutonium/ui/page/new.rb +13 -1
- data/lib/plutonium/ui/page/show.rb +8 -1
- data/lib/plutonium/ui/page_header.rb +8 -13
- data/lib/plutonium/ui/panel.rb +10 -19
- data/lib/plutonium/ui/sidebar_menu.rb +2 -25
- data/lib/plutonium/ui/tab_list.rb +29 -7
- data/lib/plutonium/ui/table/base.rb +106 -0
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
- data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
- data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
- data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
- data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
- data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
- data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
- data/lib/plutonium/ui/table/resource.rb +158 -89
- data/lib/plutonium/ui/table/theme.rb +14 -5
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +6 -0
- data/package.json +1 -1
- data/src/css/components.css +304 -131
- data/src/css/tokens.css +101 -85
- data/src/js/controllers/autosubmit_controller.js +24 -0
- data/src/js/controllers/bulk_actions_controller.js +15 -16
- data/src/js/controllers/capture_url_controller.js +14 -0
- data/src/js/controllers/filter_panel_controller.js +77 -19
- data/src/js/controllers/frame_navigator_controller.js +34 -6
- data/src/js/controllers/icon_rail_controller.js +22 -0
- data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
- data/src/js/controllers/register_controllers.js +16 -0
- data/src/js/controllers/resource_tab_list_controller.js +56 -3
- data/src/js/controllers/row_click_controller.js +21 -0
- data/src/js/controllers/table_column_menu_controller.js +43 -0
- data/src/js/controllers/table_header_controller.js +16 -0
- data/src/js/controllers/view_switcher_controller.js +29 -0
- 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(
|
|
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
|
|
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
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|