fernandes-ui 0.1.2 → 0.1.5

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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/ui.esm.js +3 -6
  3. data/app/assets/javascripts/ui.js +3 -6
  4. data/app/behaviors/ui/button_behavior.rb +7 -5
  5. data/app/behaviors/ui/button_group_behavior.rb +6 -4
  6. data/app/behaviors/ui/command_dialog_behavior.rb +0 -8
  7. data/app/behaviors/ui/empty_behavior.rb +6 -6
  8. data/app/behaviors/ui/input_behavior.rb +1 -1
  9. data/app/behaviors/ui/input_group_behavior.rb +3 -1
  10. data/app/behaviors/ui/input_group_button_behavior.rb +12 -9
  11. data/app/behaviors/ui/menubar_checkbox_item_behavior.rb +1 -1
  12. data/app/behaviors/ui/menubar_radio_item_behavior.rb +1 -1
  13. data/app/behaviors/ui/popover_behavior.rb +11 -3
  14. data/app/behaviors/ui/spinner_behavior.rb +1 -1
  15. data/app/behaviors/ui/switch_behavior.rb +49 -49
  16. data/app/behaviors/ui/toggle_group_behavior.rb +2 -2
  17. data/app/behaviors/ui/toggle_group_item_behavior.rb +7 -3
  18. data/app/behaviors/ui/tooltip_behavior.rb +12 -2
  19. data/app/components/ui/base.rb +8 -0
  20. data/app/components/ui/calendar.rb +51 -0
  21. data/app/components/ui/carousel_next.rb +2 -2
  22. data/app/components/ui/carousel_previous.rb +2 -2
  23. data/app/components/ui/date_picker.rb +1 -1
  24. data/app/components/ui/date_picker_trigger.rb +1 -1
  25. data/app/components/ui/dropdown_menu_trigger.rb +3 -2
  26. data/app/components/ui/input_group_button.rb +9 -2
  27. data/app/components/ui/navigation_menu_content.rb +1 -1
  28. data/app/components/ui/navigation_menu_item.rb +1 -1
  29. data/app/components/ui/navigation_menu_link.rb +1 -1
  30. data/app/components/ui/navigation_menu_list.rb +1 -1
  31. data/app/components/ui/navigation_menu_trigger.rb +1 -1
  32. data/app/components/ui/popover.rb +26 -1
  33. data/app/components/ui/select.rb +14 -2
  34. data/app/components/ui/sonner_toaster.rb +1 -1
  35. data/app/components/ui/table.rb +7 -7
  36. data/app/components/ui/table_body.rb +3 -3
  37. data/app/components/ui/table_footer.rb +3 -3
  38. data/app/components/ui/table_header.rb +3 -3
  39. data/app/components/ui/table_row.rb +2 -2
  40. data/app/components/ui/toggle_group.rb +8 -2
  41. data/app/components/ui/toggle_group_item.rb +4 -3
  42. data/app/components/ui/tooltip.rb +26 -2
  43. data/app/helpers/ui/popover_behavior.rb +5 -2
  44. data/app/javascript/ui/controllers/collapsible_controller.js +2 -0
  45. data/app/javascript/ui/controllers/dropdown_controller.js +8 -14
  46. data/app/view_components/ui/calendar_component.rb +62 -0
  47. data/app/view_components/ui/dropdown_menu_trigger_component.rb +14 -30
  48. data/app/view_components/ui/input_group_button_component.rb +16 -1
  49. data/app/view_components/ui/navigation_menu_content_component.rb +1 -1
  50. data/app/view_components/ui/navigation_menu_item_component.rb +1 -1
  51. data/app/view_components/ui/navigation_menu_link_component.rb +1 -1
  52. data/app/view_components/ui/navigation_menu_list_component.rb +1 -1
  53. data/app/view_components/ui/navigation_menu_trigger_component.rb +1 -1
  54. data/app/view_components/ui/popover_component.rb +25 -4
  55. data/app/view_components/ui/popover_trigger_component.rb +5 -0
  56. data/app/view_components/ui/select_component.rb +13 -2
  57. data/app/view_components/ui/table_body_component.rb +1 -1
  58. data/app/view_components/ui/table_component.rb +4 -4
  59. data/app/view_components/ui/table_footer_component.rb +1 -1
  60. data/app/view_components/ui/table_header_component.rb +1 -1
  61. data/app/view_components/ui/table_row_component.rb +2 -2
  62. data/app/view_components/ui/tooltip_component.rb +33 -2
  63. data/app/view_components/ui/tooltip_trigger_component.rb +24 -4
  64. data/app/views/ui/_avatar.html.erb +8 -4
  65. data/app/views/ui/_button.html.erb +16 -0
  66. data/app/views/ui/_date_picker.html.erb +4 -4
  67. data/app/views/ui/_item.html.erb +6 -1
  68. data/app/views/ui/_popover.html.erb +30 -4
  69. data/app/views/ui/_select.html.erb +13 -2
  70. data/app/views/ui/_skeleton.html.erb +1 -1
  71. data/app/views/ui/_tooltip.html.erb +28 -2
  72. data/app/views/ui/dropdown_menu/_trigger.html.erb +3 -1
  73. data/app/views/ui/field/_label.html.erb +2 -1
  74. data/app/views/ui/field/_separator.html.erb +7 -1
  75. data/app/views/ui/input_group/_button.html.erb +14 -3
  76. data/app/views/ui/popover/_trigger.html.erb +22 -2
  77. data/app/views/ui/tooltip/_trigger.html.erb +16 -21
  78. data/lib/ui/version.rb +1 -1
  79. metadata +2 -1
@@ -31,8 +31,9 @@ class UI::DropdownMenuTrigger < Phlex::HTML
31
31
  trigger_attrs = dropdown_menu_trigger_html_attributes.deep_merge(@attributes)
32
32
 
33
33
  if @as_child
34
- # Yield attributes to block - child receives trigger behavior
35
- yield(trigger_attrs) if block_given?
34
+ # When as_child is true, only pass functional attributes (data, aria, tabindex, role)
35
+ # The child component handles its own styling (following shadcn pattern)
36
+ yield(trigger_attrs.except(:class)) if block_given?
36
37
  else
37
38
  # Default: render wrapper div with trigger behavior
38
39
  div(**trigger_attrs) do
@@ -28,11 +28,18 @@ class UI::InputGroupButton < Phlex::HTML
28
28
  end
29
29
 
30
30
  def view_template(&block)
31
+ # Merge classes from @attributes[:class] with input_group_button_classes
32
+ # This is needed when as_child patterns pass classes through attributes
33
+ merged_classes = TailwindMerge::Merger.new.merge([
34
+ input_group_button_classes,
35
+ @attributes[:class]
36
+ ].compact.join(" "))
37
+
31
38
  # Use input_group_button_classes which includes button behavior + input group styling
32
39
  button(
33
40
  type: @type,
34
- class: input_group_button_classes,
35
- **@attributes.except(:type, :variant, :size)
41
+ class: merged_classes,
42
+ **@attributes.except(:type, :variant, :size, :class)
36
43
  ) do
37
44
  yield if block_given?
38
45
  end
@@ -14,7 +14,7 @@
14
14
  # end
15
15
  # end
16
16
  class UI::NavigationMenuContent < Phlex::HTML
17
- include UI::ContentBehavior
17
+ include UI::NavigationMenuContentBehavior
18
18
 
19
19
  # @param viewport [Boolean] Whether content should be rendered in viewport (inherited from parent)
20
20
  # @param classes [String] Additional CSS classes to merge
@@ -17,7 +17,7 @@
17
17
  # render UI::Link.new(href: "/about") { "About" }
18
18
  # end
19
19
  class UI::NavigationMenuItem < Phlex::HTML
20
- include UI::ItemBehavior
20
+ include UI::NavigationMenuItemBehavior
21
21
 
22
22
  # @param value [String] Optional value for controlled state
23
23
  # @param classes [String] Additional CSS classes to merge
@@ -20,7 +20,7 @@
20
20
  # link_to "About", about_path, **link_attrs
21
21
  # end
22
22
  class UI::NavigationMenuLink < Phlex::HTML
23
- include UI::LinkBehavior
23
+ include UI::NavigationMenuLinkBehavior
24
24
  include UI::SharedAsChildBehavior
25
25
 
26
26
  # @param href [String] URL for the link (ignored when as_child: true)
@@ -11,7 +11,7 @@
11
11
  # end
12
12
  # end
13
13
  class UI::NavigationMenuList < Phlex::HTML
14
- include UI::ListBehavior
14
+ include UI::NavigationMenuListBehavior
15
15
 
16
16
  # @param classes [String] Additional CSS classes to merge
17
17
  # @param attributes [Hash] Additional HTML attributes
@@ -11,7 +11,7 @@
11
11
  # @example First trigger (receives initial focus)
12
12
  # render UI::Trigger.new(first: true) { "Home" }
13
13
  class UI::NavigationMenuTrigger < Phlex::HTML
14
- include UI::TriggerBehavior
14
+ include UI::NavigationMenuTriggerBehavior
15
15
 
16
16
  # @param first [Boolean] Whether this is the first trigger (gets tabindex=0)
17
17
  # @param classes [String] Additional CSS classes to merge
@@ -5,6 +5,8 @@
5
5
  # Container for popover trigger and content.
6
6
  # Uses PopoverBehavior concern for shared styling logic.
7
7
  #
8
+ # Supports asChild pattern for composition without wrapper elements.
9
+ #
8
10
  # @example Basic usage
9
11
  # render UI::Popover.new do
10
12
  # render UI::Trigger.new do
@@ -14,9 +16,20 @@
14
16
  # plain "Popover content"
15
17
  # end
16
18
  # end
19
+ #
20
+ # @example With asChild - pass attributes to custom element
21
+ # render UI::Popover.new(as_child: true) do |popover_attrs|
22
+ # render UI::InputGroupAddon.new(**popover_attrs) do
23
+ # render UI::PopoverTrigger.new(as_child: true) do |trigger_attrs|
24
+ # render UI::InputGroupButton.new(**trigger_attrs) { "Info" }
25
+ # end
26
+ # render UI::PopoverContent.new { "Content" }
27
+ # end
28
+ # end
17
29
  class UI::Popover < Phlex::HTML
18
30
  include UI::PopoverBehavior
19
31
 
32
+ # @param as_child [Boolean] When true, yields attributes to block instead of rendering wrapper
20
33
  # @param placement [String] Placement of the popover (e.g., "bottom", "top-start")
21
34
  # @param offset [Integer] Distance in pixels from the trigger
22
35
  # @param trigger [String] Trigger type ("click" or "hover")
@@ -24,6 +37,7 @@ class UI::Popover < Phlex::HTML
24
37
  # @param classes [String] Additional CSS classes to merge
25
38
  # @param attributes [Hash] Additional HTML attributes
26
39
  def initialize(
40
+ as_child: false,
27
41
  placement: "bottom",
28
42
  offset: 4,
29
43
  trigger: "click",
@@ -33,6 +47,7 @@ class UI::Popover < Phlex::HTML
33
47
  side_offset: nil,
34
48
  **attributes
35
49
  )
50
+ @as_child = as_child
36
51
  @placement = placement
37
52
  @offset = side_offset || offset
38
53
  @trigger = trigger
@@ -43,6 +58,16 @@ class UI::Popover < Phlex::HTML
43
58
  end
44
59
 
45
60
  def view_template(&block)
46
- div(**popover_html_attributes, &block)
61
+ popover_attrs = popover_html_attributes.deep_merge(@attributes)
62
+
63
+ if @as_child
64
+ # Yield data attributes to block - child receives controller setup
65
+ yield(popover_attrs) if block_given?
66
+ else
67
+ # Default: render wrapper div with controller
68
+ div(**popover_attrs) do
69
+ yield if block_given?
70
+ end
71
+ end
47
72
  end
48
73
  end
@@ -29,14 +29,26 @@ class UI::Select < Phlex::HTML
29
29
 
30
30
  # @param value [String] Currently selected value
31
31
  # @param classes [String] Additional CSS classes to merge
32
+ # @param as_child [Boolean] If true, renders without wrapper div but preserves controller on inner element
32
33
  # @param attributes [Hash] Additional HTML attributes
33
- def initialize(value: nil, classes: "", **attributes)
34
+ def initialize(value: nil, classes: "", as_child: false, **attributes)
34
35
  @value = value
35
36
  @classes = classes
37
+ @as_child = as_child
36
38
  @attributes = attributes
37
39
  end
38
40
 
39
41
  def view_template(&block)
40
- div(**select_html_attributes.deep_merge(@attributes), &block)
42
+ select_attrs = select_html_attributes.deep_merge(@attributes)
43
+
44
+ if @as_child
45
+ # When as_child, we still need a wrapper for the Stimulus controller
46
+ # but we use a minimal inline wrapper that doesn't break flex layouts
47
+ # Override class to use 'contents' which makes the element invisible to layout
48
+ select_attrs[:class] = "contents"
49
+ span(**select_attrs, &block)
50
+ else
51
+ div(**select_attrs, &block)
52
+ end
41
53
  end
42
54
  end
@@ -14,7 +14,7 @@
14
14
  # close_button: true
15
15
  # ) %>
16
16
  class UI::SonnerToaster < Phlex::HTML
17
- include UI::ToasterBehavior
17
+ include UI::SonnerToasterBehavior
18
18
 
19
19
  # @param position [String] Toast position (top-left, top-center, top-right, bottom-left, bottom-center, bottom-right)
20
20
  # @param theme [String] Theme (light, dark, system)
@@ -16,30 +16,30 @@ class UI::Table < Phlex::HTML
16
16
 
17
17
  # DSL methods
18
18
  def header(classes: "", **attributes, &block)
19
- render Header.new(classes: classes, **attributes, &block)
19
+ render UI::TableHeader.new(classes: classes, **attributes, &block)
20
20
  end
21
21
 
22
22
  def body(classes: "", **attributes, &block)
23
- render Body.new(classes: classes, **attributes, &block)
23
+ render UI::TableBody.new(classes: classes, **attributes, &block)
24
24
  end
25
25
 
26
26
  def footer(classes: "", **attributes, &block)
27
- render Footer.new(classes: classes, **attributes, &block)
27
+ render UI::TableFooter.new(classes: classes, **attributes, &block)
28
28
  end
29
29
 
30
30
  def caption(classes: "", **attributes, &block)
31
- render Caption.new(classes: classes, **attributes, &block)
31
+ render UI::TableCaption.new(classes: classes, **attributes, &block)
32
32
  end
33
33
 
34
34
  def row(classes: "", **attributes, &block)
35
- render Row.new(classes: classes, **attributes, &block)
35
+ render UI::TableRow.new(classes: classes, **attributes, &block)
36
36
  end
37
37
 
38
38
  def head(classes: "", **attributes, &block)
39
- render Head.new(classes: classes, **attributes, &block)
39
+ render UI::TableHead.new(classes: classes, **attributes, &block)
40
40
  end
41
41
 
42
42
  def cell(classes: "", **attributes, &block)
43
- render Cell.new(classes: classes, **attributes, &block)
43
+ render UI::TableCell.new(classes: classes, **attributes, &block)
44
44
  end
45
45
  end
@@ -16,14 +16,14 @@ class UI::TableBody < Phlex::HTML
16
16
 
17
17
  # DSL methods
18
18
  def row(classes: "", **attributes, &block)
19
- render Row.new(classes: classes, **attributes, &block)
19
+ render UI::TableRow.new(classes: classes, **attributes, &block)
20
20
  end
21
21
 
22
22
  def head(classes: "", **attributes, &block)
23
- render Head.new(classes: classes, **attributes, &block)
23
+ render UI::TableHead.new(classes: classes, **attributes, &block)
24
24
  end
25
25
 
26
26
  def cell(classes: "", **attributes, &block)
27
- render Cell.new(classes: classes, **attributes, &block)
27
+ render UI::TableCell.new(classes: classes, **attributes, &block)
28
28
  end
29
29
  end
@@ -16,14 +16,14 @@ class UI::TableFooter < Phlex::HTML
16
16
 
17
17
  # DSL methods
18
18
  def row(classes: "", **attributes, &block)
19
- render Row.new(classes: classes, **attributes, &block)
19
+ render UI::TableRow.new(classes: classes, **attributes, &block)
20
20
  end
21
21
 
22
22
  def head(classes: "", **attributes, &block)
23
- render Head.new(classes: classes, **attributes, &block)
23
+ render UI::TableHead.new(classes: classes, **attributes, &block)
24
24
  end
25
25
 
26
26
  def cell(classes: "", **attributes, &block)
27
- render Cell.new(classes: classes, **attributes, &block)
27
+ render UI::TableCell.new(classes: classes, **attributes, &block)
28
28
  end
29
29
  end
@@ -16,14 +16,14 @@ class UI::TableHeader < Phlex::HTML
16
16
 
17
17
  # DSL methods
18
18
  def row(classes: "", **attributes, &block)
19
- render Row.new(classes: classes, **attributes, &block)
19
+ render UI::TableRow.new(classes: classes, **attributes, &block)
20
20
  end
21
21
 
22
22
  def head(classes: "", **attributes, &block)
23
- render Head.new(classes: classes, **attributes, &block)
23
+ render UI::TableHead.new(classes: classes, **attributes, &block)
24
24
  end
25
25
 
26
26
  def cell(classes: "", **attributes, &block)
27
- render Cell.new(classes: classes, **attributes, &block)
27
+ render UI::TableCell.new(classes: classes, **attributes, &block)
28
28
  end
29
29
  end
@@ -16,10 +16,10 @@ class UI::TableRow < Phlex::HTML
16
16
 
17
17
  # DSL methods
18
18
  def head(classes: "", **attributes, &block)
19
- render Head.new(classes: classes, **attributes, &block)
19
+ render UI::TableHead.new(classes: classes, **attributes, &block)
20
20
  end
21
21
 
22
22
  def cell(classes: "", **attributes, &block)
23
- render Cell.new(classes: classes, **attributes, &block)
23
+ render UI::TableCell.new(classes: classes, **attributes, &block)
24
24
  end
25
25
  end
@@ -41,7 +41,8 @@ class UI::ToggleGroup < Phlex::HTML
41
41
 
42
42
  def view_template(&block)
43
43
  div(**toggle_group_html_attributes.deep_merge(@attributes)) do
44
- # Store context for child items to access
44
+ # Store context for child items to access via thread-local
45
+ # This works across both Phlex internal render and Rails render calls
45
46
  @context = {
46
47
  variant: @variant,
47
48
  size: @size,
@@ -49,7 +50,12 @@ class UI::ToggleGroup < Phlex::HTML
49
50
  spacing: @spacing,
50
51
  value: @value
51
52
  }
52
- yield if block_given?
53
+ Thread.current[:ui_toggle_group_context] = @context
54
+ begin
55
+ yield if block_given?
56
+ ensure
57
+ Thread.current[:ui_toggle_group_context] = nil
58
+ end
53
59
  end
54
60
  end
55
61
 
@@ -37,9 +37,10 @@ class UI::ToggleGroupItem < Phlex::HTML
37
37
  end
38
38
 
39
39
  def before_template
40
- # Try to inherit from parent ToggleGroup context
41
- if helpers.respond_to?(:current_component) && helpers.current_component.respond_to?(:context)
42
- parent_context = helpers.current_component.context
40
+ # Try to inherit from parent ToggleGroup context using thread-local
41
+ # This works across both Phlex internal render and Rails render calls
42
+ parent_context = Thread.current[:ui_toggle_group_context]
43
+ if parent_context
43
44
  @variant ||= parent_context[:variant]
44
45
  @size ||= parent_context[:size]
45
46
  @group_type = parent_context[:type]
@@ -4,6 +4,8 @@
4
4
  #
5
5
  # Root container for tooltip. Manages tooltip state via Stimulus controller.
6
6
  #
7
+ # Supports asChild pattern for composition without wrapper elements.
8
+ #
7
9
  # @example Basic usage
8
10
  # render UI::Tooltip.new do
9
11
  # render UI::Trigger.new { "Hover me" }
@@ -17,15 +19,37 @@
17
19
  # end
18
20
  # render UI::Content.new { "Tooltip text" }
19
21
  # end
22
+ #
23
+ # @example With asChild - pass attributes to custom element
24
+ # render UI::Tooltip.new(as_child: true) do |tooltip_attrs|
25
+ # render UI::InputGroupAddon.new(**tooltip_attrs) do
26
+ # render UI::TooltipTrigger.new(as_child: true) do |trigger_attrs|
27
+ # render UI::InputGroupButton.new(**trigger_attrs) { "Info" }
28
+ # end
29
+ # render UI::TooltipContent.new { "Content" }
30
+ # end
31
+ # end
20
32
  class UI::Tooltip < Phlex::HTML
21
33
  include UI::TooltipBehavior
22
34
 
35
+ # @param as_child [Boolean] When true, yields attributes to block instead of rendering wrapper
23
36
  # @param attributes [Hash] Additional HTML attributes
24
- def initialize(**attributes)
37
+ def initialize(as_child: false, **attributes)
38
+ @as_child = as_child
25
39
  @attributes = attributes
26
40
  end
27
41
 
28
42
  def view_template(&block)
29
- div(**tooltip_html_attributes.deep_merge(@attributes), &block)
43
+ tooltip_attrs = tooltip_html_attributes.deep_merge(@attributes)
44
+
45
+ if @as_child
46
+ # Yield data attributes to block - child receives controller setup
47
+ yield(tooltip_attrs) if block_given?
48
+ else
49
+ # Default: render wrapper div with controller
50
+ div(**tooltip_attrs) do
51
+ yield if block_given?
52
+ end
53
+ end
30
54
  end
31
55
  end
@@ -8,10 +8,13 @@ module UI::PopoverBehavior
8
8
  # Returns HTML attributes for the popover container element
9
9
  def popover_html_attributes
10
10
  attributes_value = respond_to?(:attributes, true) ? attributes : @attributes
11
+ user_data = attributes_value[:data] || {}
12
+ other_attributes = attributes_value.except(:data)
13
+
11
14
  {
12
15
  class: popover_classes,
13
- data: popover_data_attributes
14
- }.merge(attributes_value)
16
+ data: popover_data_attributes.merge(user_data)
17
+ }.merge(other_attributes)
15
18
  end
16
19
 
17
20
  # Returns combined CSS classes for the popover
@@ -45,6 +45,8 @@ export default class extends Controller {
45
45
  if (isOpen) {
46
46
  // Opening: remove hidden and set height
47
47
  content.removeAttribute("hidden")
48
+ // Force reflow to get correct scrollHeight after removing hidden
49
+ void content.offsetHeight
48
50
  content.style.height = `${content.scrollHeight}px`
49
51
  } else {
50
52
  // Closing: animate to 0, then add hidden after transition
@@ -82,17 +82,14 @@ export default class extends Controller {
82
82
  const trigger = event.currentTarget
83
83
  const submenu = trigger.nextElementSibling
84
84
 
85
- // Remove DOM focus from currently focused element to clear :focus pseudo-class
86
- if (document.activeElement && document.activeElement.hasAttribute('role') &&
87
- document.activeElement.getAttribute('role') === 'menuitem') {
88
- document.activeElement.blur()
89
- }
90
-
91
85
  // Remove keyboard focus from all items, except this one
92
86
  clearAllTabindexes(this.element)
93
87
 
94
- // Set tabindex="0" on the hovered trigger
88
+ // Set tabindex="0" on the hovered trigger and focus it
89
+ // Focusing the new item instead of blurring the old one keeps focus inside
90
+ // the dropdown, preventing handleFocusOut from closing the menu
95
91
  trigger.setAttribute('tabindex', '0')
92
+ trigger.focus()
96
93
 
97
94
  // Track the hovered item for keyboard navigation continuity
98
95
  this.lastHoveredItem = trigger
@@ -147,17 +144,14 @@ export default class extends Controller {
147
144
  trackHoveredItem(event) {
148
145
  const item = event.currentTarget
149
146
 
150
- // Remove DOM focus from currently focused element to clear :focus pseudo-class
151
- if (document.activeElement && document.activeElement.hasAttribute('role') &&
152
- document.activeElement.getAttribute('role') === 'menuitem') {
153
- document.activeElement.blur()
154
- }
155
-
156
147
  // Remove keyboard focus from all items, except this one
157
148
  clearAllTabindexes(this.element)
158
149
 
159
- // Set tabindex="0" on the hovered item
150
+ // Set tabindex="0" on the hovered item and focus it
151
+ // Focusing the new item instead of blurring the old one keeps focus inside
152
+ // the dropdown, preventing handleFocusOut from closing the menu
160
153
  item.setAttribute('tabindex', '0')
154
+ item.focus()
161
155
 
162
156
  // Track the hovered item for keyboard navigation continuity
163
157
  this.lastHoveredItem = item
@@ -121,6 +121,14 @@ class UI::CalendarComponent < ViewComponent::Base
121
121
  end
122
122
 
123
123
  def dropdowns_html
124
+ if use_native_select?
125
+ native_dropdowns_html
126
+ else
127
+ ui_select_dropdowns_html
128
+ end
129
+ end
130
+
131
+ def native_dropdowns_html
124
132
  content_tag(:div, class: "flex items-center gap-1") do
125
133
  safe_join([month_select_html, year_select_html])
126
134
  end
@@ -143,6 +151,60 @@ class UI::CalendarComponent < ViewComponent::Base
143
151
  end
144
152
  end
145
153
 
154
+ def ui_select_dropdowns_html
155
+ content_tag(:div, class: "flex items-center gap-1 px-8 flex-1") do
156
+ safe_join([
157
+ ui_month_select_html,
158
+ ui_year_select_html
159
+ ])
160
+ end
161
+ end
162
+
163
+ def ui_month_select_html
164
+ render(UI::SelectComponent.new(classes: "flex-1", value: (@month.month - 1).to_s)) do
165
+ safe_join([
166
+ render(UI::SelectTriggerComponent.new(classes: "h-9 w-full gap-1 px-2 text-sm font-medium border-0 shadow-none")),
167
+ render(UI::SelectContentComponent.new(classes: "min-w-[8rem]")) {
168
+ safe_join(Date::MONTHNAMES.compact.each_with_index.map { |name, i|
169
+ render(UI::SelectItemComponent.new(value: i.to_s)) { name }
170
+ })
171
+ },
172
+ tag.input(
173
+ type: "hidden",
174
+ value: (@month.month - 1).to_s,
175
+ data: {
176
+ ui__select_target: "hiddenInput",
177
+ ui__calendar_target: "monthSelect",
178
+ action: "change->ui--calendar#goToMonth"
179
+ }
180
+ )
181
+ ])
182
+ end
183
+ end
184
+
185
+ def ui_year_select_html
186
+ current_year = Date.today.year
187
+ render(UI::SelectComponent.new(value: @month.year.to_s)) do
188
+ safe_join([
189
+ render(UI::SelectTriggerComponent.new(classes: "min-w-[75px] h-9 w-auto gap-1 px-2 text-sm font-medium border-0 shadow-none")),
190
+ render(UI::SelectContentComponent.new(classes: "min-w-[5rem] max-h-[200px]")) {
191
+ safe_join(((current_year - @year_range)..(current_year + 10)).map { |y|
192
+ render(UI::SelectItemComponent.new(value: y.to_s)) { y.to_s }
193
+ })
194
+ },
195
+ tag.input(
196
+ type: "hidden",
197
+ value: @month.year.to_s,
198
+ data: {
199
+ ui__select_target: "hiddenInput",
200
+ ui__calendar_target: "yearSelect",
201
+ action: "change->ui--calendar#goToYear"
202
+ }
203
+ )
204
+ ])
205
+ end
206
+ end
207
+
146
208
  def table_html
147
209
  content_tag(:table, class: "w-full border-collapse space-y-1", role: "grid") do
148
210
  safe_join([
@@ -4,47 +4,31 @@
4
4
  class UI::DropdownMenuTriggerComponent < ViewComponent::Base
5
5
  include UI::DropdownMenuTriggerBehavior
6
6
 
7
+ renders_one :trigger_content
8
+
7
9
  def initialize(as_child: false, classes: "", **attributes)
8
10
  @as_child = as_child
9
11
  @classes = classes
10
12
  @attributes = attributes
11
13
  end
12
14
 
15
+ # Returns trigger attributes for as_child mode
16
+ def trigger_attrs
17
+ attrs = dropdown_menu_trigger_html_attributes.deep_merge(@attributes)
18
+ # When as_child is true, only pass functional attributes (data, aria, tabindex, role)
19
+ # The child component handles its own styling (following shadcn pattern)
20
+ attrs.except(:class)
21
+ end
22
+
13
23
  def call
14
- trigger_attrs = dropdown_menu_trigger_html_attributes.deep_merge(@attributes)
24
+ all_attrs = dropdown_menu_trigger_html_attributes.deep_merge(@attributes)
15
25
 
16
26
  if @as_child
17
- # asChild mode: merge attributes into child element
18
- rendered = content.to_s
19
- doc = Nokogiri::HTML::DocumentFragment.parse(rendered)
20
- first_element = doc.children.find { |node| node.element? }
21
-
22
- if first_element
23
- # Merge data attributes (convert Rails naming to HTML)
24
- trigger_attrs.fetch(:data, {}).each do |key, value|
25
- html_key = key.to_s.gsub("__", "--").tr("_", "-")
26
- first_element["data-#{html_key}"] = value
27
- end
28
-
29
- # Merge CSS classes with TailwindMerge
30
- if trigger_attrs[:class]
31
- existing_classes = first_element["class"] || ""
32
- merged_classes = TailwindMerge::Merger.new.merge([existing_classes, trigger_attrs[:class]].join(" "))
33
- first_element["class"] = merged_classes
34
- end
35
-
36
- # Merge other attributes (except data and class)
37
- trigger_attrs.except(:data, :class).each do |key, value|
38
- first_element[key.to_s] = value
39
- end
40
-
41
- doc.to_html.html_safe
42
- else
43
- content
44
- end
27
+ # asChild mode: yield attributes to block, child handles rendering
28
+ content
45
29
  else
46
30
  # Default mode: render as div
47
- content_tag :div, **trigger_attrs do
31
+ content_tag :div, **all_attrs do
48
32
  content
49
33
  end
50
34
  end
@@ -31,6 +31,21 @@ class UI::InputGroupButtonComponent < ViewComponent::Base
31
31
  end
32
32
 
33
33
  def call
34
- render partial: "ui/button", locals: input_group_button_attributes.merge(content: content)
34
+ # Get base attributes from behavior (type, variant, classes)
35
+ base_attrs = input_group_button_attributes
36
+
37
+ # Extract button-specific params that ui/button accepts as direct arguments
38
+ button_params = {
39
+ type: base_attrs[:type],
40
+ variant: base_attrs[:variant],
41
+ classes: base_attrs[:classes],
42
+ content: content
43
+ }
44
+
45
+ # Merge additional HTML attributes (data, role, aria, tabindex, etc.) into attributes hash
46
+ html_attrs = base_attrs.except(:type, :variant, :classes, :"data-size")
47
+ button_params[:attributes] = html_attrs unless html_attrs.empty?
48
+
49
+ render partial: "ui/button", locals: button_params
35
50
  end
36
51
  end
@@ -4,7 +4,7 @@
4
4
  #
5
5
  # Container for navigation menu content that appears when trigger is activated.
6
6
  class UI::NavigationMenuContentComponent < ViewComponent::Base
7
- include UI::ContentBehavior
7
+ include UI::NavigationMenuContentBehavior
8
8
 
9
9
  # @param viewport [Boolean] Whether content should be rendered in viewport
10
10
  # @param classes [String] Additional CSS classes to merge
@@ -4,7 +4,7 @@
4
4
  #
5
5
  # Wrapper for individual navigation menu item.
6
6
  class UI::NavigationMenuItemComponent < ViewComponent::Base
7
- include UI::ItemBehavior
7
+ include UI::NavigationMenuItemBehavior
8
8
 
9
9
  # @param value [String] Optional value for controlled state
10
10
  # @param classes [String] Additional CSS classes to merge
@@ -21,7 +21,7 @@ require "nokogiri"
21
21
  # <%= link_to "About", about_path %>
22
22
  # <% end %>
23
23
  class UI::NavigationMenuLinkComponent < ViewComponent::Base
24
- include UI::LinkBehavior
24
+ include UI::NavigationMenuLinkBehavior
25
25
  include UI::SharedAsChildBehavior
26
26
 
27
27
  # @param href [String] URL for the link (ignored when as_child: true)
@@ -4,7 +4,7 @@
4
4
  #
5
5
  # Container for navigation menu items.
6
6
  class UI::NavigationMenuListComponent < ViewComponent::Base
7
- include UI::ListBehavior
7
+ include UI::NavigationMenuListBehavior
8
8
 
9
9
  # @param classes [String] Additional CSS classes to merge
10
10
  # @param attributes [Hash] Additional HTML attributes