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
@@ -26,8 +26,17 @@ module MaquinaComponents
26
26
  private
27
27
 
28
28
  def apply_icon_options(svg, options)
29
+ return svg.html_safe unless options&.any?
30
+
29
31
  css_classes = options[:class]
30
- svg = svg.gsub('class="', "class=\"#{css_classes} ") if css_classes
32
+ if css_classes&.is_a?(String)
33
+ escaped_css_classes = ERB::Util.html_escape(css_classes)
34
+ svg = if /\bclass\s*=\s*(['"])(.*?)\1/.match?(svg)
35
+ svg.sub(/\bclass\s*=\s*(['"])(.*?)\1/, "class=\\1#{escaped_css_classes}\\1")
36
+ else
37
+ svg.sub(/<svg(?=(\s|>))/, "<svg class=\"#{escaped_css_classes}\"")
38
+ end
39
+ end
31
40
 
32
41
  if options[:stroke_width]
33
42
  svg = svg.gsub('stroke-width="2"', "stroke-width=\"#{options[:stroke_width]}\"")
@@ -392,6 +401,64 @@ module MaquinaComponents
392
401
  <rect width="7" height="7" x="3" y="14" rx="1"/>
393
402
  </svg>
394
403
  SVG
404
+ when :message_square
405
+ <<~SVG.freeze
406
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
407
+ <path d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
408
+ </svg>
409
+ SVG
410
+ when :arrow_up
411
+ <<~SVG.freeze
412
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
413
+ <path d="M7 17L17 7M17 7H7M17 7V17"/>
414
+ </svg>
415
+ SVG
416
+ when :arrow_down
417
+ <<~SVG.freeze
418
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
419
+ <path d="M17 7L7 17M7 17H17M7 17V7"/>
420
+ </svg>
421
+ SVG
422
+ when :lightning_bolt
423
+ <<~SVG.freeze
424
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
425
+ <path d="M13 10V3L4 14h7v7l9-11h-7z"/>
426
+ </svg>
427
+ SVG
428
+ when :clipboard_list
429
+ <<~SVG.freeze
430
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
431
+ <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
432
+ </svg>
433
+ SVG
434
+ when :chart_bar
435
+ <<~SVG.freeze
436
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
437
+ <path d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
438
+ </svg>
439
+ SVG
440
+ when :circle_check
441
+ <<~SVG.freeze
442
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
443
+ <circle cx="12" cy="12" r="10"/>
444
+ <path d="m9 12 2 2 4-4"/>
445
+ </svg>
446
+ SVG
447
+ when :circle_x
448
+ <<~SVG.freeze
449
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
450
+ <circle cx="12" cy="12" r="10"/>
451
+ <path d="m15 9-6 6"/>
452
+ <path d="m9 9 6 6"/>
453
+ </svg>
454
+ SVG
455
+ when :x
456
+ <<~SVG.freeze
457
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
458
+ <path d="M18 6 6 18"/>
459
+ <path d="m6 6 12 12"/>
460
+ </svg>
461
+ SVG
395
462
  end
396
463
  end
397
464
  end
@@ -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
@@ -0,0 +1,287 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ /**
4
+ * Drawer Controller
5
+ *
6
+ * Manages drawer state (slide-out panel), keyboard shortcuts, and persistence.
7
+ * Works with drawer_trigger_controller via Stimulus outlets.
8
+ *
9
+ * @example
10
+ * <div data-controller="drawer" data-outlet="drawer">
11
+ * <!-- drawer content -->
12
+ * </div>
13
+ */
14
+ export default class extends Controller {
15
+ static values = {
16
+ open: { type: Boolean, default: false },
17
+ defaultOpen: { type: Boolean, default: false },
18
+ cookieName: { type: String, default: "drawer_state" },
19
+ cookieMaxAge: { type: Number, default: 60 * 60 * 24 * 365 },
20
+ keyboardShortcut: { type: String, default: "d" }
21
+ }
22
+
23
+ static targets = ["drawer", "container", "backdrop", "panel"]
24
+
25
+ initialize() {
26
+ const cookieValue = this.getCookie(this.cookieNameValue)
27
+ this.openValue = cookieValue !== null
28
+ ? cookieValue === "true"
29
+ : this.defaultOpenValue
30
+
31
+ this._morphing = false
32
+ }
33
+
34
+ connect() {
35
+ this.resizeHandler = this.debounce(this.checkScreenSize.bind(this), 150)
36
+ window.addEventListener("resize", this.resizeHandler)
37
+
38
+ this.boundTeardown = this.teardown.bind(this)
39
+ this.boundBeforeMorphElement = this.beforeMorphElement.bind(this)
40
+ this.boundHandleMorph = this.handleMorph.bind(this)
41
+ document.addEventListener("turbo:before-cache", this.boundTeardown)
42
+ document.addEventListener("turbo:before-morph-element", this.boundBeforeMorphElement)
43
+ document.addEventListener("turbo:morph", this.boundHandleMorph)
44
+
45
+ this.updateStateImmediate()
46
+ }
47
+
48
+ disconnect() {
49
+ window.removeEventListener("resize", this.resizeHandler)
50
+ document.removeEventListener("turbo:before-cache", this.boundTeardown)
51
+ document.removeEventListener("turbo:before-morph-element", this.boundBeforeMorphElement)
52
+ document.removeEventListener("turbo:morph", this.boundHandleMorph)
53
+ }
54
+
55
+ // ============================================================================
56
+ // Turbo Cache Teardown
57
+ // ============================================================================
58
+
59
+ teardown() {
60
+ this.openValue = false
61
+
62
+ if (this.hasBackdropTarget) {
63
+ this.backdropTarget.setAttribute("data-state", "hidden")
64
+ this.backdropTarget.classList.add("hidden")
65
+ }
66
+
67
+ this.unlockScroll()
68
+ }
69
+
70
+ beforeMorphElement(event) {
71
+ if (event.target === this.element) {
72
+ this._morphing = true
73
+ }
74
+ }
75
+
76
+ handleMorph() {
77
+ const cookieValue = this.getCookie(this.cookieNameValue)
78
+
79
+ if (cookieValue !== null) {
80
+ this.openValue = cookieValue === "true"
81
+ }
82
+
83
+ if (this.hasDrawerTarget) {
84
+ this.drawerTarget.classList.remove("drawer-loading")
85
+ }
86
+
87
+ this.updateState()
88
+ this._morphing = false
89
+ }
90
+
91
+ // ============================================================================
92
+ // Keyboard Shortcut Actions
93
+ // ============================================================================
94
+
95
+ toggleWithKeyboard(event) {
96
+ this.toggle()
97
+ }
98
+
99
+ closeOnEscape(event) {
100
+ if (this.openValue) {
101
+ this.close()
102
+ }
103
+ }
104
+
105
+ // ============================================================================
106
+ // State Management
107
+ // ============================================================================
108
+
109
+ openValueChanged(new_value, old_value) {
110
+ if (new_value === old_value) return
111
+ if (this._morphing) return
112
+
113
+ this.updateState()
114
+ this.persistState()
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
+ }
135
+ }
136
+
137
+ updateState() {
138
+ if (!this.hasDrawerTarget) return
139
+
140
+ const isOpen = this.openValue
141
+ const state = isOpen ? "open" : "closed"
142
+
143
+ this.drawerTarget.setAttribute("data-state", state)
144
+
145
+ if (this.hasBackdropTarget) {
146
+ const backdropState = isOpen ? "visible" : "hidden"
147
+ this.backdropTarget.setAttribute("data-state", backdropState)
148
+
149
+ if (backdropState === "visible") {
150
+ this.backdropTarget.classList.remove("hidden")
151
+ } else {
152
+ setTimeout(() => {
153
+ if (this.backdropTarget.getAttribute("data-state") === "hidden") {
154
+ this.backdropTarget.classList.add("hidden")
155
+ }
156
+ }, 300)
157
+ }
158
+ }
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
+
171
+ if (isOpen) {
172
+ this.lockScroll()
173
+ } else {
174
+ this.unlockScroll()
175
+ }
176
+ }
177
+
178
+ updateStateImmediate() {
179
+ if (!this.hasDrawerTarget) return
180
+
181
+ this.updateState()
182
+
183
+ requestAnimationFrame(() => {
184
+ this.drawerTarget.classList.remove("drawer-loading")
185
+ })
186
+ }
187
+
188
+ // ============================================================================
189
+ // Public Actions
190
+ // ============================================================================
191
+
192
+ toggle() {
193
+ this.openValue = !this.openValue
194
+ }
195
+
196
+ open() {
197
+ this.openValue = true
198
+ }
199
+
200
+ close() {
201
+ this.openValue = false
202
+ }
203
+
204
+ backdropClick(event) {
205
+ if (this.openValue) {
206
+ this.close()
207
+ }
208
+ }
209
+
210
+ // ============================================================================
211
+ // Persistence
212
+ // ============================================================================
213
+
214
+ persistState() {
215
+ this.setCookie(
216
+ this.cookieNameValue,
217
+ this.openValue.toString(),
218
+ this.cookieMaxAgeValue
219
+ )
220
+ }
221
+
222
+ getCookie(name) {
223
+ const value = `; ${document.cookie}`
224
+ const parts = value.split(`; ${name}=`)
225
+ if (parts.length === 2) {
226
+ return parts.pop().split(";").shift()
227
+ }
228
+ return null
229
+ }
230
+
231
+ setCookie(name, value, maxAge) {
232
+ document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`
233
+ }
234
+
235
+ // ============================================================================
236
+ // Scroll Lock
237
+ // ============================================================================
238
+
239
+ // Styling lives in drawer.css (body[data-maquina-scroll-locked]); the
240
+ // controller only records the scroll offset and flips the attribute
241
+ lockScroll() {
242
+ this.scrollPosition = window.pageYOffset
243
+ document.body.style.setProperty("--maquina-scroll-lock-top", `-${this.scrollPosition}px`)
244
+ document.body.setAttribute("data-maquina-scroll-locked", "")
245
+ }
246
+
247
+ unlockScroll() {
248
+ document.body.removeAttribute("data-maquina-scroll-locked")
249
+ document.body.style.removeProperty("--maquina-scroll-lock-top")
250
+ if (this.scrollPosition !== undefined) {
251
+ window.scrollTo(0, this.scrollPosition)
252
+ }
253
+ }
254
+
255
+ // ============================================================================
256
+ // Events
257
+ // ============================================================================
258
+
259
+ dispatchStateChange() {
260
+ this.dispatch("stateChanged", {
261
+ detail: {
262
+ open: this.openValue,
263
+ state: this.openValue ? "open" : "closed"
264
+ },
265
+ bubbles: true
266
+ })
267
+ }
268
+
269
+ // ============================================================================
270
+ // Utilities
271
+ // ============================================================================
272
+
273
+ checkScreenSize() {
274
+ }
275
+
276
+ debounce(func, wait) {
277
+ let timeout
278
+ return function executedFunction(...args) {
279
+ const later = () => {
280
+ clearTimeout(timeout)
281
+ func(...args)
282
+ }
283
+ clearTimeout(timeout)
284
+ timeout = setTimeout(later, wait)
285
+ }
286
+ }
287
+ }
@@ -0,0 +1,58 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ /**
4
+ * Drawer Trigger Controller
5
+ *
6
+ * Triggers drawer toggle via Stimulus outlets and mirrors the drawer's
7
+ * state on the trigger (aria-expanded, aria-controls).
8
+ * Can be placed anywhere on the page - finds drawer via outlet selector.
9
+ *
10
+ * @example
11
+ * <button
12
+ * data-controller="drawer-trigger"
13
+ * data-action="click->drawer-trigger#triggerClick"
14
+ * data-drawer-trigger-drawer-outlet="[data-outlet='drawer']"
15
+ * >
16
+ * Toggle
17
+ * </button>
18
+ */
19
+ export default class extends Controller {
20
+ static outlets = ["drawer"]
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
+
40
+ triggerClick() {
41
+ if (this.hasDrawerOutlet) {
42
+ this.drawerOutlets.forEach(outlet => {
43
+ outlet.toggle()
44
+ })
45
+ }
46
+ }
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
+ }