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
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shadcn
4
+ # Dropdown Menu Radio Item component
5
+ # A radio button within a radio group
6
+ class DropdownMenuRadioItemComponent < 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 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--dropdown-target": "item",
69
+ "data-action": "click->shadcn--dropdown#selectRadio"
70
+ }
71
+ attrs.merge!(html_options)
72
+ attrs.merge!(build_data)
73
+ attrs.compact
74
+ end
75
+ end
76
+ end
@@ -6,23 +6,44 @@ module Shadcn
6
6
  class MenubarContentComponent < BaseComponent
7
7
  BASE_CLASSES = "z-50 min-w-[12rem] 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"
8
8
 
9
- renders_many :items, lambda { |**options, &block|
10
- MenubarItemComponent.new(**options, &block)
11
- }
12
- renders_many :labels, lambda { |**options, &block|
13
- MenubarLabelComponent.new(**options, &block)
14
- }
15
- renders_many :separators, lambda { |**options|
16
- MenubarSeparatorComponent.new(**options)
17
- }
18
- renders_many :checkbox_items, lambda { |**options, &block|
19
- MenubarCheckboxItemComponent.new(**options, &block)
20
- }
21
- renders_many :radio_groups, lambda { |**options, &block|
22
- MenubarRadioGroupComponent.new(**options, &block)
23
- }
24
- renders_many :sub_menus, lambda { |**options, &block|
25
- MenubarSubComponent.new(**options, &block)
9
+ # Use polymorphic slots to preserve the order of items, labels, separators, etc.
10
+ renders_many :menu_items, types: {
11
+ item: {
12
+ renders: lambda { |**options, &block|
13
+ MenubarItemComponent.new(**options, &block)
14
+ },
15
+ as: :item
16
+ },
17
+ label: {
18
+ renders: lambda { |**options, &block|
19
+ MenubarLabelComponent.new(**options, &block)
20
+ },
21
+ as: :label
22
+ },
23
+ separator: {
24
+ renders: lambda { |**options|
25
+ MenubarSeparatorComponent.new(**options)
26
+ },
27
+ as: :separator
28
+ },
29
+ checkbox_item: {
30
+ renders: lambda { |**options, &block|
31
+ MenubarCheckboxItemComponent.new(**options, &block)
32
+ },
33
+ as: :checkbox_item
34
+ },
35
+ radio_group: {
36
+ renders: lambda { |**options, &block|
37
+ MenubarRadioGroupComponent.new(**options, &block)
38
+ },
39
+ as: :radio_group
40
+ },
41
+ sub_menu: {
42
+ renders: lambda { |**options, &block|
43
+ MenubarSubComponent.new(**options, &block)
44
+ },
45
+ as: :sub_menu
46
+ }
26
47
  }
27
48
 
28
49
  # @param align [Symbol] Content alignment (:start, :center, :end)
@@ -40,10 +61,14 @@ module Shadcn
40
61
  private
41
62
 
42
63
  def menu_content
43
- if items.any? || labels.any? || separators.any? || checkbox_items.any? || radio_groups.any? || sub_menus.any?
44
- safe_join([labels, items, separators, checkbox_items, radio_groups, sub_menus, content].flatten.compact)
64
+ # Trigger slot evaluation first by accessing content
65
+ raw_content = content
66
+ # If polymorphic slots were used, render them in order
67
+ if menu_items.any?
68
+ safe_join(menu_items)
45
69
  else
46
- content
70
+ # Otherwise render the raw block content (for backwards compatibility)
71
+ raw_content
47
72
  end
48
73
  end
49
74
 
@@ -6,11 +6,20 @@ module Shadcn
6
6
  class MenubarSubContentComponent < BaseComponent
7
7
  BASE_CLASSES = "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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"
8
8
 
9
- renders_many :items, lambda { |**options, &block|
10
- MenubarItemComponent.new(**options, &block)
11
- }
12
- renders_many :separators, lambda { |**options|
13
- MenubarSeparatorComponent.new(**options)
9
+ # Use polymorphic slots to preserve the order of items and separators
10
+ renders_many :menu_items, types: {
11
+ item: {
12
+ renders: lambda { |**options, &block|
13
+ MenubarItemComponent.new(**options, &block)
14
+ },
15
+ as: :item
16
+ },
17
+ separator: {
18
+ renders: lambda { |**options|
19
+ MenubarSeparatorComponent.new(**options)
20
+ },
21
+ as: :separator
22
+ }
14
23
  }
15
24
 
16
25
  def call
@@ -20,10 +29,14 @@ module Shadcn
20
29
  private
21
30
 
22
31
  def sub_content
23
- if items.any? || separators.any?
24
- safe_join([items, separators, content].flatten.compact)
32
+ # Trigger slot evaluation first by accessing content
33
+ raw_content = content
34
+ # If polymorphic slots were used, render them in order
35
+ if menu_items.any?
36
+ safe_join(menu_items)
25
37
  else
26
- content
38
+ # Otherwise render the raw block content (for backwards compatibility)
39
+ raw_content
27
40
  end
28
41
  end
29
42
 
@@ -8,6 +8,9 @@ module Shadcn
8
8
  # @example With label parameter (Tier 2 API)
9
9
  # <%= group.with_item(value: "free", label: "Free") %>
10
10
  #
11
+ # @example With label and description
12
+ # <%= group.with_item(value: "pro", label: "Pro", description: "For professional developers") %>
13
+ #
11
14
  # @example With block content (backward compatible)
12
15
  # <%= group.with_item(value: "free") { "Free" } %>
13
16
  #
@@ -36,10 +39,12 @@ module Shadcn
36
39
  ].join(" ")
37
40
 
38
41
  LABEL_CLASSES = "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
42
+ DESCRIPTION_CLASSES = "text-sm text-muted-foreground"
39
43
 
40
44
  # @param value [String] The value for this radio option
41
45
  # @param id [String, nil] HTML id attribute
42
46
  # @param label [String, nil] Label text (alternative to block content)
47
+ # @param description [String, nil] Description text displayed below the label
43
48
  # @param disabled [Boolean] Whether this option is disabled
44
49
  # @param group_name [String, nil] The name attribute from parent group
45
50
  # @param selected [Boolean] Whether this option is selected
@@ -47,6 +52,7 @@ module Shadcn
47
52
  value:,
48
53
  id: nil,
49
54
  label: nil,
55
+ description: nil,
50
56
  disabled: false,
51
57
  group_name: nil,
52
58
  selected: false,
@@ -57,6 +63,7 @@ module Shadcn
57
63
  @value = value
58
64
  @id = id || "radio-#{value}"
59
65
  @label = label
66
+ @description = description
60
67
  @disabled = disabled
61
68
  @group_name = group_name
62
69
  @selected = selected
@@ -66,12 +73,17 @@ module Shadcn
66
73
  label_text = @label || content.presence
67
74
 
68
75
  if label_text.present?
69
- # Render with integrated label
70
- content_tag(:label, label_wrapper_attributes) do
71
- safe_join([
72
- radio_input,
73
- content_tag(:span, label_text, class: LABEL_CLASSES)
74
- ])
76
+ if @description.present?
77
+ # Render with label and description
78
+ render_with_description(label_text)
79
+ else
80
+ # Render with integrated label only
81
+ content_tag(:label, label_wrapper_attributes) do
82
+ safe_join([
83
+ radio_input,
84
+ content_tag(:span, label_text, class: LABEL_CLASSES)
85
+ ])
86
+ end
75
87
  end
76
88
  else
77
89
  # Render just the radio input (for use with external labels)
@@ -81,6 +93,20 @@ module Shadcn
81
93
 
82
94
  private
83
95
 
96
+ def render_with_description(label_text)
97
+ content_tag(:div, class: "flex items-start space-x-3") do
98
+ safe_join([
99
+ content_tag(:div, class: "mt-0.5") { radio_input },
100
+ content_tag(:div, class: "grid gap-1.5 leading-none") do
101
+ safe_join([
102
+ content_tag(:label, label_text, class: cn(LABEL_CLASSES, "cursor-pointer"), for: @id),
103
+ content_tag(:p, @description, class: DESCRIPTION_CLASSES)
104
+ ])
105
+ end
106
+ ])
107
+ end
108
+ end
109
+
84
110
  def radio_input
85
111
  tag(:input, input_attributes)
86
112
  end
@@ -32,22 +32,30 @@ module Shadcn
32
32
  # <% end %>
33
33
  #
34
34
  class ResizablePanelGroupComponent < BaseComponent
35
- renders_many :panels, lambda { |default_size: nil, min_size: nil, max_size: nil, **options|
36
- ResizablePanelComponent.new(
37
- default_size: default_size,
38
- min_size: min_size,
39
- max_size: max_size,
40
- direction: @direction,
41
- **options
42
- )
43
- }
44
-
45
- renders_many :handles, lambda { |with_handle: false, **options|
46
- ResizableHandleComponent.new(
47
- with_handle: with_handle,
48
- direction: @direction,
49
- **options
50
- )
35
+ # Use polymorphic slots to preserve the order of panels and handles
36
+ renders_many :items, types: {
37
+ panel: {
38
+ renders: lambda { |default_size: nil, min_size: nil, max_size: nil, **options|
39
+ ResizablePanelComponent.new(
40
+ default_size: default_size,
41
+ min_size: min_size,
42
+ max_size: max_size,
43
+ direction: @direction,
44
+ **options
45
+ )
46
+ },
47
+ as: :panel
48
+ },
49
+ handle: {
50
+ renders: lambda { |with_handle: false, **options|
51
+ ResizableHandleComponent.new(
52
+ with_handle: with_handle,
53
+ direction: @direction,
54
+ **options
55
+ )
56
+ },
57
+ as: :handle
58
+ }
51
59
  }
52
60
 
53
61
  DIRECTIONS = {
@@ -70,7 +78,10 @@ module Shadcn
70
78
  private
71
79
 
72
80
  def group_content
81
+ # Trigger slot evaluation first
73
82
  content
83
+ # Render all items in the order they were added
84
+ safe_join(items)
74
85
  end
75
86
 
76
87
  def group_attributes
@@ -25,11 +25,20 @@ module Shadcn
25
25
  CONTENT_CLASSES = "absolute left-0 top-full z-50 mt-1 max-h-96 min-w-[var(--radix-select-trigger-width)] w-max overflow-hidden rounded-md border bg-popover 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"
26
26
  VIEWPORT_CLASSES = "p-1"
27
27
 
28
- renders_many :items, lambda { |value:, **options, &block|
29
- SelectItemComponent.new(value: value, **options, &block)
30
- }
31
- renders_many :groups, lambda { |label: nil, **options, &block|
32
- SelectGroupComponent.new(label: label, **options, &block)
28
+ # Use polymorphic slots to preserve the order of items and groups
29
+ renders_many :select_items, types: {
30
+ item: {
31
+ renders: lambda { |value:, **options, &block|
32
+ SelectItemComponent.new(value: value, **options, &block)
33
+ },
34
+ as: :item
35
+ },
36
+ group: {
37
+ renders: lambda { |label: nil, **options, &block|
38
+ SelectGroupComponent.new(label: label, **options, &block)
39
+ },
40
+ as: :group
41
+ }
33
42
  }
34
43
 
35
44
  # @param name [String, nil] Form field name
@@ -133,7 +142,15 @@ module Shadcn
133
142
  end
134
143
 
135
144
  def items_content
136
- safe_join([items, groups, content].compact.flatten)
145
+ # Trigger slot evaluation first by accessing content
146
+ raw_content = content
147
+ # If polymorphic slots were used, render them in order
148
+ if select_items.any?
149
+ safe_join(select_items)
150
+ else
151
+ # Otherwise render the raw block content (for backwards compatibility)
152
+ raw_content
153
+ end
137
154
  end
138
155
 
139
156
  def select_attributes