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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Modal
6
+ class Base < Plutonium::UI::Component::Base
7
+ include Phlex::Slotable
8
+
9
+ slot :close
10
+ slot :footer
11
+
12
+ def initialize(title: nil, description: nil)
13
+ @title = title
14
+ @description = description
15
+ end
16
+
17
+ def view_template(&block)
18
+ dialog(**dialog_attributes) do
19
+ div(class: inner_classes) do
20
+ render_header
21
+ render_body(&block)
22
+ render_footer if footer_slot?
23
+ end
24
+ end
25
+ end
26
+
27
+ protected
28
+
29
+ # Native <dialog>+showModal() handles the focus trap, Esc-to-close,
30
+ # and focus restoration on close. We just need to label the dialog
31
+ # so screen readers announce it on open.
32
+ def dialog_attributes
33
+ attrs = {
34
+ closedby: "any",
35
+ class: dialog_classes,
36
+ data: {controller: "remote-modal"},
37
+ "aria-modal": "true"
38
+ }
39
+ if @title
40
+ attrs[:"aria-labelledby"] = title_id
41
+ else
42
+ attrs[:"aria-label"] = "Dialog"
43
+ end
44
+ attrs[:"aria-describedby"] = description_id if @description.present?
45
+ attrs
46
+ end
47
+
48
+ def title_id
49
+ @title_id ||= "pu-modal-title-#{SecureRandom.hex(4)}"
50
+ end
51
+
52
+ def description_id
53
+ @description_id ||= "pu-modal-desc-#{SecureRandom.hex(4)}"
54
+ end
55
+
56
+ def dialog_classes
57
+ raise NotImplementedError
58
+ end
59
+
60
+ def inner_classes
61
+ "flex flex-col h-full max-h-[inherit] min-h-0"
62
+ end
63
+
64
+ def render_header
65
+ div(class: "flex items-start justify-between gap-4 px-6 pt-5 pb-4 border-b border-[var(--pu-border)]") do
66
+ div(class: "min-w-0 flex-1") do
67
+ if @title
68
+ h2(id: title_id, class: "text-lg font-semibold text-[var(--pu-text)] truncate") { @title }
69
+ end
70
+ if @description.present?
71
+ p(id: description_id, class: "mt-1 text-sm text-[var(--pu-text-muted)]") { @description }
72
+ end
73
+ end
74
+ render_close_button
75
+ end
76
+ end
77
+
78
+ def render_close_button
79
+ if close_slot?
80
+ render close_slot
81
+ else
82
+ button(
83
+ type: "button",
84
+ class: "p-1.5 -m-1.5 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] rounded-md transition-colors",
85
+ data: {action: "remote-modal#close"},
86
+ "aria-label": "Close dialog"
87
+ ) do
88
+ render Phlex::TablerIcons::X.new(class: "w-5 h-5")
89
+ end
90
+ end
91
+ end
92
+
93
+ def render_body(&block)
94
+ # Body is a flex column with no padding/scroll; content owns its
95
+ # own padding and scroll regions. This lets form-shaped content
96
+ # split itself into a scrollable fields region and a pinned
97
+ # action strip flush with the modal's bottom edge.
98
+ div(class: "flex-1 min-h-0 flex flex-col overflow-hidden", &block)
99
+ end
100
+
101
+ def render_footer
102
+ div(class: "flex items-center justify-end gap-2 px-6 py-4 border-t border-[var(--pu-border)]") do
103
+ render footer_slot
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Modal
6
+ class Centered < Plutonium::UI::Modal::Base
7
+ protected
8
+
9
+ def dialog_classes
10
+ "rounded-[var(--pu-radius-lg)] w-full max-w-xl " \
11
+ "bg-[var(--pu-surface)] border border-[var(--pu-border)] " \
12
+ "backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
13
+ "top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
14
+ "max-h-[80vh] " \
15
+ "hidden open:flex flex-col p-0 " \
16
+ "opacity-0 open:opacity-100 transition-opacity duration-200 ease-in-out"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Modal
6
+ class Slideover < Plutonium::UI::Modal::Base
7
+ protected
8
+
9
+ def dialog_classes
10
+ "fixed top-0 right-0 bottom-0 left-auto m-0 h-screen w-full sm:w-[480px] max-w-full max-h-screen " \
11
+ "bg-[var(--pu-surface)] border-l border-[var(--pu-border)] " \
12
+ "backdrop:bg-black/60 backdrop:backdrop-blur-sm " \
13
+ "rounded-none p-0 " \
14
+ "hidden open:flex flex-col " \
15
+ "translate-x-full open:translate-x-0 " \
16
+ "transition-[transform,display,overlay] duration-300 ease-out " \
17
+ "[transition-behavior:allow-discrete] " \
18
+ "starting:open:translate-x-full " \
19
+ "backdrop:transition-[display,overlay,background-color] backdrop:duration-300 " \
20
+ "backdrop:[transition-behavior:allow-discrete] " \
21
+ "starting:open:backdrop:bg-transparent"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -11,13 +11,15 @@ module Plutonium
11
11
  end
12
12
 
13
13
  def view_template(&block)
14
- DynaFrameContent(page_content(block)) do |frame|
14
+ body = block || proc { render_default_content }
15
+
16
+ DynaFrameContent() do
15
17
  render_before_header
16
18
  render_header
17
19
  render_after_header
18
20
 
19
21
  render_before_content
20
- frame.render_content
22
+ body.call
21
23
  render_after_content
22
24
 
23
25
  render_before_footer
@@ -51,6 +53,11 @@ module Plutonium
51
53
  end
52
54
 
53
55
  def render_breadcrumbs?
56
+ # Hide breadcrumbs when rendered inside a turbo frame — the host
57
+ # page already provides the navigation context (e.g., association
58
+ # tabs on a parent show page).
59
+ return false if in_frame?
60
+
54
61
  # Check specific page setting first, fall back to global setting
55
62
  page_specific_setting = current_definition.send(:"#{page_type}_breadcrumbs")
56
63
  page_specific_setting.nil? ? current_definition.breadcrumbs : page_specific_setting
@@ -66,10 +73,6 @@ module Plutonium
66
73
  # Implement toolbar content
67
74
  end
68
75
 
69
- def page_content(block)
70
- block || proc { render_default_content }
71
- end
72
-
73
76
  def render_default_content
74
77
  raise NotImplementedError, "#{self.class}#render_default_content"
75
78
  end
@@ -78,6 +81,22 @@ module Plutonium
78
81
  # Implement footer content
79
82
  end
80
83
 
84
+ # Renders the optional aside (right-side panel) on show pages.
85
+ # No-op by default; future metadata DSL will populate this slot.
86
+ def render_aside
87
+ end
88
+
89
+ # True when the show layout should reserve space for the aside.
90
+ # Returns false by default; pages opt-in by overriding.
91
+ def aside_present? = false
92
+
93
+ # True when the page is rendered inside any turbo frame.
94
+ def in_frame? = current_turbo_frame.present?
95
+
96
+ # True when the page is rendered inside the remote_modal turbo frame.
97
+ # Used by form pages to suppress the sticky footer (modal owns its own footer).
98
+ def in_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
99
+
81
100
  # Customization hooks
82
101
  def render_before_header
83
102
  end
@@ -15,7 +15,19 @@ module Plutonium
15
15
  end
16
16
 
17
17
  def render_default_content
18
- render partial("resource_form")
18
+ if in_modal?
19
+ render_modal_form
20
+ else
21
+ div(class: "pb-20") { render partial("resource_form") }
22
+ end
23
+ end
24
+
25
+ def render_modal_form
26
+ modal_class = (current_definition.modal == :centered) ?
27
+ Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
28
+ render modal_class.new(title: page_title, description: page_description) do
29
+ render partial("resource_form")
30
+ end
19
31
  end
20
32
 
21
33
  def page_type = :edit_page
@@ -4,6 +4,24 @@ module Plutonium
4
4
  module UI
5
5
  module Page
6
6
  class Index < Base
7
+ # Cookie name carrying a per-resource view preference. Single
8
+ # source of truth — Table::Resource, Grid::Resource, and the
9
+ # Stimulus view-switcher controller all read from here. Underscored
10
+ # token-only characters keep this RFC 6265-compliant (the `:` form
11
+ # this replaces is technically forbidden, even if browsers
12
+ # accept it in practice).
13
+ def self.view_cookie_name(resource_class)
14
+ "pu_view_#{resource_class.name.gsub("::", "_").underscore}"
15
+ end
16
+
17
+ # Cookie Path scoped to the engine mount point (request.script_name).
18
+ # Two portals mounting the same resource class get independent
19
+ # view preferences instead of leaking through a site-wide cookie.
20
+ def self.view_cookie_path(request)
21
+ path = request.script_name.to_s
22
+ path.empty? ? "/" : path
23
+ end
24
+
7
25
  private
8
26
 
9
27
  def page_title
@@ -19,7 +37,28 @@ module Plutonium
19
37
  end
20
38
 
21
39
  def render_default_content
22
- render partial("resource_table")
40
+ case selected_view
41
+ when :grid then render partial("resource_grid")
42
+ else render partial("resource_table")
43
+ end
44
+ end
45
+
46
+ # Resolution order:
47
+ # 1. `?view=` URL param (so a shared link can pin a view)
48
+ # 2. The view-preference cookie (sticky per-resource selection)
49
+ # 3. The resource's `default_view` (which itself defaults to
50
+ # `views.first`)
51
+ def selected_view
52
+ definition = current_definition
53
+ enabled = definition.defined_views
54
+
55
+ requested = params[:view]&.to_sym
56
+ return requested if requested && enabled.include?(requested)
57
+
58
+ stored = helpers.cookies[self.class.view_cookie_name(resource_class)]&.to_sym
59
+ return stored if stored && enabled.include?(stored)
60
+
61
+ definition.default_view
23
62
  end
24
63
 
25
64
  def page_type = :index_page
@@ -17,49 +17,18 @@ module Plutonium
17
17
  end
18
18
 
19
19
  def render_default_content
20
- if current_turbo_frame == "remote_modal"
21
- dialog(
22
- closedby: "any",
23
- class: "rounded-[var(--pu-radius-lg)] w-full max-w-3xl
24
- bg-[var(--pu-surface)]
25
- border border-[var(--pu-border)]
26
- backdrop:bg-black/60 backdrop:backdrop-blur-sm
27
- top-auto md:top-1/2 md:-translate-y-1/2 left-1/2 -translate-x-1/2
28
- max-h-[80%] p-6
29
- hidden open:flex flex-col
30
- relative opacity-0 open:opacity-100
31
- transition-opacity duration-300 ease-in-out",
32
- style: "box-shadow: var(--pu-shadow-lg)",
33
- data: {controller: "remote-modal"}
34
- ) do
35
- # Close button
36
- button(
37
- type: "button",
38
- class: "absolute top-4 right-4 p-2 text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors duration-200",
39
- data: {action: "remote-modal#close"},
40
- "aria-label": "Close dialog"
41
- ) do
42
- svg(
43
- class: "w-5 h-5",
44
- fill: "none",
45
- stroke: "currentColor",
46
- viewBox: "0 0 24 24",
47
- xmlns: "http://www.w3.org/2000/svg"
48
- ) do |s|
49
- s.path(
50
- stroke_linecap: "round",
51
- stroke_linejoin: "round",
52
- stroke_width: "2",
53
- d: "M6 18L18 6M6 6l12 12"
54
- )
55
- end
56
- end
20
+ if in_modal?
21
+ modal_class = (current_interactive_action.modal == :slideover) ?
22
+ Plutonium::UI::Modal::Slideover : Plutonium::UI::Modal::Centered
57
23
 
58
- render_page_header
24
+ render modal_class.new(
25
+ title: page_title,
26
+ description: page_description
27
+ ) do
59
28
  render partial("interactive_action_form")
60
29
  end
61
30
  else
62
- render partial("interactive_action_form")
31
+ div(class: "pb-20") { render partial("interactive_action_form") }
63
32
  end
64
33
  end
65
34
 
@@ -15,7 +15,19 @@ module Plutonium
15
15
  end
16
16
 
17
17
  def render_default_content
18
- render partial("resource_form")
18
+ if in_modal?
19
+ render_modal_form
20
+ else
21
+ div(class: "pb-20") { render partial("resource_form") }
22
+ end
23
+ end
24
+
25
+ def render_modal_form
26
+ modal_class = (current_definition.modal == :centered) ?
27
+ Plutonium::UI::Modal::Centered : Plutonium::UI::Modal::Slideover
28
+ render modal_class.new(title: page_title, description: page_description) do
29
+ render partial("resource_form")
30
+ end
19
31
  end
20
32
 
21
33
  def page_type = :new_page
@@ -19,7 +19,14 @@ module Plutonium
19
19
  end
20
20
 
21
21
  def render_default_content
22
- render partial("resource_details")
22
+ if aside_present?
23
+ div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_240px] gap-6") do
24
+ div { render partial("resource_details") }
25
+ aside(class: "hidden lg:block") { render_aside }
26
+ end
27
+ else
28
+ render partial("resource_details")
29
+ end
23
30
  end
24
31
 
25
32
  def page_type = :show_page
@@ -8,30 +8,25 @@ module Plutonium
8
8
  end
9
9
 
10
10
  def view_template
11
- div(class: "sm:flex sm:space-y-0 sm:gap-6 sm:flex-row items-center justify-between space-y-4 mb-8") {
12
- div {
13
- phlexi_render(@title) {
14
- render_title @title
15
- }
16
-
17
- phlexi_render(@description) {
18
- render_description @description
19
- }
20
- }
11
+ div(class: "flex items-start justify-between gap-4 mb-4") do
12
+ div(class: "min-w-0 flex-1") do
13
+ phlexi_render(@title) { render_title @title } if @title
14
+ phlexi_render(@description) { render_description @description } if @description.present?
15
+ end
21
16
  render_actions if @actions.any?
22
- }
17
+ end
23
18
  end
24
19
 
25
20
  private
26
21
 
27
22
  def render_title(title)
28
- h2(class: "mb-2 text-3xl font-bold leading-none tracking-tight text-[var(--pu-text)] md:text-4xl") {
23
+ h1(class: "text-xl font-semibold leading-tight text-[var(--pu-text)] truncate") {
29
24
  title
30
25
  }
31
26
  end
32
27
 
33
28
  def render_description(description)
34
- p(class: "text-lg text-[var(--pu-text-muted)]") {
29
+ p(class: "mt-1 text-sm text-[var(--pu-text-muted)]") {
35
30
  description
36
31
  }
37
32
  end
@@ -1,5 +1,8 @@
1
1
  module Plutonium
2
2
  module UI
3
+ # A lightweight panel: optional title + action items rendered as a small
4
+ # floating cluster in the top-right of the panel; content fills the panel
5
+ # body. No outer card chrome — the panel sits flush in its host.
3
6
  class Panel < Plutonium::UI::Component::Base
4
7
  def initialize
5
8
  @items = []
@@ -23,30 +26,18 @@ module Plutonium
23
26
  end
24
27
 
25
28
  def view_template
26
- wrapped do
27
- render_toolbar if render_toolbar?
28
- render_content if render_content?
29
- end
29
+ render_toolbar if render_toolbar?
30
+ render_content if render_content?
30
31
  end
31
32
 
32
33
  private
33
34
 
34
- def wrapped(&)
35
- div(class: "mt-8", &)
36
- end
37
-
38
35
  def render_toolbar
39
- div(class: "flex justify-between items-center mb-6") do
40
- if @title
41
- h5(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") do
42
- @title
43
- end
44
- end
45
- div(class: "flex gap-3") do
46
- @items.each do |item|
47
- render item
48
- end
36
+ div(class: "flex items-center justify-end gap-0.5 mb-2") do
37
+ if @title.present?
38
+ span(class: "mr-auto text-[10px] font-semibold uppercase tracking-wider text-[var(--pu-text-muted)]") { @title }
49
39
  end
40
+ @items.each { |item| render item }
50
41
  end
51
42
  end
52
43
 
@@ -55,7 +46,7 @@ module Plutonium
55
46
  end
56
47
 
57
48
  def render_toolbar?
58
- @title || @items
49
+ @title.present? || @items.any?
59
50
  end
60
51
 
61
52
  def render_content?
@@ -2,8 +2,8 @@ require "phlexi-menu"
2
2
 
3
3
  module Plutonium
4
4
  module UI
5
- # A sidebar navigation component that renders a max depth of 2 levels
6
- # Provides collapsible menu sections and is compatible with turbo-permanent
5
+ # A sidebar navigation component that renders a max depth of 2 levels.
6
+ # Provides collapsible menu sections and is compatible with turbo-permanent.
7
7
  class SidebarMenu < Phlexi::Menu::Component
8
8
  include Plutonium::UI::Component::Behaviour
9
9
 
@@ -64,29 +64,6 @@ module Plutonium
64
64
  end
65
65
  end
66
66
 
67
- # def render_items(items, depth = 0)
68
- # return if depth >= @max_depth
69
-
70
- # if depth.zero?
71
- # ul(class: themed(:items_container, depth)) do
72
- # items.each do |item|
73
- # render_item_wrapper(item, depth)
74
- # end
75
- # end
76
- # else
77
- # # Use collapsible rendering for nested levels
78
- # ul(
79
- # id: generate_menu_id(:root),
80
- # class: themed(:sub_items_container, depth),
81
- # data: {"resource-collapse-target": "menu"}
82
- # ) do
83
- # items.each do |item|
84
- # render_item_wrapper(item, depth)
85
- # end
86
- # end
87
- # end
88
- # end
89
-
90
67
  def render_item_wrapper(item, depth)
91
68
  wrapper_attrs = {
92
69
  class: tokens(themed(:item_wrapper, depth)),
@@ -4,6 +4,10 @@ module Plutonium
4
4
  class TabDefinition
5
5
  end
6
6
 
7
+ BASE_BUTTON_CLASSES = "inline-block px-5 py-3 border-b-2 rounded-t-lg transition-colors"
8
+ ACTIVE_CLASSES = "focus:outline-none text-primary-600 border-primary-600 dark:text-primary-400 dark:border-primary-400"
9
+ INACTIVE_CLASSES = "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] border-transparent hover:border-[var(--pu-border-strong)]"
10
+
7
11
  def initialize(...)
8
12
  super
9
13
 
@@ -19,25 +23,30 @@ module Plutonium
19
23
  end
20
24
 
21
25
  def view_template
26
+ default_identifier = @tabs.first&.dig(:identifier)
27
+
22
28
  div(
23
29
  data_controller: "resource-tab-list",
24
- data_resource_tab_list_active_classes_value: "focus:outline-none text-primary-600 border-primary-600 dark:text-primary-400 dark:border-primary-400",
25
- data_resource_tab_list_in_active_classes_value: "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] border-transparent hover:border-[var(--pu-border-strong)]"
30
+ data_resource_tab_list_active_classes_value: ACTIVE_CLASSES,
31
+ data_resource_tab_list_in_active_classes_value: INACTIVE_CLASSES
26
32
  ) do
27
- div(class: "mb-6 border-b border-[var(--pu-border)]") do
33
+ div(class: "relative mb-6 border-b border-[var(--pu-border)]") do
28
34
  ul(
29
- class: "flex flex-wrap -mb-px text-base font-semibold text-center gap-1",
35
+ class: "flex flex-nowrap overflow-x-auto whitespace-nowrap -mb-px text-base font-semibold gap-1 " \
36
+ "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
30
37
  role: "tablist"
31
38
  ) do
32
39
  @tabs.each do |tab|
40
+ active = tab[:identifier] == default_identifier
33
41
  li(role: "presentation") do
34
42
  button(
35
- class: "inline-block px-5 py-3 border-b-2 rounded-t-lg transition-colors",
43
+ class: button_classes_for(active),
36
44
  id: "#{tab[:identifier]}-tab",
37
45
  type: "button",
38
46
  role: "tab",
39
47
  aria_controls: "#{tab[:identifier]}-tabpanel",
40
- aria_selected: "false",
48
+ aria_selected: active.to_s,
49
+ tabindex: active ? "0" : "-1",
41
50
  data_resource_tab_list_target: "btn",
42
51
  data_target: "#{tab[:identifier]}-tabpanel",
43
52
  data_action: "click->resource-tab-list#select"
@@ -49,15 +58,22 @@ module Plutonium
49
58
  end
50
59
  end
51
60
  end
61
+ div(
62
+ class: "pointer-events-none absolute right-0 top-0 bottom-0 w-8 " \
63
+ "bg-gradient-to-l from-[var(--pu-body)] to-transparent",
64
+ aria_hidden: "true"
65
+ )
52
66
  end
53
67
 
54
68
  div do
55
69
  @tabs.each do |tab|
70
+ active = tab[:identifier] == default_identifier
56
71
  div(
57
- hidden: true,
72
+ hidden: !active,
58
73
  id: "#{tab[:identifier]}-tabpanel",
59
74
  role: "tabpanel",
60
75
  aria_labelledby: "#{tab[:identifier]}-tab",
76
+ aria_hidden: (!active).to_s,
61
77
  data_resource_tab_list_target: "tab"
62
78
  ) do
63
79
  phlexi_render tab[:block] do |val|
@@ -68,6 +84,12 @@ module Plutonium
68
84
  end
69
85
  end
70
86
  end
87
+
88
+ private
89
+
90
+ def button_classes_for(active)
91
+ "#{BASE_BUTTON_CLASSES} #{active ? ACTIVE_CLASSES : INACTIVE_CLASSES}"
92
+ end
71
93
  end
72
94
  end
73
95
  end