maquina-components 0.5.0 → 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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -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 +15 -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 +3 -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/icons_helper.rb +58 -0
  14. data/app/javascript/controllers/breadcrumb_controller.js +39 -30
  15. data/app/javascript/controllers/combobox_controller.js +9 -0
  16. data/app/javascript/controllers/drawer_controller.js +43 -9
  17. data/app/javascript/controllers/drawer_trigger_controller.js +33 -3
  18. data/app/javascript/controllers/menu_button_controller.js +107 -29
  19. data/app/javascript/controllers/sidebar_controller.js +6 -8
  20. data/app/javascript/controllers/sidebar_trigger_controller.js +33 -4
  21. data/app/javascript/controllers/toaster_controller.js +12 -0
  22. data/app/views/components/_alert.html.erb +3 -1
  23. data/app/views/components/_badge.html.erb +7 -1
  24. data/app/views/components/_breadcrumbs.html.erb +1 -1
  25. data/app/views/components/_calendar.html.erb +1 -1
  26. data/app/views/components/_card.html.erb +1 -1
  27. data/app/views/components/_combobox.html.erb +3 -2
  28. data/app/views/components/_date_picker.html.erb +4 -3
  29. data/app/views/components/_drawer.html.erb +8 -2
  30. data/app/views/components/_dropdown.html.erb +21 -24
  31. data/app/views/components/_dropdown_menu.html.erb +1 -1
  32. data/app/views/components/_empty.html.erb +1 -1
  33. data/app/views/components/_header.html.erb +1 -1
  34. data/app/views/components/_menu_button.html.erb +29 -34
  35. data/app/views/components/_pagination.html.erb +1 -1
  36. data/app/views/components/_separator.html.erb +9 -10
  37. data/app/views/components/_sidebar.html.erb +1 -1
  38. data/app/views/components/_simple_table.html.erb +3 -1
  39. data/app/views/components/_table.html.erb +1 -1
  40. data/app/views/components/_toast.html.erb +6 -1
  41. data/app/views/components/_toaster.html.erb +1 -1
  42. data/app/views/components/_toggle_group.html.erb +1 -1
  43. data/app/views/components/alert/_description.html.erb +1 -1
  44. data/app/views/components/alert/_title.html.erb +1 -1
  45. data/app/views/components/breadcrumbs/_ellipsis.html.erb +1 -1
  46. data/app/views/components/breadcrumbs/_item.html.erb +1 -1
  47. data/app/views/components/breadcrumbs/_link.html.erb +1 -1
  48. data/app/views/components/breadcrumbs/_list.html.erb +1 -1
  49. data/app/views/components/breadcrumbs/_page.html.erb +1 -1
  50. data/app/views/components/breadcrumbs/_separator.html.erb +1 -1
  51. data/app/views/components/calendar/_header.html.erb +1 -1
  52. data/app/views/components/calendar/_week.html.erb +1 -1
  53. data/app/views/components/card/_action.html.erb +1 -1
  54. data/app/views/components/card/_content.html.erb +1 -1
  55. data/app/views/components/card/_description.html.erb +1 -1
  56. data/app/views/components/card/_footer.html.erb +1 -1
  57. data/app/views/components/card/_header.html.erb +1 -1
  58. data/app/views/components/card/_title.html.erb +1 -1
  59. data/app/views/components/combobox/_content.html.erb +1 -1
  60. data/app/views/components/combobox/_empty.html.erb +3 -3
  61. data/app/views/components/combobox/_group.html.erb +1 -1
  62. data/app/views/components/combobox/_input.html.erb +1 -1
  63. data/app/views/components/combobox/_label.html.erb +1 -1
  64. data/app/views/components/combobox/_list.html.erb +1 -1
  65. data/app/views/components/combobox/_option.html.erb +1 -1
  66. data/app/views/components/combobox/_separator.html.erb +1 -1
  67. data/app/views/components/combobox/_trigger.html.erb +1 -1
  68. data/app/views/components/drawer/_close.html.erb +1 -1
  69. data/app/views/components/drawer/_content.html.erb +1 -1
  70. data/app/views/components/drawer/_footer.html.erb +1 -1
  71. data/app/views/components/drawer/_header.html.erb +2 -2
  72. data/app/views/components/drawer/_provider.html.erb +2 -2
  73. data/app/views/components/drawer/_trigger.html.erb +1 -1
  74. data/app/views/components/dropdown_menu/_content.html.erb +1 -1
  75. data/app/views/components/dropdown_menu/_group.html.erb +1 -1
  76. data/app/views/components/dropdown_menu/_item.html.erb +1 -1
  77. data/app/views/components/dropdown_menu/_label.html.erb +3 -7
  78. data/app/views/components/dropdown_menu/_separator.html.erb +1 -1
  79. data/app/views/components/dropdown_menu/_shortcut.html.erb +3 -7
  80. data/app/views/components/dropdown_menu/_trigger.html.erb +1 -1
  81. data/app/views/components/empty/_content.html.erb +1 -1
  82. data/app/views/components/empty/_description.html.erb +3 -7
  83. data/app/views/components/empty/_header.html.erb +1 -1
  84. data/app/views/components/empty/_media.html.erb +5 -3
  85. data/app/views/components/empty/_title.html.erb +3 -7
  86. data/app/views/components/pagination/_content.html.erb +1 -1
  87. data/app/views/components/pagination/_item.html.erb +1 -1
  88. data/app/views/components/pagination/_link.html.erb +1 -1
  89. data/app/views/components/pagination/_next.html.erb +1 -1
  90. data/app/views/components/pagination/_previous.html.erb +1 -1
  91. data/app/views/components/sidebar/_content.html.erb +1 -1
  92. data/app/views/components/sidebar/_footer.html.erb +1 -1
  93. data/app/views/components/sidebar/_group.html.erb +1 -1
  94. data/app/views/components/sidebar/_header.html.erb +1 -1
  95. data/app/views/components/sidebar/_inset.html.erb +1 -1
  96. data/app/views/components/sidebar/_menu.html.erb +1 -1
  97. data/app/views/components/sidebar/_menu_button.html.erb +1 -1
  98. data/app/views/components/sidebar/_menu_item.html.erb +1 -1
  99. data/app/views/components/sidebar/_menu_link.html.erb +4 -7
  100. data/app/views/components/sidebar/_provider.html.erb +1 -1
  101. data/app/views/components/sidebar/_trigger.html.erb +1 -1
  102. data/app/views/components/stats/_stats_card.html.erb +32 -92
  103. data/app/views/components/stats/_stats_grid.html.erb +18 -24
  104. data/app/views/components/table/_body.html.erb +1 -1
  105. data/app/views/components/table/_caption.html.erb +3 -3
  106. data/app/views/components/table/_cell.html.erb +3 -3
  107. data/app/views/components/table/_footer.html.erb +1 -1
  108. data/app/views/components/table/_head.html.erb +3 -3
  109. data/app/views/components/table/_header.html.erb +1 -1
  110. data/app/views/components/table/_row.html.erb +1 -1
  111. data/app/views/components/toast/_action.html.erb +1 -1
  112. data/app/views/components/toast/_description.html.erb +1 -1
  113. data/app/views/components/toast/_title.html.erb +1 -1
  114. data/app/views/components/toggle_group/_item.html.erb +1 -1
  115. data/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt +1 -0
  116. data/lib/maquina-components.rb +5 -0
  117. data/lib/maquina_components/engine.rb +7 -3
  118. data/lib/maquina_components/version.rb +1 -1
  119. metadata +33 -2
  120. data/app/assets/stylesheets/maquina_components.css +0 -178
@@ -4,10 +4,15 @@ export default class extends Controller {
4
4
  static targets = ["item", "ellipsis", "ellipsisSeparator"]
5
5
  static values = { collapseAfter: { type: Number, default: 0 } }
6
6
 
7
+ // True when CSS anchor positioning (breadcrumbs.css) places the popover
8
+ static supportsAnchorPositioning =
9
+ typeof CSS !== "undefined" &&
10
+ CSS.supports("anchor-name: --a") &&
11
+ CSS.supports("position-area: block-end")
12
+
7
13
  connect() {
8
14
  this._dropdown = null
9
- this._clickOutsideHandler = this._closeDropdown.bind(this)
10
- this._escapeHandler = this._handleEscape.bind(this)
15
+ this._popoverToggleHandler = this._handlePopoverToggle.bind(this)
11
16
  this._teardownHandler = this._teardown.bind(this)
12
17
 
13
18
  document.addEventListener("turbo:before-cache", this._teardownHandler)
@@ -29,7 +34,6 @@ export default class extends Controller {
29
34
  this._ellipsisTrigger = trigger
30
35
  this._toggleHandler = this._toggleDropdown.bind(this)
31
36
  trigger.addEventListener('click', this._toggleHandler)
32
- trigger.style.cursor = 'pointer'
33
37
  }
34
38
  }
35
39
 
@@ -133,7 +137,7 @@ export default class extends Controller {
133
137
  _toggleDropdown(event) {
134
138
  event.stopPropagation()
135
139
 
136
- if (this._dropdown && this._dropdown.dataset.state === "open") {
140
+ if (this._dropdown && this._dropdown.matches(":popover-open")) {
137
141
  this._closeDropdown()
138
142
  } else {
139
143
  this._openDropdown()
@@ -144,47 +148,55 @@ export default class extends Controller {
144
148
  if (!this._hiddenLinks || this._hiddenLinks.length === 0) return
145
149
 
146
150
  if (!this._dropdown) {
151
+ // popover="auto" gives top-layer rendering, light dismiss, and
152
+ // Escape-to-close natively — no document listeners needed
147
153
  this._dropdown = document.createElement('div')
148
154
  this._dropdown.setAttribute('role', 'menu')
155
+ this._dropdown.setAttribute('popover', 'auto')
149
156
  this._dropdown.dataset.dropdownMenuPart = 'content'
150
- this._dropdown.style.position = 'fixed'
151
- this._dropdown.style.zIndex = '50'
152
- document.body.appendChild(this._dropdown)
157
+ this._dropdown.dataset.breadcrumbPart = 'dropdown'
158
+ this._dropdown.addEventListener('beforetoggle', this._popoverToggleHandler)
159
+ // Next to the trigger so CSS anchor positioning can resolve it
160
+ this._ellipsisTrigger.insertAdjacentElement('afterend', this._dropdown)
153
161
  }
154
162
 
155
163
  this._buildDropdownContent()
156
164
  this._positionDropdown()
157
- this._dropdown.dataset.state = 'open'
158
-
159
- if (this._ellipsisTrigger) {
160
- this._ellipsisTrigger.dataset.state = 'open'
161
- }
162
-
163
- // Defer listeners so the current click doesn't immediately close
164
- requestAnimationFrame(() => {
165
- document.addEventListener('click', this._clickOutsideHandler)
166
- document.addEventListener('keydown', this._escapeHandler)
167
- })
165
+ this._dropdown.showPopover()
168
166
  }
169
167
 
170
168
  _closeDropdown() {
171
- if (this._dropdown) {
172
- this._dropdown.dataset.state = 'closed'
169
+ if (this._dropdown && this._dropdown.matches(":popover-open")) {
170
+ this._dropdown.hidePopover()
173
171
  }
172
+ }
173
+
174
+ // Mirrors the native popover state onto the data attributes the CSS
175
+ // animations and the trigger styling key on
176
+ _handlePopoverToggle(event) {
177
+ const open = event.newState === 'open'
178
+
179
+ this._dropdown.dataset.state = open ? 'open' : 'closed'
180
+
174
181
  if (this._ellipsisTrigger) {
175
- delete this._ellipsisTrigger.dataset.state
182
+ if (open) {
183
+ this._ellipsisTrigger.dataset.state = 'open'
184
+ } else {
185
+ delete this._ellipsisTrigger.dataset.state
186
+ }
176
187
  }
177
-
178
- document.removeEventListener('click', this._clickOutsideHandler)
179
- document.removeEventListener('keydown', this._escapeHandler)
180
188
  }
181
189
 
182
190
  _removeDropdown() {
183
191
  this._closeDropdown()
184
192
  if (this._dropdown) {
193
+ this._dropdown.removeEventListener('beforetoggle', this._popoverToggleHandler)
185
194
  this._dropdown.remove()
186
195
  this._dropdown = null
187
196
  }
197
+ if (this._ellipsisTrigger) {
198
+ delete this._ellipsisTrigger.dataset.state
199
+ }
188
200
  }
189
201
 
190
202
  _buildDropdownContent() {
@@ -203,19 +215,16 @@ export default class extends Controller {
203
215
  }
204
216
 
205
217
  _positionDropdown() {
218
+ // Modern browsers position the popover declaratively via anchor CSS
219
+ if (this.constructor.supportsAnchorPositioning) return
206
220
  if (!this._dropdown || !this._ellipsisTrigger) return
207
221
 
208
222
  const rect = this._ellipsisTrigger.getBoundingClientRect()
223
+ this._dropdown.style.position = 'fixed'
209
224
  this._dropdown.style.top = `${rect.bottom + 4}px`
210
225
  this._dropdown.style.left = `${rect.left}px`
211
226
  }
212
227
 
213
- _handleEscape(event) {
214
- if (event.key === 'Escape') {
215
- this._closeDropdown()
216
- }
217
- }
218
-
219
228
  _teardown() {
220
229
  this._removeDropdown()
221
230
  this._hiddenLinks = null
@@ -72,7 +72,16 @@ export default class extends Controller {
72
72
  }
73
73
  }
74
74
 
75
+ // True when CSS anchor positioning (combobox.css) places the popover
76
+ static supportsAnchorPositioning =
77
+ typeof CSS !== "undefined" &&
78
+ CSS.supports("anchor-name: --a") &&
79
+ CSS.supports("position-area: block-end")
80
+
75
81
  positionPopover() {
82
+ // Modern browsers position the popover declaratively via anchor CSS
83
+ if (this.constructor.supportsAnchorPositioning) return
84
+
76
85
  if (!this.hasTriggerTarget || !this.hasContentTarget) return
77
86
 
78
87
  const trigger = this.triggerTarget
@@ -20,7 +20,7 @@ export default class extends Controller {
20
20
  keyboardShortcut: { type: String, default: "d" }
21
21
  }
22
22
 
23
- static targets = ["drawer", "container", "backdrop"]
23
+ static targets = ["drawer", "container", "backdrop", "panel"]
24
24
 
25
25
  initialize() {
26
26
  const cookieValue = this.getCookie(this.cookieNameValue)
@@ -96,6 +96,12 @@ export default class extends Controller {
96
96
  this.toggle()
97
97
  }
98
98
 
99
+ closeOnEscape(event) {
100
+ if (this.openValue) {
101
+ this.close()
102
+ }
103
+ }
104
+
99
105
  // ============================================================================
100
106
  // State Management
101
107
  // ============================================================================
@@ -107,6 +113,25 @@ export default class extends Controller {
107
113
  this.updateState()
108
114
  this.persistState()
109
115
  this.dispatchStateChange()
116
+
117
+ // Only manage focus on user-driven changes, not the initial sync
118
+ if (old_value !== undefined) {
119
+ this.manageFocus(new_value)
120
+ }
121
+ }
122
+
123
+ manageFocus(isOpen) {
124
+ if (!this.hasPanelTarget) return
125
+
126
+ if (isOpen) {
127
+ this._previouslyFocused = document.activeElement
128
+ this.panelTarget.focus()
129
+ } else {
130
+ if (this._previouslyFocused?.isConnected) {
131
+ this._previouslyFocused.focus()
132
+ }
133
+ this._previouslyFocused = null
134
+ }
110
135
  }
111
136
 
112
137
  updateState() {
@@ -132,6 +157,17 @@ export default class extends Controller {
132
157
  }
133
158
  }
134
159
 
160
+ if (this.hasPanelTarget) {
161
+ // The closed panel stays in the DOM: hide it from AT and block focus
162
+ if (isOpen) {
163
+ this.panelTarget.removeAttribute("aria-hidden")
164
+ this.panelTarget.removeAttribute("inert")
165
+ } else {
166
+ this.panelTarget.setAttribute("aria-hidden", "true")
167
+ this.panelTarget.setAttribute("inert", "")
168
+ }
169
+ }
170
+
135
171
  if (isOpen) {
136
172
  this.lockScroll()
137
173
  } else {
@@ -200,19 +236,17 @@ export default class extends Controller {
200
236
  // Scroll Lock
201
237
  // ============================================================================
202
238
 
239
+ // Styling lives in drawer.css (body[data-maquina-scroll-locked]); the
240
+ // controller only records the scroll offset and flips the attribute
203
241
  lockScroll() {
204
242
  this.scrollPosition = window.pageYOffset
205
- document.body.style.overflow = "hidden"
206
- document.body.style.position = "fixed"
207
- document.body.style.top = `-${this.scrollPosition}px`
208
- document.body.style.width = "100%"
243
+ document.body.style.setProperty("--maquina-scroll-lock-top", `-${this.scrollPosition}px`)
244
+ document.body.setAttribute("data-maquina-scroll-locked", "")
209
245
  }
210
246
 
211
247
  unlockScroll() {
212
- document.body.style.overflow = ""
213
- document.body.style.position = ""
214
- document.body.style.top = ""
215
- document.body.style.width = ""
248
+ document.body.removeAttribute("data-maquina-scroll-locked")
249
+ document.body.style.removeProperty("--maquina-scroll-lock-top")
216
250
  if (this.scrollPosition !== undefined) {
217
251
  window.scrollTo(0, this.scrollPosition)
218
252
  }
@@ -3,11 +3,12 @@ import { Controller } from "@hotwired/stimulus";
3
3
  /**
4
4
  * Drawer Trigger Controller
5
5
  *
6
- * Triggers drawer toggle via Stimulus outlets.
6
+ * Triggers drawer toggle via Stimulus outlets and mirrors the drawer's
7
+ * state on the trigger (aria-expanded, aria-controls).
7
8
  * Can be placed anywhere on the page - finds drawer via outlet selector.
8
9
  *
9
10
  * @example
10
- * <button
11
+ * <button
11
12
  * data-controller="drawer-trigger"
12
13
  * data-action="click->drawer-trigger#triggerClick"
13
14
  * data-drawer-trigger-drawer-outlet="[data-outlet='drawer']"
@@ -18,6 +19,24 @@ import { Controller } from "@hotwired/stimulus";
18
19
  export default class extends Controller {
19
20
  static outlets = ["drawer"]
20
21
 
22
+ connect() {
23
+ this.handleStateChanged = this.handleStateChanged.bind(this)
24
+ window.addEventListener("drawer:stateChanged", this.handleStateChanged)
25
+ }
26
+
27
+ disconnect() {
28
+ window.removeEventListener("drawer:stateChanged", this.handleStateChanged)
29
+ }
30
+
31
+ drawerOutletConnected(outlet) {
32
+ this.element.setAttribute("aria-haspopup", "dialog")
33
+ this.syncExpanded(outlet.openValue)
34
+
35
+ if (outlet.hasPanelTarget && outlet.panelTarget.id) {
36
+ this.element.setAttribute("aria-controls", outlet.panelTarget.id)
37
+ }
38
+ }
39
+
21
40
  triggerClick() {
22
41
  if (this.hasDrawerOutlet) {
23
42
  this.drawerOutlets.forEach(outlet => {
@@ -25,4 +44,15 @@ export default class extends Controller {
25
44
  })
26
45
  }
27
46
  }
28
- }
47
+
48
+ handleStateChanged(event) {
49
+ if (!this.hasDrawerOutlet) return
50
+ if (!this.drawerOutletElements.includes(event.target)) return
51
+
52
+ this.syncExpanded(event.detail.open)
53
+ }
54
+
55
+ syncExpanded(open) {
56
+ this.element.setAttribute("aria-expanded", open ? "true" : "false")
57
+ }
58
+ }
@@ -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,