maquina-components 0.4.4 → 0.5.1

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -1
  3. data/app/assets/stylesheets/breadcrumbs.css +21 -0
  4. data/app/assets/stylesheets/combobox.css +36 -1
  5. data/app/assets/stylesheets/drawer.css +135 -0
  6. data/app/assets/stylesheets/menu_button.css +155 -0
  7. data/app/assets/stylesheets/separator.css +20 -0
  8. data/app/assets/stylesheets/sidebar.css +12 -0
  9. data/app/assets/stylesheets/stats.css +101 -0
  10. data/app/assets/tailwind/maquina_components_engine/engine.css +4 -0
  11. data/app/helpers/maquina_components/combobox_helper.rb +2 -1
  12. data/app/helpers/maquina_components/components_helper.rb +33 -0
  13. data/app/helpers/maquina_components/drawer_helper.rb +19 -0
  14. data/app/helpers/maquina_components/icons_helper.rb +68 -1
  15. data/app/javascript/controllers/breadcrumb_controller.js +39 -30
  16. data/app/javascript/controllers/combobox_controller.js +9 -0
  17. data/app/javascript/controllers/drawer_controller.js +287 -0
  18. data/app/javascript/controllers/drawer_trigger_controller.js +58 -0
  19. data/app/javascript/controllers/menu_button_controller.js +107 -29
  20. data/app/javascript/controllers/sidebar_controller.js +6 -8
  21. data/app/javascript/controllers/sidebar_trigger_controller.js +33 -4
  22. data/app/javascript/controllers/toaster_controller.js +12 -0
  23. data/app/views/components/_alert.html.erb +3 -1
  24. data/app/views/components/_badge.html.erb +7 -1
  25. data/app/views/components/_breadcrumbs.html.erb +1 -1
  26. data/app/views/components/_calendar.html.erb +1 -1
  27. data/app/views/components/_card.html.erb +1 -1
  28. data/app/views/components/_combobox.html.erb +3 -2
  29. data/app/views/components/_date_picker.html.erb +4 -3
  30. data/app/views/components/_drawer.html.erb +37 -0
  31. data/app/views/components/_dropdown.html.erb +21 -24
  32. data/app/views/components/_dropdown_menu.html.erb +1 -1
  33. data/app/views/components/_empty.html.erb +1 -1
  34. data/app/views/components/_header.html.erb +1 -1
  35. data/app/views/components/_menu_button.html.erb +29 -34
  36. data/app/views/components/_pagination.html.erb +1 -1
  37. data/app/views/components/_separator.html.erb +9 -10
  38. data/app/views/components/_sidebar.html.erb +1 -1
  39. data/app/views/components/_simple_table.html.erb +3 -1
  40. data/app/views/components/_table.html.erb +1 -1
  41. data/app/views/components/_toast.html.erb +6 -1
  42. data/app/views/components/_toaster.html.erb +1 -1
  43. data/app/views/components/_toggle_group.html.erb +1 -1
  44. data/app/views/components/alert/_description.html.erb +1 -1
  45. data/app/views/components/alert/_title.html.erb +1 -1
  46. data/app/views/components/breadcrumbs/_ellipsis.html.erb +1 -1
  47. data/app/views/components/breadcrumbs/_item.html.erb +1 -1
  48. data/app/views/components/breadcrumbs/_link.html.erb +1 -1
  49. data/app/views/components/breadcrumbs/_list.html.erb +1 -1
  50. data/app/views/components/breadcrumbs/_page.html.erb +1 -1
  51. data/app/views/components/breadcrumbs/_separator.html.erb +1 -1
  52. data/app/views/components/calendar/_header.html.erb +1 -1
  53. data/app/views/components/calendar/_week.html.erb +1 -1
  54. data/app/views/components/card/_action.html.erb +1 -1
  55. data/app/views/components/card/_content.html.erb +1 -1
  56. data/app/views/components/card/_description.html.erb +1 -1
  57. data/app/views/components/card/_footer.html.erb +1 -1
  58. data/app/views/components/card/_header.html.erb +1 -1
  59. data/app/views/components/card/_title.html.erb +1 -1
  60. data/app/views/components/combobox/_content.html.erb +1 -1
  61. data/app/views/components/combobox/_empty.html.erb +3 -3
  62. data/app/views/components/combobox/_group.html.erb +1 -1
  63. data/app/views/components/combobox/_input.html.erb +1 -1
  64. data/app/views/components/combobox/_label.html.erb +1 -1
  65. data/app/views/components/combobox/_list.html.erb +1 -1
  66. data/app/views/components/combobox/_option.html.erb +1 -1
  67. data/app/views/components/combobox/_separator.html.erb +1 -1
  68. data/app/views/components/combobox/_trigger.html.erb +1 -1
  69. data/app/views/components/drawer/_close.html.erb +14 -0
  70. data/app/views/components/drawer/_content.html.erb +7 -0
  71. data/app/views/components/drawer/_footer.html.erb +7 -0
  72. data/app/views/components/drawer/_header.html.erb +12 -0
  73. data/app/views/components/drawer/_provider.html.erb +14 -0
  74. data/app/views/components/drawer/_trigger.html.erb +14 -0
  75. data/app/views/components/dropdown_menu/_content.html.erb +1 -1
  76. data/app/views/components/dropdown_menu/_group.html.erb +1 -1
  77. data/app/views/components/dropdown_menu/_item.html.erb +1 -1
  78. data/app/views/components/dropdown_menu/_label.html.erb +3 -7
  79. data/app/views/components/dropdown_menu/_separator.html.erb +1 -1
  80. data/app/views/components/dropdown_menu/_shortcut.html.erb +3 -7
  81. data/app/views/components/dropdown_menu/_trigger.html.erb +1 -1
  82. data/app/views/components/empty/_content.html.erb +1 -1
  83. data/app/views/components/empty/_description.html.erb +3 -7
  84. data/app/views/components/empty/_header.html.erb +1 -1
  85. data/app/views/components/empty/_media.html.erb +5 -3
  86. data/app/views/components/empty/_title.html.erb +3 -7
  87. data/app/views/components/pagination/_content.html.erb +1 -1
  88. data/app/views/components/pagination/_item.html.erb +1 -1
  89. data/app/views/components/pagination/_link.html.erb +1 -1
  90. data/app/views/components/pagination/_next.html.erb +1 -1
  91. data/app/views/components/pagination/_previous.html.erb +1 -1
  92. data/app/views/components/sidebar/_content.html.erb +1 -1
  93. data/app/views/components/sidebar/_footer.html.erb +1 -1
  94. data/app/views/components/sidebar/_group.html.erb +1 -1
  95. data/app/views/components/sidebar/_header.html.erb +1 -1
  96. data/app/views/components/sidebar/_inset.html.erb +1 -1
  97. data/app/views/components/sidebar/_menu.html.erb +1 -1
  98. data/app/views/components/sidebar/_menu_button.html.erb +1 -1
  99. data/app/views/components/sidebar/_menu_item.html.erb +1 -1
  100. data/app/views/components/sidebar/_menu_link.html.erb +4 -7
  101. data/app/views/components/sidebar/_provider.html.erb +1 -1
  102. data/app/views/components/sidebar/_trigger.html.erb +1 -1
  103. data/app/views/components/stats/_stats_card.html.erb +32 -92
  104. data/app/views/components/stats/_stats_grid.html.erb +18 -24
  105. data/app/views/components/table/_body.html.erb +1 -1
  106. data/app/views/components/table/_caption.html.erb +3 -3
  107. data/app/views/components/table/_cell.html.erb +3 -3
  108. data/app/views/components/table/_footer.html.erb +1 -1
  109. data/app/views/components/table/_head.html.erb +3 -3
  110. data/app/views/components/table/_header.html.erb +1 -1
  111. data/app/views/components/table/_row.html.erb +1 -1
  112. data/app/views/components/toast/_action.html.erb +1 -1
  113. data/app/views/components/toast/_description.html.erb +1 -1
  114. data/app/views/components/toast/_title.html.erb +1 -1
  115. data/app/views/components/toggle_group/_item.html.erb +1 -1
  116. data/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt +8 -0
  117. data/lib/generators/maquina_components/scaffold_templates/scaffold_templates_generator.rb +40 -0
  118. data/lib/generators/maquina_components/scaffold_templates/templates/_form.html.erb.tt +49 -0
  119. data/lib/generators/maquina_components/scaffold_templates/templates/edit.html.erb.tt +29 -0
  120. data/lib/generators/maquina_components/scaffold_templates/templates/index.html.erb.tt +58 -0
  121. data/lib/generators/maquina_components/scaffold_templates/templates/new.html.erb.tt +26 -0
  122. data/lib/generators/maquina_components/scaffold_templates/templates/partial.html.erb.tt +17 -0
  123. data/lib/generators/maquina_components/scaffold_templates/templates/show.html.erb.tt +53 -0
  124. data/lib/maquina-components.rb +5 -0
  125. data/lib/maquina_components/engine.rb +7 -3
  126. data/lib/maquina_components/version.rb +1 -1
  127. metadata +52 -3
  128. data/app/assets/stylesheets/maquina_components.css +0 -178
@@ -1,59 +1,137 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ /**
4
+ * MenuButton Controller
5
+ *
6
+ * Sidebar-flavored button that toggles a dropdown panel with:
7
+ * - Click to toggle
8
+ * - Click outside to close
9
+ * - Escape key to close
10
+ * - aria-expanded state on the trigger
11
+ * - Animation states via data-state
12
+ */
3
13
  export default class extends Controller {
4
14
  static targets = ["button", "content"]
5
15
 
16
+ static values = {
17
+ open: { type: Boolean, default: false }
18
+ }
19
+
6
20
  connect() {
7
- if (!this.hasContentTarget) {
8
- return
9
- }
21
+ this.handleClickOutside = this.handleClickOutside.bind(this)
22
+ this.handleKeydown = this.handleKeydown.bind(this)
23
+ this.boundTeardown = this.teardown.bind(this)
10
24
 
11
- this.clickOutside = this.clickOutside.bind(this)
12
- this.isOpen = this.buttonTarget.dataset.state === "open"
25
+ document.addEventListener("turbo:before-cache", this.boundTeardown)
13
26
 
14
- if (this.isOpen) {
15
- this.addClickOutsideListener()
27
+ if (this.hasContentTarget) {
28
+ this.contentTarget.hidden = !this.openValue
16
29
  }
17
30
  }
18
31
 
19
32
  disconnect() {
20
- this.removeClickOutsideListener()
33
+ this.removeEventListeners()
34
+ document.removeEventListener("turbo:before-cache", this.boundTeardown)
21
35
  }
22
36
 
23
- toggle() {
24
- if (!this.hasContentTarget) {
25
- return
37
+ toggle(event) {
38
+ event?.preventDefault()
39
+
40
+ if (this.openValue) {
41
+ this.close()
42
+ } else {
43
+ this.open()
26
44
  }
45
+ }
46
+
47
+ open() {
48
+ if (this.openValue || !this.hasContentTarget) return
27
49
 
50
+ this.openValue = true
51
+ this.buttonTarget.dataset.state = "open"
52
+ this.buttonTarget.setAttribute("aria-expanded", "true")
53
+ this.contentTarget.dataset.state = "open"
28
54
  this.contentTarget.classList.remove("hidden")
55
+ this.contentTarget.hidden = false
29
56
 
30
- this.isOpen = !this.isOpen
31
- this.buttonTarget.dataset.state = this.isOpen ? "open" : "closed"
57
+ this.addEventListeners()
58
+ }
32
59
 
33
- if (this.isOpen) {
34
- // Add a small delay before adding the click outside listener
35
- setTimeout(() => {
36
- this.addClickOutsideListener()
37
- }, 100)
38
- } else {
39
- this.removeClickOutsideListener()
60
+ close() {
61
+ if (!this.openValue || !this.hasContentTarget) return
62
+
63
+ // Start closing animation, then hide once it finishes
64
+ this.contentTarget.dataset.state = "closing"
65
+
66
+ const animationDuration = 100 // matches CSS animation duration
67
+
68
+ this._closeTimeout = setTimeout(() => {
69
+ this._closeTimeout = null
70
+ this.openValue = false
71
+ this.buttonTarget.dataset.state = "closed"
72
+ this.buttonTarget.setAttribute("aria-expanded", "false")
73
+ this.contentTarget.dataset.state = "closed"
74
+ this.contentTarget.hidden = true
75
+
76
+ this.removeEventListeners()
77
+ this.buttonTarget.focus()
78
+ }, animationDuration)
79
+ }
80
+
81
+ // Turbo Cache Teardown
82
+
83
+ teardown() {
84
+ if (this._closeTimeout) {
85
+ clearTimeout(this._closeTimeout)
86
+ this._closeTimeout = null
87
+ }
88
+
89
+ this.openValue = false
90
+
91
+ if (this.hasButtonTarget) {
92
+ this.buttonTarget.dataset.state = "closed"
93
+ this.buttonTarget.setAttribute("aria-expanded", "false")
94
+ }
95
+
96
+ if (this.hasContentTarget) {
97
+ this.contentTarget.dataset.state = "closed"
98
+ this.contentTarget.hidden = true
40
99
  }
100
+
101
+ this.removeEventListeners()
41
102
  }
42
103
 
43
- clickOutside(event) {
44
- if (!this.isOpen) return
45
- if (event.target === this.element) return
104
+ // Event Handlers
105
+
106
+ handleClickOutside(event) {
107
+ if (!this.openValue) return
108
+ if (this.element.contains(event.target)) return
109
+
110
+ this.close()
111
+ }
46
112
 
47
- if (!this.contentTarget.contains(event.target)) {
48
- this.toggle()
113
+ handleKeydown(event) {
114
+ if (!this.openValue) return
115
+
116
+ if (event.key === "Escape") {
117
+ event.preventDefault()
118
+ this.close()
49
119
  }
50
120
  }
51
121
 
52
- addClickOutsideListener() {
53
- document.addEventListener('click', this.clickOutside)
122
+ // Event Listener Management
123
+
124
+ addEventListeners() {
125
+ // Delay adding click outside listener to prevent immediate close
126
+ setTimeout(() => {
127
+ document.addEventListener("click", this.handleClickOutside)
128
+ }, 0)
129
+
130
+ document.addEventListener("keydown", this.handleKeydown)
54
131
  }
55
132
 
56
- removeClickOutsideListener() {
57
- document.removeEventListener('click', this.clickOutside)
133
+ removeEventListeners() {
134
+ document.removeEventListener("click", this.handleClickOutside)
135
+ document.removeEventListener("keydown", this.handleKeydown)
58
136
  }
59
137
  }
@@ -318,19 +318,17 @@ export default class extends Controller {
318
318
  // Scroll Lock (for mobile overlay)
319
319
  // ============================================================================
320
320
 
321
+ // Styling lives in drawer.css (body[data-maquina-scroll-locked]); the
322
+ // controller only records the scroll offset and flips the attribute
321
323
  lockScroll() {
322
324
  this.scrollPosition = window.pageYOffset
323
- document.body.style.overflow = "hidden"
324
- document.body.style.position = "fixed"
325
- document.body.style.top = `-${this.scrollPosition}px`
326
- document.body.style.width = "100%"
325
+ document.body.style.setProperty("--maquina-scroll-lock-top", `-${this.scrollPosition}px`)
326
+ document.body.setAttribute("data-maquina-scroll-locked", "")
327
327
  }
328
328
 
329
329
  unlockScroll() {
330
- document.body.style.overflow = ""
331
- document.body.style.position = ""
332
- document.body.style.top = ""
333
- document.body.style.width = ""
330
+ document.body.removeAttribute("data-maquina-scroll-locked")
331
+ document.body.style.removeProperty("--maquina-scroll-lock-top")
334
332
  if (this.scrollPosition !== undefined) {
335
333
  window.scrollTo(0, this.scrollPosition)
336
334
  }
@@ -2,12 +2,13 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  /**
4
4
  * Sidebar Trigger Controller
5
- *
6
- * Triggers sidebar toggle via Stimulus outlets.
5
+ *
6
+ * Triggers sidebar toggle via Stimulus outlets and mirrors the sidebar's
7
+ * state on the trigger (aria-expanded, aria-controls).
7
8
  * Can be placed anywhere on the page - finds sidebar via outlet selector.
8
- *
9
+ *
9
10
  * @example
10
- * <button
11
+ * <button
11
12
  * data-controller="sidebar-trigger"
12
13
  * data-action="click->sidebar-trigger#triggerClick"
13
14
  * data-sidebar-trigger-sidebar-outlet="[data-outlet='sidebar']"
@@ -18,6 +19,23 @@ import { Controller } from "@hotwired/stimulus"
18
19
  export default class extends Controller {
19
20
  static outlets = ["sidebar"]
20
21
 
22
+ connect() {
23
+ this.handleStateChanged = this.handleStateChanged.bind(this)
24
+ window.addEventListener("sidebar:stateChanged", this.handleStateChanged)
25
+ }
26
+
27
+ disconnect() {
28
+ window.removeEventListener("sidebar:stateChanged", this.handleStateChanged)
29
+ }
30
+
31
+ sidebarOutletConnected(outlet) {
32
+ this.syncExpanded(outlet.openValue)
33
+
34
+ if (outlet.hasSidebarTarget && outlet.sidebarTarget.id) {
35
+ this.element.setAttribute("aria-controls", outlet.sidebarTarget.id)
36
+ }
37
+ }
38
+
21
39
  /**
22
40
  * Toggle sidebar when trigger is clicked
23
41
  * Works with multiple sidebars if multiple outlets are connected
@@ -29,4 +47,15 @@ export default class extends Controller {
29
47
  })
30
48
  }
31
49
  }
50
+
51
+ handleStateChanged(event) {
52
+ if (!this.hasSidebarOutlet) return
53
+ if (!this.sidebarOutletElements.includes(event.target)) return
54
+
55
+ this.syncExpanded(event.detail.open)
56
+ }
57
+
58
+ syncExpanded(open) {
59
+ this.element.setAttribute("aria-expanded", open ? "true" : "false")
60
+ }
32
61
  }
@@ -60,6 +60,10 @@ export default class extends Controller {
60
60
  return controller.createToast({ title, variant: "error", ...options })
61
61
  },
62
62
 
63
+ destructive(title, options = {}) {
64
+ return controller.createToast({ title, variant: "error", ...options })
65
+ },
66
+
63
67
  dismiss(toastId) {
64
68
  controller.dismissToast(toastId)
65
69
  },
@@ -115,6 +119,14 @@ export default class extends Controller {
115
119
  }
116
120
 
117
121
  buildToastHtml({ id, title, description, variant, duration, dismissible, action }) {
122
+ // Titles/descriptions are escaped below; variant and duration are
123
+ // interpolated into attributes, so constrain them to safe values.
124
+ // :destructive is the library-wide alias for toast's error variant.
125
+ if (variant === "destructive") variant = "error"
126
+ const knownVariants = ["default", "success", "info", "warning", "error"]
127
+ variant = knownVariants.includes(variant) ? variant : "default"
128
+ duration = Number(duration) || 0
129
+
118
130
  const icons = {
119
131
  default: "",
120
132
  success: this.getIconSvg("circle_check"),
@@ -1,5 +1,7 @@
1
1
  <%# locals: (variant: :default, icon: nil, css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <%# :error is accepted as an alias of the canonical :destructive variant %>
3
+ <% variant = :destructive if variant.to_s == "error" %>
4
+ <% merged_data = merge_component_data(html_options,
3
5
  component: :alert,
4
6
  variant: variant,
5
7
  has_icon: icon.present? || nil
@@ -1,5 +1,11 @@
1
1
  <%# locals: (variant: :default, size: :md, css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <%
3
+ # Library-wide vocabulary aliases: :default is the canonical middle
4
+ # size (badge's own is :md), :destructive the canonical danger variant
5
+ size = :md if size.to_s == "default"
6
+ variant = :destructive if variant.to_s == "error"
7
+ %>
8
+ <% merged_data = merge_component_data(html_options,
3
9
  component: :badge,
4
10
  variant: variant,
5
11
  size: size
@@ -1,5 +1,5 @@
1
1
  <%# locals: (css_classes: "", responsive: false, collapse_after: 0, **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <% merged_data = merge_component_data(html_options,
3
3
  component: :breadcrumbs
4
4
  )
5
5
 
@@ -55,7 +55,7 @@
55
55
  abbr_day_names.rotate(1) :
56
56
  abbr_day_names
57
57
 
58
- merged_data = (html_options.delete(:data) || {}).merge(
58
+ merged_data = merge_component_data(html_options,
59
59
  controller: "calendar",
60
60
  component: "calendar",
61
61
  "calendar-mode-value": mode,
@@ -1,5 +1,5 @@
1
1
  <%# locals: (css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(component: :card) %>
2
+ <% merged_data = merge_component_data(html_options, component: :card) %>
3
3
 
4
4
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5
5
  <%= yield %>
@@ -1,6 +1,7 @@
1
1
  <%# locals: (id: nil, name: nil, value: nil, placeholder: "Select...", css_classes: "", **html_options) %>
2
- <% combobox_id = id || "combobox-#{SecureRandom.hex(4)}" %>
3
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <%# Deterministic fallback id: random ids break idiomorph matching across Turbo morphs %>
3
+ <% combobox_id = id || "combobox-#{name.to_s.parameterize.presence || "default"}" %>
4
+ <% merged_data = merge_component_data(html_options,
4
5
  component: :combobox,
5
6
  controller: "combobox",
6
7
  combobox_value_value: value,
@@ -1,7 +1,8 @@
1
1
  <%# locals: (selected: nil, selected_end: nil, mode: :single, min_date: nil, max_date: nil, disabled_dates: [], show_outside_days: true, week_starts_on: :sunday, placeholder: nil, input_name: nil, input_name_end: nil, id: nil, disabled: false, required: false, css_classes: "", **html_options) %>
2
2
  <%
3
- # Generate unique ID for popover targeting
4
- component_id = id || "date-picker-#{SecureRandom.hex(4)}"
3
+ # Deterministic ID for popover targeting: random ids break idiomorph
4
+ # matching across Turbo morphs
5
+ component_id = id || "date-picker-#{input_name.to_s.parameterize.presence || "default"}"
5
6
  popover_id = "#{component_id}-popover"
6
7
 
7
8
  # Parse selected dates for display
@@ -28,7 +29,7 @@
28
29
 
29
30
  default_placeholder = mode == :range ? "Select date range" : "Select date"
30
31
 
31
- merged_data = (html_options.delete(:data) || {}).merge(
32
+ merged_data = merge_component_data(html_options,
32
33
  controller: "date-picker",
33
34
  component: "date-picker",
34
35
  "date-picker-mode-value": mode,
@@ -0,0 +1,37 @@
1
+ <%# locals: (id: nil, state: :closed, side: :right, aria_label: "Drawer", css_classes: "", **html_options) %>
2
+ <% stable_id = id || "drawer-#{side}"
3
+ merged_data = merge_component_data(html_options,
4
+ drawer_part: :root,
5
+ drawer_target: "drawer",
6
+ state: state,
7
+ side: side
8
+ ) %>
9
+ <aside
10
+ id="<%= stable_id %>"
11
+ class="group peer drawer-loading <%= css_classes %>"
12
+ <%= tag.attributes(data: merged_data, **html_options) %>
13
+ >
14
+ <%# Backdrop overlay %>
15
+ <div
16
+ id="<%= stable_id %>-backdrop"
17
+ data-drawer-part="backdrop"
18
+ data-drawer-target="backdrop"
19
+ data-action="click->drawer#backdropClick"
20
+ ></div>
21
+ <%# Drawer container (fixed positioned) %>
22
+ <div
23
+ id="<%= stable_id %>-panel"
24
+ data-drawer-part="panel"
25
+ data-drawer-target="panel"
26
+ role="dialog"
27
+ aria-modal="true"
28
+ aria-label="<%= aria_label %>"
29
+ tabindex="-1"
30
+ <%= tag.attributes(aria: {hidden: true}, inert: true) if state.to_s == "closed" %>
31
+ >
32
+ <%# Inner drawer content wrapper %>
33
+ <div id="<%= stable_id %>-inner" data-drawer-part="inner">
34
+ <%= yield %>
35
+ </div>
36
+ </div>
37
+ </aside>
@@ -1,25 +1,22 @@
1
- <div
2
- data-menu-button-target="content"
3
- class="
4
- absolute z-10 overflow-hidden border border-border p-1 text-popover-foreground
5
- min-w-56 max-w-56 content-fit shadow-md bottom-full rounded-lg hidden
6
- peer-data-[state=closed]/menu-button:animate-out
7
- peer-data-[state=closed]/menu-button:fade-out-0
8
- peer-data-[state=open]/menu-button:fade-in-0
9
- peer-data-[state=closed]/menu-button:zoom-out-95
10
- peer-data-[state=open]/menu-button:zoom-in-95
11
- peer-data-[side=bottom]/menu-button:slide-in-from-top-2
12
- peer-data-[side=left]/menu-button:slide-in-from-right-2
13
- peer-data-[side=right]/menu-button:slide-in-from-left-2
14
- peer-data-[side=top]/menu-button:slide-in-from-bottom-2
15
- "
16
- role="menu"
17
- aria-orientation="vertical"
18
- aria-labelledby="menu-button"
19
- tabindex="-1"
20
- >
1
+ <%# locals: (id: nil, side: :top, align: :start, css_classes: "", **html_options) %>
2
+ <% merged_data = merge_component_data(html_options,
3
+ "menu-button-part": "content",
4
+ "menu-button-target": "content",
5
+ side: side,
6
+ align: align,
7
+ state: "closed"
8
+ ) %>
9
+ <% aria_attrs = { orientation: "vertical" } %>
10
+ <% aria_attrs[:labelledby] = "#{id}-trigger" if id.present? %>
11
+
12
+ <%= content_tag :div,
13
+ id: (id.present? ? "#{id}-content" : nil),
14
+ role: "menu",
15
+ tabindex: "-1",
16
+ aria: aria_attrs,
17
+ class: css_classes.presence,
18
+ data: merged_data,
19
+ hidden: true,
20
+ **html_options do %>
21
21
  <%= yield %>
22
- </div>
23
- <!--
24
- <div role="separator" aria-orientation="horizontal" class="separator"></div>
25
- -->
22
+ <% end %>
@@ -1,5 +1,5 @@
1
1
  <%# locals: (auto_close: false, css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <% merged_data = merge_component_data(html_options,
3
3
  component: "dropdown-menu",
4
4
  controller: "dropdown-menu"
5
5
  ) %>
@@ -1,5 +1,5 @@
1
1
  <%# locals: (variant: :default, size: :default, css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <% merged_data = merge_component_data(html_options,
3
3
  component: :empty,
4
4
  variant: variant,
5
5
  size: size
@@ -1,5 +1,5 @@
1
1
  <%# locals: (css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <% merged_data = merge_component_data(html_options,
3
3
  component: :header
4
4
  ) %>
5
5
 
@@ -1,44 +1,39 @@
1
- <%# locals: (title: "", subtitle: nil, icon: nil, text_icon: nil, icon_classes: "", submenu: false) %>
1
+ <%# locals: (title: "", subtitle: nil, icon: nil, text_icon: nil, icon_classes: "", submenu: false, id: nil, css_classes: "", **html_options) %>
2
+ <% menu_button_id = id || "menu-button-#{title.to_s.parameterize.presence || "menu"}" %>
3
+ <% merged_data = merge_component_data(html_options,
4
+ component: "menu-button"
5
+ ) %>
2
6
 
3
- <ul class="flex w-full min-w-0 flex-col gap-1">
4
- <li class="group/menu-item relative" data-controller="menu-button">
7
+ <%= content_tag :ul, class: css_classes.presence, data: merged_data, **html_options do %>
8
+ <li data-menu-button-part="item" data-controller="menu-button">
5
9
  <button
10
+ type="button"
11
+ id="<%= menu_button_id %>-trigger"
6
12
  data-state="closed"
7
- class="
8
- peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2
9
- text-left outline-none ring-sidebar-ring transition-[width,height,padding]
10
- focus-visible:ring-2 active:bg-sidebar-accent
11
- active:text-sidebar-accent-foreground disabled:pointer-events-none
12
- disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50
13
- data-[state=open]:bg-sidebar-accent
14
- data-[state=open]text-sidebar-accent-foreground
15
- group-data-[collapsible=icon]:size-8! [&>span:last-child]:truncate
16
- [&>svg]:size-4 [&>svg]:shrink-0 hover:bg-sidebar-accent
17
- hover:text-sidebar-accent-foreground h-12 text-sm
18
- group-data-[collapsible=icon]:p-0!
19
- ", data-action="menu-button#toggle", data-menu-button-target="button" }
13
+ data-menu-button-part="trigger"
14
+ data-menu-button-target="button"
15
+ data-action="menu-button#toggle"
16
+ aria-haspopup="menu"
17
+ aria-expanded="false"
20
18
  >
21
- <% if icon.present? %>
22
- <%= image_tag icon, alt: "maquina", class: "#{icon_classes}" %>
23
- <% elsif text_icon.present? %>
24
- <div
25
- class="
26
- flex aspect-square size-8 items-center justify-center rounded-lg
27
- bg-sidebar-primary text-sidebar-primary-foreground
28
- "
29
- >
30
- <span class="<%= icon_classes %>"><%= text_icon %></span>
31
- </div>
32
- <% end %>
33
- <div class="grid flex-1 text-left text-sm leading-tight">
34
- <span class="truncate font-semibold"><%= title %></span>
19
+ <% if icon.present? %>
20
+ <%= image_tag icon, alt: title.presence || "", class: icon_classes.presence %>
21
+ <% elsif text_icon.present? %>
22
+ <span data-menu-button-part="avatar">
23
+ <span class="<%= icon_classes %>"><%= text_icon %></span>
24
+ </span>
25
+ <% end %>
26
+
27
+ <span data-menu-button-part="text">
28
+ <span data-menu-button-part="title"><%= title %></span>
35
29
  <% if subtitle.present? %>
36
- <span class="truncate text-xs"><%= subtitle %></span>
30
+ <span data-menu-button-part="subtitle"><%= subtitle %></span>
37
31
  <% end %>
38
- </div>
32
+ </span>
33
+
39
34
  <%= builtin_icon_for(:chevron_up_down, class: "ml-auto") if submenu %>
40
35
  </button>
41
36
 
42
- <%= yield %>
37
+ <%= yield menu_button_id %>
43
38
  </li>
44
- </ul>
39
+ <% end %>
@@ -1,5 +1,5 @@
1
1
  <%# locals: (css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <% merged_data = merge_component_data(html_options,
3
3
  component: :pagination
4
4
  ) %>
5
5
 
@@ -1,11 +1,10 @@
1
- <%# locals: (orientation: :horizontal) -%>
1
+ <%# locals: (orientation: :horizontal, css_classes: "", **html_options) %>
2
+ <% merged_data = merge_component_data(html_options,
3
+ component: :separator,
4
+ orientation: orientation
5
+ ) %>
2
6
 
3
- <div
4
- data-orientation="<%= orientation %>"
5
- role="none"
6
- class="
7
- bg-border shrink-0 data-[orientation=horizontal]:h-px
8
- data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px mx-2
9
- data-[orientation=vertical]:h-4
10
- "
11
- ></div>
7
+ <%= tag.div role: "none",
8
+ class: css_classes.presence,
9
+ data: merged_data,
10
+ **html_options %>
@@ -1,7 +1,7 @@
1
1
  <%# locals: (id: nil, state: :collapsed, collapsible: :offcanvas, variant: :inset, side: :left, css_classes: "", **html_options) %>
2
2
  <% stable_id = id || "sidebar-#{side}"
3
3
 
4
- merged_data = (html_options.delete(:data) || {}).merge(
4
+ merged_data = merge_component_data(html_options,
5
5
  sidebar_part: :root,
6
6
  sidebar_target: "sidebar",
7
7
  state: state,
@@ -1,4 +1,6 @@
1
- <%# locals: (collection:, columns:, caption: nil, variant: nil, table_variant: nil, empty_message: "No data available", row_id: nil, html_options: {}) %>
1
+ <%# locals: (collection:, columns:, caption: nil, variant: nil, table_variant: nil, empty_message: "No data available", row_id: nil, html_options: {}, **extra_html_options) %>
2
+ <%# html_options: {} is the legacy call style; bare keyword passthrough is the standard one %>
3
+ <% html_options = html_options.merge(extra_html_options) %>
2
4
  <%= render "components/table", variant: variant, table_variant: table_variant, **html_options do %>
3
5
  <% if caption.present? %>
4
6
  <%= render "components/table/caption" do %><%= caption %><% end %>
@@ -1,7 +1,7 @@
1
1
  <%# locals: (css_classes: "", container: true, variant: nil, table_variant: nil, **html_options) %>
2
2
  <%
3
3
  # Table data attributes - merge user data with component defaults
4
- table_data = (html_options.delete(:data) || {}).merge(component: :table)
4
+ table_data = merge_component_data(html_options, component: :table)
5
5
  table_data[:variant] = table_variant if table_variant
6
6
 
7
7
  # Container data attributes
@@ -1,5 +1,10 @@
1
1
  <%# locals: (variant: :default, title: nil, description: nil, icon: nil, duration: 5000, dismissible: true, content: nil, css_classes: "", **html_options) %>
2
2
  <%
3
+ # :destructive is the library-wide danger variant; toast renders it
4
+ # with its existing error styling
5
+ variant = variant.to_s.to_sym
6
+ variant = :error if variant == :destructive
7
+
3
8
  # Auto-select icon based on variant if not provided
4
9
  default_icons = {
5
10
  default: nil,
@@ -10,7 +15,7 @@
10
15
  }
11
16
  toast_icon = icon || default_icons[variant]
12
17
  %>
13
- <% merged_data = (html_options.delete(:data) || {}).merge(
18
+ <% merged_data = merge_component_data(html_options,
14
19
  component: :toast,
15
20
  controller: "toast",
16
21
  variant: variant,
@@ -1,5 +1,5 @@
1
1
  <%# locals: (position: :bottom_right, content: nil, css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(
2
+ <% merged_data = merge_component_data(html_options,
3
3
  component: :toaster,
4
4
  controller: "toaster",
5
5
  position: position.to_s.dasherize,
@@ -5,7 +5,7 @@
5
5
  else [value.to_s]
6
6
  end
7
7
 
8
- merged_data = (html_options.delete(:data) || {}).merge(
8
+ merged_data = merge_component_data(html_options,
9
9
  controller: "toggle-group",
10
10
  component: "toggle-group",
11
11
  variant: variant,
@@ -1,5 +1,5 @@
1
1
  <%# locals: (text: nil, content: nil, css_classes: "", **html_options) %>
2
- <% merged_data = (html_options.delete(:data) || {}).merge(alert_part: :description) %>
2
+ <% merged_data = merge_component_data(html_options, alert_part: :description) %>
3
3
 
4
4
  <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
5
5
  <%= text || content %>