shadcn-rails 0.1.0 → 0.2.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -1
  3. data/CLAUDE.md +151 -2
  4. data/PROGRESS.md +30 -20
  5. data/README.md +89 -1398
  6. data/Rakefile +66 -0
  7. data/__tests__/controllers/combobox_controller.test.js +56 -51
  8. data/__tests__/controllers/context_menu_controller.test.js +280 -2
  9. data/__tests__/controllers/menubar_controller.test.js +5 -4
  10. data/__tests__/controllers/navigation_menu_controller.test.js +5 -4
  11. data/__tests__/controllers/popover_controller.test.js +35 -60
  12. data/__tests__/controllers/select_controller.test.js +5 -1
  13. data/app/assets/javascripts/shadcn/controllers/base_menu_controller.js +266 -0
  14. data/app/assets/javascripts/shadcn/controllers/combobox_controller.js +13 -8
  15. data/app/assets/javascripts/shadcn/controllers/command_controller.js +5 -1
  16. data/app/assets/javascripts/shadcn/controllers/context_menu_controller.js +61 -105
  17. data/app/assets/javascripts/shadcn/controllers/dropdown_controller.js +49 -170
  18. data/app/assets/javascripts/shadcn/controllers/menubar_controller.js +10 -7
  19. data/app/assets/javascripts/shadcn/controllers/navigation_menu_controller.js +10 -6
  20. data/app/assets/javascripts/shadcn/controllers/popover_controller.js +7 -7
  21. data/app/assets/javascripts/shadcn/controllers/select_controller.js +12 -10
  22. data/app/assets/javascripts/shadcn/controllers/sidebar_controller.js +24 -14
  23. data/app/assets/javascripts/shadcn/index.js +2 -0
  24. data/app/assets/stylesheets/shadcn/components.css +12 -0
  25. data/app/components/shadcn/command_list_component.rb +29 -14
  26. data/app/components/shadcn/context_menu_checkbox_item_component.rb +76 -0
  27. data/app/components/shadcn/context_menu_content_component.rb +37 -14
  28. data/app/components/shadcn/context_menu_item_component.rb +3 -2
  29. data/app/components/shadcn/context_menu_radio_group_component.rb +42 -0
  30. data/app/components/shadcn/context_menu_radio_item_component.rb +76 -0
  31. data/app/components/shadcn/dropdown_menu_checkbox_item_component.rb +76 -0
  32. data/app/components/shadcn/dropdown_menu_content_component.rb +45 -16
  33. data/app/components/shadcn/dropdown_menu_radio_group_component.rb +42 -0
  34. data/app/components/shadcn/dropdown_menu_radio_item_component.rb +76 -0
  35. data/app/components/shadcn/menubar_content_component.rb +45 -20
  36. data/app/components/shadcn/menubar_sub_content_component.rb +21 -8
  37. data/app/components/shadcn/radio_group_item_component.rb +32 -6
  38. data/app/components/shadcn/resizable_panel_group_component.rb +27 -16
  39. data/app/components/shadcn/select_component.rb +23 -6
  40. data/bin/bump +321 -0
  41. data/bin/release +205 -0
  42. data/bin/test +75 -0
  43. data/jest.config.js +1 -1
  44. data/lib/shadcn/rails/version.rb +1 -1
  45. data/package-lock.json +27 -4
  46. data/package.json +4 -1
  47. metadata +11 -1
@@ -1,4 +1,5 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
+ import { useMatchMedia } from "stimulus-use"
2
3
 
3
4
  // Constants for sidebar dimensions
4
5
  const SIDEBAR_COOKIE_NAME = "sidebar:state"
@@ -7,6 +8,10 @@ const SIDEBAR_WIDTH = "16rem"
7
8
  const SIDEBAR_WIDTH_MOBILE = "18rem"
8
9
  const SIDEBAR_WIDTH_ICON = "3rem"
9
10
 
11
+ /**
12
+ * Sidebar Controller
13
+ * Uses stimulus-use useMatchMedia for responsive behavior
14
+ */
10
15
  export default class extends Controller {
11
16
  static targets = ["sidebar"]
12
17
  static values = {
@@ -26,10 +31,13 @@ export default class extends Controller {
26
31
  this.handleKeyDown = this.handleKeyDown.bind(this)
27
32
  document.addEventListener("keydown", this.handleKeyDown)
28
33
 
29
- // Set up mobile detection
34
+ // Use stimulus-use for responsive media query detection
30
35
  this.isMobile = window.innerWidth < 768
31
- this.handleResize = this.handleResize.bind(this)
32
- window.addEventListener("resize", this.handleResize)
36
+ useMatchMedia(this, {
37
+ mediaQueries: {
38
+ mobile: "(max-width: 767px)"
39
+ }
40
+ })
33
41
 
34
42
  // Initial state sync
35
43
  this.syncState()
@@ -37,7 +45,19 @@ export default class extends Controller {
37
45
 
38
46
  disconnect() {
39
47
  document.removeEventListener("keydown", this.handleKeyDown)
40
- window.removeEventListener("resize", this.handleResize)
48
+ }
49
+
50
+ // Called by stimulus-use when mobile media query state changes
51
+ mobileChanged({ matches }) {
52
+ const wasMobile = this.isMobile
53
+ this.isMobile = matches
54
+
55
+ // Close mobile sidebar when switching to desktop
56
+ if (wasMobile && !this.isMobile) {
57
+ this.openMobileValue = false
58
+ }
59
+
60
+ this.syncState()
41
61
  }
42
62
 
43
63
  handleKeyDown(event) {
@@ -51,16 +71,6 @@ export default class extends Controller {
51
71
  }
52
72
  }
53
73
 
54
- handleResize() {
55
- const wasMobile = this.isMobile
56
- this.isMobile = window.innerWidth < 768
57
-
58
- // Close mobile sidebar when switching to desktop
59
- if (wasMobile && !this.isMobile) {
60
- this.openMobileValue = false
61
- }
62
- }
63
-
64
74
  toggle() {
65
75
  if (this.isMobile) {
66
76
  this.openMobileValue = !this.openMobileValue
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  // Import all controllers
17
+ import BaseMenuController from "./controllers/base_menu_controller"
17
18
  import AccordionController from "./controllers/accordion_controller"
18
19
  import AvatarController from "./controllers/avatar_controller"
19
20
  import CalendarController from "./controllers/calendar_controller"
@@ -49,6 +50,7 @@ import SidebarController from "./controllers/sidebar_controller"
49
50
 
50
51
  // Export individual controllers
51
52
  export {
53
+ BaseMenuController,
52
54
  AccordionController,
53
55
  AvatarController,
54
56
  CalendarController,
@@ -437,6 +437,18 @@ button[data-state="off"].shadcn-toggle {
437
437
  animation: fade-out 150ms ease-in, zoom-out 150ms ease-in;
438
438
  }
439
439
 
440
+ /* ============================================
441
+ Context Menu Component
442
+ ============================================ */
443
+
444
+ .shadcn-context-menu[data-state="open"] {
445
+ animation: fade-in 100ms ease-out, zoom-in 100ms ease-out;
446
+ }
447
+
448
+ .shadcn-context-menu[data-state="closed"] {
449
+ animation: fade-out 100ms ease-in, zoom-out 100ms ease-in;
450
+ }
451
+
440
452
  /* ============================================
441
453
  Drawer Component
442
454
  ============================================ */
@@ -10,19 +10,26 @@ module Shadcn
10
10
  CommandEmptyComponent.new(**options)
11
11
  }
12
12
 
13
- # Groups of items
14
- renders_many :groups, lambda { |heading: nil, **options|
15
- CommandGroupComponent.new(heading: heading, **options)
16
- }
17
-
18
- # Direct items (without group)
19
- renders_many :items, lambda { |value: nil, disabled: false, **options|
20
- CommandItemComponent.new(value: value, disabled: disabled, **options)
21
- }
22
-
23
- # Separators
24
- renders_many :separators, lambda { |**options|
25
- CommandSeparatorComponent.new(**options)
13
+ # Use polymorphic slots to preserve the order of groups, items, and separators
14
+ renders_many :list_items, types: {
15
+ group: {
16
+ renders: lambda { |heading: nil, **options, &block|
17
+ CommandGroupComponent.new(heading: heading, **options, &block)
18
+ },
19
+ as: :group
20
+ },
21
+ item: {
22
+ renders: lambda { |value: nil, disabled: false, **options, &block|
23
+ CommandItemComponent.new(value: value, disabled: disabled, **options, &block)
24
+ },
25
+ as: :item
26
+ },
27
+ separator: {
28
+ renders: lambda { |**options|
29
+ CommandSeparatorComponent.new(**options)
30
+ },
31
+ as: :separator
32
+ }
26
33
  }
27
34
 
28
35
  def call
@@ -32,7 +39,15 @@ module Shadcn
32
39
  private
33
40
 
34
41
  def list_content
35
- safe_join([empty, groups, items, separators, content].flatten.compact)
42
+ # Trigger slot evaluation first by accessing content
43
+ raw_content = content
44
+ # If polymorphic slots were used, render them in order with empty at the start
45
+ if list_items.any?
46
+ safe_join([empty, list_items].flatten.compact)
47
+ else
48
+ # Otherwise render the raw block content (for backwards compatibility)
49
+ safe_join([empty, raw_content].flatten.compact)
50
+ end
36
51
  end
37
52
  end
38
53
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadcn
4
+ # Context Menu Checkbox Item component
5
+ # A menu item that can be checked/unchecked
6
+ class ContextMenuCheckboxItemComponent < BaseComponent
7
+ BASE_CLASSES = "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
8
+
9
+ renders_one :shortcut, lambda { |**options|
10
+ ContextMenuShortcutComponent.new(**options)
11
+ }
12
+
13
+ # @param checked [Boolean] Whether item is checked
14
+ # @param disabled [Boolean] Whether item is disabled
15
+ def initialize(checked: false, disabled: false, **options, &block)
16
+ super(**options, &block)
17
+ @checked = checked
18
+ @disabled = disabled
19
+ end
20
+
21
+ def call
22
+ content_tag(:div, item_content, item_attributes)
23
+ end
24
+
25
+ private
26
+
27
+ def item_content
28
+ safe_join([
29
+ check_indicator,
30
+ content,
31
+ shortcut
32
+ ].compact)
33
+ end
34
+
35
+ def check_indicator
36
+ content_tag(:span, check_icon, class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center")
37
+ end
38
+
39
+ def check_icon
40
+ return "" unless @checked
41
+
42
+ content_tag(:svg, check_svg_path, {
43
+ xmlns: "http://www.w3.org/2000/svg",
44
+ width: "16",
45
+ height: "16",
46
+ viewBox: "0 0 24 24",
47
+ fill: "none",
48
+ stroke: "currentColor",
49
+ "stroke-width": "2",
50
+ "stroke-linecap": "round",
51
+ "stroke-linejoin": "round",
52
+ class: "h-4 w-4"
53
+ })
54
+ end
55
+
56
+ def check_svg_path
57
+ content_tag(:polyline, "", points: "20 6 9 17 4 12")
58
+ end
59
+
60
+ def item_attributes
61
+ attrs = {
62
+ class: cn(BASE_CLASSES, class_name),
63
+ role: "menuitemcheckbox",
64
+ "aria-checked": @checked.to_s,
65
+ tabindex: @disabled ? nil : "-1",
66
+ "data-disabled": @disabled ? "" : nil,
67
+ "data-state": @checked ? "checked" : "unchecked",
68
+ "data-shadcn--context-menu-target": "item",
69
+ "data-action": "click->shadcn--context-menu#selectItem"
70
+ }
71
+ attrs.merge!(html_options)
72
+ attrs.merge!(build_data)
73
+ attrs.compact
74
+ end
75
+ end
76
+ end
@@ -3,16 +3,40 @@
3
3
  module Shadcn
4
4
  # Context Menu Content component
5
5
  class ContextMenuContentComponent < BaseComponent
6
- BASE_CLASSES = "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
6
+ BASE_CLASSES = "shadcn-context-menu z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
7
7
 
8
- renders_many :items, lambda { |**options, &block|
9
- ContextMenuItemComponent.new(**options, &block)
10
- }
11
- renders_many :labels, lambda { |**options, &block|
12
- ContextMenuLabelComponent.new(**options, &block)
13
- }
14
- renders_many :separators, lambda { |**options|
15
- ContextMenuSeparatorComponent.new(**options)
8
+ # Use polymorphic slots to preserve the order of items, labels, and separators
9
+ renders_many :menu_items, types: {
10
+ item: {
11
+ renders: lambda { |**options, &block|
12
+ ContextMenuItemComponent.new(**options, &block)
13
+ },
14
+ as: :item
15
+ },
16
+ checkbox_item: {
17
+ renders: lambda { |**options, &block|
18
+ ContextMenuCheckboxItemComponent.new(**options, &block)
19
+ },
20
+ as: :checkbox_item
21
+ },
22
+ radio_group: {
23
+ renders: lambda { |**options, &block|
24
+ ContextMenuRadioGroupComponent.new(**options, &block)
25
+ },
26
+ as: :radio_group
27
+ },
28
+ label: {
29
+ renders: lambda { |**options, &block|
30
+ ContextMenuLabelComponent.new(**options, &block)
31
+ },
32
+ as: :label
33
+ },
34
+ separator: {
35
+ renders: lambda { |**options|
36
+ ContextMenuSeparatorComponent.new(**options)
37
+ },
38
+ as: :separator
39
+ }
16
40
  }
17
41
 
18
42
  def call
@@ -22,11 +46,10 @@ module Shadcn
22
46
  private
23
47
 
24
48
  def menu_content
25
- if items.any? || labels.any? || separators.any?
26
- safe_join([labels, items, separators, content].flatten.compact)
27
- else
28
- content
29
- end
49
+ # Trigger slot evaluation first
50
+ content
51
+ # Render all menu items in the order they were added
52
+ safe_join(menu_items)
30
53
  end
31
54
 
32
55
  def menu_attributes
@@ -3,11 +3,11 @@
3
3
  module Shadcn
4
4
  # Context Menu Item component
5
5
  class ContextMenuItemComponent < BaseComponent
6
- BASE_CLASSES = "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0"
6
+ BASE_CLASSES = "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0"
7
7
 
8
8
  VARIANTS = {
9
9
  default: "",
10
- destructive: "text-destructive focus:bg-destructive focus:text-destructive-foreground"
10
+ destructive: "text-destructive hover:bg-destructive hover:text-destructive-foreground focus:bg-destructive focus:text-destructive-foreground"
11
11
  }.freeze
12
12
 
13
13
  renders_one :shortcut, lambda { |**options|
@@ -53,6 +53,7 @@ module Shadcn
53
53
  tabindex: @disabled ? nil : "-1",
54
54
  href: @href,
55
55
  "data-disabled": @disabled ? "" : nil,
56
+ "data-shadcn--context-menu-target": "item",
56
57
  "data-action": "click->shadcn--context-menu#selectItem"
57
58
  }
58
59
  attrs.merge!(html_options)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadcn
4
+ # Context Menu Radio Group component
5
+ # Group of mutually exclusive radio items
6
+ class ContextMenuRadioGroupComponent < BaseComponent
7
+ renders_many :items, lambda { |**options, &block|
8
+ ContextMenuRadioItemComponent.new(**options, &block)
9
+ }
10
+
11
+ # @param value [String] Currently selected value
12
+ def initialize(value: nil, **options, &block)
13
+ super(**options, &block)
14
+ @value = value
15
+ end
16
+
17
+ def call
18
+ content_tag(:div, group_content, group_attributes)
19
+ end
20
+
21
+ private
22
+
23
+ def group_content
24
+ if items.any?
25
+ safe_join(items)
26
+ else
27
+ content
28
+ end
29
+ end
30
+
31
+ def group_attributes
32
+ attrs = {
33
+ class: class_name,
34
+ role: "group",
35
+ "data-value": @value
36
+ }
37
+ attrs.merge!(html_options)
38
+ attrs.merge!(build_data)
39
+ attrs.compact
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadcn
4
+ # Context Menu Radio Item component
5
+ # A radio button within a radio group
6
+ class ContextMenuRadioItemComponent < BaseComponent
7
+ BASE_CLASSES = "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
8
+
9
+ renders_one :shortcut, lambda { |**options|
10
+ ContextMenuShortcutComponent.new(**options)
11
+ }
12
+
13
+ # @param value [String] Value of this radio item
14
+ # @param checked [Boolean] Whether item is selected
15
+ # @param disabled [Boolean] Whether item is disabled
16
+ def initialize(value: nil, checked: false, disabled: false, **options, &block)
17
+ super(**options, &block)
18
+ @value = value
19
+ @checked = checked
20
+ @disabled = disabled
21
+ end
22
+
23
+ def call
24
+ content_tag(:div, item_content, item_attributes)
25
+ end
26
+
27
+ private
28
+
29
+ def item_content
30
+ safe_join([
31
+ radio_indicator,
32
+ content,
33
+ shortcut
34
+ ].compact)
35
+ end
36
+
37
+ def radio_indicator
38
+ content_tag(:span, radio_icon, class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center")
39
+ end
40
+
41
+ def radio_icon
42
+ return "" unless @checked
43
+
44
+ content_tag(:svg, circle_svg, {
45
+ xmlns: "http://www.w3.org/2000/svg",
46
+ width: "16",
47
+ height: "16",
48
+ viewBox: "0 0 24 24",
49
+ fill: "currentColor",
50
+ stroke: "none",
51
+ class: "h-4 w-4"
52
+ })
53
+ end
54
+
55
+ def circle_svg
56
+ content_tag(:circle, "", cx: "12", cy: "12", r: "6")
57
+ end
58
+
59
+ def item_attributes
60
+ attrs = {
61
+ class: cn(BASE_CLASSES, class_name),
62
+ role: "menuitemradio",
63
+ "aria-checked": @checked.to_s,
64
+ tabindex: @disabled ? nil : "-1",
65
+ "data-disabled": @disabled ? "" : nil,
66
+ "data-state": @checked ? "checked" : "unchecked",
67
+ "data-value": @value,
68
+ "data-shadcn--context-menu-target": "item",
69
+ "data-action": "click->shadcn--context-menu#selectItem"
70
+ }
71
+ attrs.merge!(html_options)
72
+ attrs.merge!(build_data)
73
+ attrs.compact
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadcn
4
+ # Dropdown Menu Checkbox Item component
5
+ # A menu item that can be checked/unchecked
6
+ class DropdownMenuCheckboxItemComponent < BaseComponent
7
+ BASE_CLASSES = "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
8
+
9
+ renders_one :shortcut, lambda { |**options|
10
+ DropdownMenuShortcutComponent.new(**options)
11
+ }
12
+
13
+ # @param checked [Boolean] Whether item is checked
14
+ # @param disabled [Boolean] Whether item is disabled
15
+ def initialize(checked: false, disabled: false, **options, &block)
16
+ super(**options, &block)
17
+ @checked = checked
18
+ @disabled = disabled
19
+ end
20
+
21
+ def call
22
+ content_tag(:div, item_content, item_attributes)
23
+ end
24
+
25
+ private
26
+
27
+ def item_content
28
+ safe_join([
29
+ check_indicator,
30
+ content,
31
+ shortcut
32
+ ].compact)
33
+ end
34
+
35
+ def check_indicator
36
+ content_tag(:span, check_icon, class: "absolute left-2 flex h-3.5 w-3.5 items-center justify-center")
37
+ end
38
+
39
+ def check_icon
40
+ return "" unless @checked
41
+
42
+ content_tag(:svg, check_svg_path, {
43
+ xmlns: "http://www.w3.org/2000/svg",
44
+ width: "16",
45
+ height: "16",
46
+ viewBox: "0 0 24 24",
47
+ fill: "none",
48
+ stroke: "currentColor",
49
+ "stroke-width": "2",
50
+ "stroke-linecap": "round",
51
+ "stroke-linejoin": "round",
52
+ class: "h-4 w-4"
53
+ })
54
+ end
55
+
56
+ def check_svg_path
57
+ content_tag(:polyline, "", points: "20 6 9 17 4 12")
58
+ end
59
+
60
+ def item_attributes
61
+ attrs = {
62
+ class: cn(BASE_CLASSES, class_name),
63
+ role: "menuitemcheckbox",
64
+ "aria-checked": @checked.to_s,
65
+ tabindex: @disabled ? nil : "-1",
66
+ "data-disabled": @disabled ? "" : nil,
67
+ "data-state": @checked ? "checked" : "unchecked",
68
+ "data-shadcn--dropdown-target": "item",
69
+ "data-action": "click->shadcn--dropdown#toggleCheckbox"
70
+ }
71
+ attrs.merge!(html_options)
72
+ attrs.merge!(build_data)
73
+ attrs.compact
74
+ end
75
+ end
76
+ end
@@ -5,17 +5,44 @@ module Shadcn
5
5
  class DropdownMenuContentComponent < BaseComponent
6
6
  BASE_CLASSES = "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
7
7
 
8
- renders_many :items, lambda { |**options, &block|
9
- DropdownMenuItemComponent.new(**options, &block)
10
- }
11
- renders_many :labels, lambda { |**options, &block|
12
- DropdownMenuLabelComponent.new(**options, &block)
13
- }
14
- renders_many :separators, lambda { |**options|
15
- DropdownMenuSeparatorComponent.new(**options)
16
- }
17
- renders_many :groups, lambda { |**options, &block|
18
- DropdownMenuGroupComponent.new(**options, &block)
8
+ # Use polymorphic slots to preserve the order of items, labels, separators, groups, etc.
9
+ renders_many :menu_items, types: {
10
+ item: {
11
+ renders: lambda { |**options, &block|
12
+ DropdownMenuItemComponent.new(**options, &block)
13
+ },
14
+ as: :item
15
+ },
16
+ label: {
17
+ renders: lambda { |**options, &block|
18
+ DropdownMenuLabelComponent.new(**options, &block)
19
+ },
20
+ as: :label
21
+ },
22
+ separator: {
23
+ renders: lambda { |**options|
24
+ DropdownMenuSeparatorComponent.new(**options)
25
+ },
26
+ as: :separator
27
+ },
28
+ group: {
29
+ renders: lambda { |**options, &block|
30
+ DropdownMenuGroupComponent.new(**options, &block)
31
+ },
32
+ as: :group
33
+ },
34
+ checkbox_item: {
35
+ renders: lambda { |**options, &block|
36
+ DropdownMenuCheckboxItemComponent.new(**options, &block)
37
+ },
38
+ as: :checkbox_item
39
+ },
40
+ radio_group: {
41
+ renders: lambda { |**options, &block|
42
+ DropdownMenuRadioGroupComponent.new(**options, &block)
43
+ },
44
+ as: :radio_group
45
+ }
19
46
  }
20
47
 
21
48
  def call
@@ -25,12 +52,14 @@ module Shadcn
25
52
  private
26
53
 
27
54
  def menu_content
28
- # If items/labels/separators are used, render them
29
- # Otherwise render the block content
30
- if items.any? || labels.any? || separators.any? || groups.any?
31
- safe_join([labels, items, separators, groups, content].flatten.compact)
55
+ # Trigger slot evaluation first by accessing content
56
+ raw_content = content
57
+ # If polymorphic slots were used, render them in order
58
+ if menu_items.any?
59
+ safe_join(menu_items)
32
60
  else
33
- content
61
+ # Otherwise render the raw block content (for backwards compatibility)
62
+ raw_content
34
63
  end
35
64
  end
36
65
 
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadcn
4
+ # Dropdown Menu Radio Group component
5
+ # Group of mutually exclusive radio items
6
+ class DropdownMenuRadioGroupComponent < BaseComponent
7
+ renders_many :items, lambda { |**options, &block|
8
+ DropdownMenuRadioItemComponent.new(**options, &block)
9
+ }
10
+
11
+ # @param value [String] Currently selected value
12
+ def initialize(value: nil, **options, &block)
13
+ super(**options, &block)
14
+ @value = value
15
+ end
16
+
17
+ def call
18
+ content_tag(:div, group_content, group_attributes)
19
+ end
20
+
21
+ private
22
+
23
+ def group_content
24
+ if items.any?
25
+ safe_join(items)
26
+ else
27
+ content
28
+ end
29
+ end
30
+
31
+ def group_attributes
32
+ attrs = {
33
+ class: class_name,
34
+ role: "group",
35
+ "data-value": @value
36
+ }
37
+ attrs.merge!(html_options)
38
+ attrs.merge!(build_data)
39
+ attrs.compact
40
+ end
41
+ end
42
+ end