maquina-components 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +349 -138
  3. data/app/assets/images/maquina.svg +1 -0
  4. data/app/assets/stylesheets/alert.css +143 -0
  5. data/app/assets/stylesheets/badge.css +145 -0
  6. data/app/assets/stylesheets/breadcrumbs.css +163 -0
  7. data/app/assets/stylesheets/card.css +128 -0
  8. data/app/assets/stylesheets/dropdown_menu.css +248 -0
  9. data/app/assets/stylesheets/empty.css +133 -0
  10. data/app/assets/stylesheets/form.css +617 -0
  11. data/app/assets/stylesheets/header.css +61 -0
  12. data/app/assets/stylesheets/maquina_components.css +143 -64
  13. data/app/assets/stylesheets/pagination.css +154 -0
  14. data/app/assets/stylesheets/sidebar.css +477 -0
  15. data/app/assets/stylesheets/table.css +205 -0
  16. data/app/assets/stylesheets/toggle_group.css +151 -0
  17. data/app/assets/tailwind/maquina_components_engine/engine.css +16 -0
  18. data/app/helpers/maquina_components/breadcrumbs_helper.rb +118 -0
  19. data/app/helpers/maquina_components/dropdown_menu_helper.rb +249 -0
  20. data/app/helpers/maquina_components/empty_helper.rb +102 -0
  21. data/app/helpers/{components → maquina_components}/icons_helper.rb +40 -3
  22. data/app/helpers/maquina_components/pagination_helper.rb +153 -0
  23. data/app/helpers/maquina_components/sidebar_helper.rb +63 -0
  24. data/app/helpers/maquina_components/table_helper.rb +144 -0
  25. data/app/helpers/maquina_components/toggle_group_helper.rb +172 -0
  26. data/app/javascript/controllers/breadcrumb_controller.js +71 -0
  27. data/app/javascript/controllers/dropdown_menu_controller.js +203 -0
  28. data/app/javascript/controllers/menu_button_controller.js +59 -0
  29. data/app/javascript/controllers/sidebar_controller.js +316 -0
  30. data/app/javascript/controllers/sidebar_trigger_controller.js +32 -0
  31. data/app/javascript/controllers/toggle_group_controller.js +178 -0
  32. data/app/views/components/_alert.html.erb +11 -10
  33. data/app/views/components/_badge.html.erb +10 -0
  34. data/app/views/components/_breadcrumbs.html.erb +16 -0
  35. data/app/views/components/_card.html.erb +4 -8
  36. data/app/views/components/_dropdown.html.erb +25 -0
  37. data/app/views/components/_dropdown_menu.html.erb +9 -0
  38. data/app/views/components/_empty.html.erb +10 -0
  39. data/app/views/components/_header.html.erb +8 -0
  40. data/app/views/components/_menu_button.html.erb +44 -0
  41. data/app/views/components/_pagination.html.erb +12 -33
  42. data/app/views/components/_separator.html.erb +11 -0
  43. data/app/views/components/_sidebar.html.erb +30 -20
  44. data/app/views/components/_simple_table.html.erb +49 -0
  45. data/app/views/components/_table.html.erb +21 -0
  46. data/app/views/components/_toggle_group.html.erb +24 -0
  47. data/app/views/components/alert/_description.html.erb +6 -0
  48. data/app/views/components/alert/_title.html.erb +6 -0
  49. data/app/views/components/breadcrumbs/_ellipsis.html.erb +9 -0
  50. data/app/views/components/breadcrumbs/_item.html.erb +8 -0
  51. data/app/views/components/breadcrumbs/_link.html.erb +8 -0
  52. data/app/views/components/breadcrumbs/_list.html.erb +8 -0
  53. data/app/views/components/breadcrumbs/_page.html.erb +8 -0
  54. data/app/views/components/breadcrumbs/_separator.html.erb +17 -0
  55. data/app/views/components/card/_action.html.erb +6 -0
  56. data/app/views/components/card/_content.html.erb +9 -0
  57. data/app/views/components/card/_description.html.erb +6 -0
  58. data/app/views/components/card/_footer.html.erb +17 -0
  59. data/app/views/components/card/_header.html.erb +9 -0
  60. data/app/views/components/card/_title.html.erb +9 -0
  61. data/app/views/components/dropdown_menu/_content.html.erb +20 -0
  62. data/app/views/components/dropdown_menu/_group.html.erb +12 -0
  63. data/app/views/components/dropdown_menu/_item.html.erb +29 -0
  64. data/app/views/components/dropdown_menu/_label.html.erb +13 -0
  65. data/app/views/components/dropdown_menu/_separator.html.erb +11 -0
  66. data/app/views/components/dropdown_menu/_shortcut.html.erb +12 -0
  67. data/app/views/components/dropdown_menu/_trigger.html.erb +24 -0
  68. data/app/views/components/empty/_content.html.erb +8 -0
  69. data/app/views/components/empty/_description.html.erb +12 -0
  70. data/app/views/components/empty/_header.html.erb +8 -0
  71. data/app/views/components/empty/_media.html.erb +13 -0
  72. data/app/views/components/empty/_title.html.erb +12 -0
  73. data/app/views/components/pagination/_content.html.erb +8 -0
  74. data/app/views/components/pagination/_ellipsis.html.erb +28 -0
  75. data/app/views/components/pagination/_item.html.erb +8 -0
  76. data/app/views/components/pagination/_link.html.erb +23 -0
  77. data/app/views/components/pagination/_next.html.erb +57 -0
  78. data/app/views/components/pagination/_previous.html.erb +57 -0
  79. data/app/views/components/sidebar/_content.html.erb +8 -0
  80. data/app/views/components/sidebar/_footer.html.erb +8 -0
  81. data/app/views/components/sidebar/_group.html.erb +12 -0
  82. data/app/views/components/sidebar/_header.html.erb +8 -0
  83. data/app/views/components/sidebar/_inset.html.erb +8 -0
  84. data/app/views/components/sidebar/_menu.html.erb +8 -0
  85. data/app/views/components/sidebar/_menu_button.html.erb +14 -0
  86. data/app/views/components/sidebar/_menu_item.html.erb +7 -0
  87. data/app/views/components/sidebar/_menu_link.html.erb +32 -0
  88. data/app/views/components/sidebar/_provider.html.erb +16 -0
  89. data/app/views/components/sidebar/_trigger.html.erb +12 -0
  90. data/app/views/components/stats/_stats_card.html.erb +100 -0
  91. data/app/views/components/stats/_stats_grid.html.erb +38 -0
  92. data/app/views/components/table/_body.html.erb +5 -0
  93. data/app/views/components/table/_caption.html.erb +5 -0
  94. data/app/views/components/table/_cell.html.erb +5 -0
  95. data/app/views/components/table/_footer.html.erb +5 -0
  96. data/app/views/components/table/_head.html.erb +8 -0
  97. data/app/views/components/table/_header.html.erb +8 -0
  98. data/app/views/components/table/_row.html.erb +8 -0
  99. data/app/views/components/toggle_group/_item.html.erb +19 -0
  100. data/config/importmap.rb +1 -0
  101. data/lib/generators/maquina_components/install/USAGE +39 -0
  102. data/lib/generators/maquina_components/install/install_generator.rb +123 -0
  103. data/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt +68 -0
  104. data/lib/generators/maquina_components/install/templates/theme.css.tt +179 -0
  105. data/lib/maquina_components/engine.rb +10 -0
  106. data/lib/maquina_components/version.rb +1 -1
  107. metadata +116 -12
  108. data/app/helpers/components/pagination_helper.rb +0 -15
  109. data/app/views/components/_card_content.html.erb +0 -5
  110. data/app/views/components/_card_header.html.erb +0 -8
  111. data/app/views/components/_sidebar_content.html.erb +0 -8
  112. data/app/views/components/_sidebar_group.html.erb +0 -42
  113. data/app/views/components/_sidebar_header.html.erb +0 -3
@@ -0,0 +1,203 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * DropdownMenu Controller
5
+ *
6
+ * Handles opening/closing dropdown menus with:
7
+ * - Click to toggle
8
+ * - Click outside to close
9
+ * - Escape key to close
10
+ * - Keyboard navigation within menu
11
+ * - Focus management
12
+ * - Animation states
13
+ */
14
+ export default class extends Controller {
15
+ static targets = ["trigger", "content", "chevron"]
16
+
17
+ static values = {
18
+ open: { type: Boolean, default: false }
19
+ }
20
+
21
+ connect() {
22
+ this.handleClickOutside = this.handleClickOutside.bind(this)
23
+ this.handleKeydown = this.handleKeydown.bind(this)
24
+
25
+ // Set initial state on root element
26
+ this.element.dataset.state = "closed"
27
+ }
28
+
29
+ disconnect() {
30
+ this.removeEventListeners()
31
+ }
32
+
33
+ toggle(event) {
34
+ event?.preventDefault()
35
+
36
+ if (this.openValue) {
37
+ this.close()
38
+ } else {
39
+ this.open()
40
+ }
41
+ }
42
+
43
+ open() {
44
+ if (this.openValue || !this.hasContentTarget) return
45
+
46
+ this.openValue = true
47
+ this.element.dataset.state = "open"
48
+ this.contentTarget.dataset.state = "open"
49
+ this.contentTarget.hidden = false
50
+
51
+ // Update trigger aria
52
+ if (this.hasTriggerTarget) {
53
+ this.triggerTarget.setAttribute("aria-expanded", "true")
54
+ }
55
+
56
+ // Add event listeners
57
+ this.addEventListeners()
58
+
59
+ // Focus first item after animation
60
+ requestAnimationFrame(() => {
61
+ this.focusFirstItem()
62
+ })
63
+ }
64
+
65
+ close() {
66
+ if (!this.openValue || !this.hasContentTarget) return
67
+
68
+ // Start closing animation
69
+ this.contentTarget.dataset.state = "closing"
70
+
71
+ // Wait for animation to complete
72
+ const animationDuration = 100 // matches CSS animation duration
73
+
74
+ setTimeout(() => {
75
+ this.openValue = false
76
+ this.element.dataset.state = "closed"
77
+ this.contentTarget.dataset.state = "closed"
78
+ this.contentTarget.hidden = true
79
+
80
+ // Update trigger aria
81
+ if (this.hasTriggerTarget) {
82
+ this.triggerTarget.setAttribute("aria-expanded", "false")
83
+ }
84
+
85
+ // Remove event listeners
86
+ this.removeEventListeners()
87
+
88
+ // Return focus to trigger
89
+ if (this.hasTriggerTarget) {
90
+ this.triggerTarget.focus()
91
+ }
92
+ }, animationDuration)
93
+ }
94
+
95
+ // Event Handlers
96
+
97
+ handleClickOutside(event) {
98
+ if (!this.openValue) return
99
+ if (this.element.contains(event.target)) return
100
+
101
+ this.close()
102
+ }
103
+
104
+ handleKeydown(event) {
105
+ if (!this.openValue) return
106
+
107
+ switch (event.key) {
108
+ case "Escape":
109
+ event.preventDefault()
110
+ this.close()
111
+ break
112
+
113
+ case "ArrowDown":
114
+ event.preventDefault()
115
+ this.focusNextItem()
116
+ break
117
+
118
+ case "ArrowUp":
119
+ event.preventDefault()
120
+ this.focusPreviousItem()
121
+ break
122
+
123
+ case "Home":
124
+ event.preventDefault()
125
+ this.focusFirstItem()
126
+ break
127
+
128
+ case "End":
129
+ event.preventDefault()
130
+ this.focusLastItem()
131
+ break
132
+
133
+ case "Tab":
134
+ // Close menu and let focus move naturally
135
+ this.close()
136
+ break
137
+ }
138
+ }
139
+
140
+ // Focus Management
141
+
142
+ get menuItems() {
143
+ if (!this.hasContentTarget) return []
144
+
145
+ return Array.from(
146
+ this.contentTarget.querySelectorAll('[data-dropdown-menu-part="item"]:not([disabled]):not([aria-disabled="true"])')
147
+ )
148
+ }
149
+
150
+ get focusedItemIndex() {
151
+ const items = this.menuItems
152
+ const focused = document.activeElement
153
+ return items.indexOf(focused)
154
+ }
155
+
156
+ focusFirstItem() {
157
+ const items = this.menuItems
158
+ if (items.length > 0) {
159
+ items[0].focus()
160
+ }
161
+ }
162
+
163
+ focusLastItem() {
164
+ const items = this.menuItems
165
+ if (items.length > 0) {
166
+ items[items.length - 1].focus()
167
+ }
168
+ }
169
+
170
+ focusNextItem() {
171
+ const items = this.menuItems
172
+ if (items.length === 0) return
173
+
174
+ const currentIndex = this.focusedItemIndex
175
+ const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0
176
+ items[nextIndex].focus()
177
+ }
178
+
179
+ focusPreviousItem() {
180
+ const items = this.menuItems
181
+ if (items.length === 0) return
182
+
183
+ const currentIndex = this.focusedItemIndex
184
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1
185
+ items[prevIndex].focus()
186
+ }
187
+
188
+ // Event Listener Management
189
+
190
+ addEventListeners() {
191
+ // Delay adding click outside listener to prevent immediate close
192
+ setTimeout(() => {
193
+ document.addEventListener("click", this.handleClickOutside)
194
+ }, 0)
195
+
196
+ document.addEventListener("keydown", this.handleKeydown)
197
+ }
198
+
199
+ removeEventListeners() {
200
+ document.removeEventListener("click", this.handleClickOutside)
201
+ document.removeEventListener("keydown", this.handleKeydown)
202
+ }
203
+ }
@@ -0,0 +1,59 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["button", "content"]
5
+
6
+ connect() {
7
+ if (!this.hasContentTarget) {
8
+ return
9
+ }
10
+
11
+ this.clickOutside = this.clickOutside.bind(this)
12
+ this.isOpen = this.buttonTarget.dataset.state === "open"
13
+
14
+ if (this.isOpen) {
15
+ this.addClickOutsideListener()
16
+ }
17
+ }
18
+
19
+ disconnect() {
20
+ this.removeClickOutsideListener()
21
+ }
22
+
23
+ toggle() {
24
+ if (!this.hasContentTarget) {
25
+ return
26
+ }
27
+
28
+ this.contentTarget.classList.remove("hidden")
29
+
30
+ this.isOpen = !this.isOpen
31
+ this.buttonTarget.dataset.state = this.isOpen ? "open" : "closed"
32
+
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()
40
+ }
41
+ }
42
+
43
+ clickOutside(event) {
44
+ if (!this.isOpen) return
45
+ if (event.target === this.element) return
46
+
47
+ if (!this.contentTarget.contains(event.target)) {
48
+ this.toggle()
49
+ }
50
+ }
51
+
52
+ addClickOutsideListener() {
53
+ document.addEventListener('click', this.clickOutside)
54
+ }
55
+
56
+ removeClickOutsideListener() {
57
+ document.removeEventListener('click', this.clickOutside)
58
+ }
59
+ }
@@ -0,0 +1,316 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Sidebar Controller
5
+ *
6
+ * Manages sidebar state, keyboard shortcuts, responsive behavior, and persistence.
7
+ * Works with sidebar_trigger_controller via Stimulus outlets.
8
+ *
9
+ * @example
10
+ * <div data-controller="sidebar" data-outlet="sidebar">
11
+ * <!-- sidebar content -->
12
+ * </div>
13
+ */
14
+ export default class extends Controller {
15
+ static values = {
16
+ open: { type: Boolean, default: true },
17
+ defaultOpen: { type: Boolean, default: true },
18
+ cookieName: { type: String, default: "sidebar_state" },
19
+ cookieMaxAge: { type: Number, default: 60 * 60 * 24 * 365 },
20
+ keyboardShortcut: { type: String, default: "b" }
21
+ }
22
+
23
+ static targets = ["sidebar", "container", "backdrop"]
24
+
25
+ initialize() {
26
+ // Load saved state from cookie or use default
27
+ // Note: Rails backend can set defaultOpen based on cookie value
28
+ // via sidebar_state helper
29
+ const cookieValue = this.getCookie(this.cookieNameValue)
30
+ this.openValue = cookieValue !== null
31
+ ? cookieValue === "true"
32
+ : this.defaultOpenValue
33
+
34
+ // Track if we're on mobile
35
+ this._isMobile = null
36
+ }
37
+
38
+ connect() {
39
+ // Detect initial screen size
40
+ this.checkScreenSize()
41
+
42
+ // Setup resize handler with debounce
43
+ this.resizeHandler = this.debounce(this.checkScreenSize.bind(this), 150)
44
+ window.addEventListener("resize", this.resizeHandler)
45
+
46
+ // Apply initial state without animation
47
+ this.updateStateImmediate()
48
+ }
49
+
50
+ disconnect() {
51
+ window.removeEventListener("resize", this.resizeHandler)
52
+ }
53
+
54
+ // ============================================================================
55
+ // Keyboard Shortcut Actions (Called by Stimulus declarative actions)
56
+ // ============================================================================
57
+
58
+ /**
59
+ * Toggle sidebar via keyboard shortcut
60
+ * Called by data-action keyboard bindings (Cmd/Ctrl+B by default)
61
+ * @param {KeyboardEvent} event
62
+ */
63
+ toggleWithKeyboard(event) {
64
+ this.toggle()
65
+ }
66
+
67
+ // ============================================================================
68
+ // State Management
69
+ // ============================================================================
70
+
71
+ openValueChanged(new_value, old_value) {
72
+ if (new_value === old_value) return
73
+
74
+ this.updateState()
75
+ this.persistState()
76
+ this.dispatchStateChange()
77
+ }
78
+
79
+ updateState() {
80
+ if (!this.hasSidebarTarget) return
81
+
82
+ const isOpen = this.openValue
83
+ const isMobile = this.isMobile()
84
+ const state = isOpen ? "expanded" : "collapsed"
85
+
86
+ // Update sidebar state
87
+ this.sidebarTarget.setAttribute("data-state", state)
88
+
89
+ const collapsible = isOpen
90
+ ? "none"
91
+ : !isMobile
92
+ ? "icon"
93
+ : "offcanvas";
94
+
95
+ // Update sidebar visual state
96
+ this.sidebarTarget.setAttribute("data-collapsible", collapsible)
97
+
98
+ // Update backdrop (only visible on mobile when open)
99
+ if (this.hasBackdropTarget) {
100
+ const backdropState = (isOpen && isMobile) ? "visible" : "hidden"
101
+ this.backdropTarget.setAttribute("data-state", backdropState)
102
+
103
+ // Toggle hidden class for visibility
104
+ if (backdropState === "visible") {
105
+ this.backdropTarget.classList.remove("hidden")
106
+ } else {
107
+ // Wait for transition before hiding
108
+ setTimeout(() => {
109
+ if (this.backdropTarget.getAttribute("data-state") === "hidden") {
110
+ this.backdropTarget.classList.add("hidden")
111
+ }
112
+ }, 300)
113
+ }
114
+ }
115
+
116
+ // Lock/unlock scroll on mobile
117
+ if (isMobile) {
118
+ if (isOpen) {
119
+ this.lockScroll()
120
+ } else {
121
+ this.unlockScroll()
122
+ }
123
+ }
124
+ }
125
+
126
+ updateStateImmediate() {
127
+ // Update state without transitions (for initial render)
128
+ if (!this.hasSidebarTarget) return
129
+
130
+ this.updateState()
131
+
132
+ requestAnimationFrame(() => {
133
+ this.sidebarTarget.classList.remove("sidebar-loading")
134
+ })
135
+ }
136
+
137
+ // ============================================================================
138
+ // Public Actions (Called by triggers or other controllers)
139
+ // ============================================================================
140
+
141
+ /**
142
+ * Toggle sidebar open/closed state
143
+ */
144
+ toggle() {
145
+ this.openValue = !this.openValue
146
+ }
147
+
148
+ /**
149
+ * Open the sidebar
150
+ */
151
+ open() {
152
+ this.openValue = true
153
+ }
154
+
155
+ /**
156
+ * Close the sidebar
157
+ */
158
+ close() {
159
+ this.openValue = false
160
+ }
161
+
162
+ /**
163
+ * Toggle only if on mobile (useful for touch gestures)
164
+ */
165
+ toggleMobile() {
166
+ if (this.isMobile()) {
167
+ this.toggle()
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Handle backdrop click (close sidebar on mobile)
173
+ * @param {MouseEvent} event
174
+ */
175
+ backdropClick(event) {
176
+ if (this.isMobile() && this.openValue) {
177
+ this.close()
178
+ }
179
+ }
180
+
181
+ // ============================================================================
182
+ // Responsive Behavior
183
+ // ============================================================================
184
+
185
+ checkScreenSize() {
186
+ const wasMobile = this._isMobile
187
+ this._isMobile = window.innerWidth < 768
188
+
189
+ // First time detecting screen size
190
+ if (wasMobile === null) {
191
+ // On mobile, start closed; on desktop, use saved state
192
+ if (this._isMobile) {
193
+ this.openValue = false
194
+ }
195
+ return
196
+ }
197
+
198
+ // Screen size changed
199
+ if (wasMobile !== this._isMobile) {
200
+ this.handleScreenSizeChange(wasMobile, this._isMobile)
201
+ }
202
+ }
203
+
204
+ handleScreenSizeChange(wasMobile, isMobile) {
205
+ if (this.sidebarTarget.getAttribute("data-collapsible") === "offcanvas") {
206
+ // Mobile to desktop: restore saved state
207
+ if (wasMobile && !isMobile) {
208
+ const cookieValue = this.getCookie(this.cookieNameValue)
209
+ this.openValue = cookieValue !== null
210
+ ? cookieValue === "true"
211
+ : this.defaultOpenValue
212
+ }
213
+ // Desktop to mobile: close sidebar
214
+ else if (!wasMobile && isMobile) {
215
+ this.openValue = false
216
+ }
217
+ } else if (this.sidebarTarget.getAttribute("data-collapsible") === "icon") {
218
+ // Icon mode is always visible, just changes width
219
+ // Desktop to mobile: might want to close
220
+ if (!wasMobile && isMobile) {
221
+ this.openValue = false
222
+ }
223
+ } else if (this.sidebarTarget.getAttribute("data-collapsible") === "none") {
224
+ // Non-collapsible sidebars stay open
225
+ this.openValue = true
226
+ }
227
+
228
+ this.updateState()
229
+ }
230
+
231
+ isMobile() {
232
+ return this._isMobile ?? (window.innerWidth < 768)
233
+ }
234
+
235
+ // ============================================================================
236
+ // Persistence (Cookie Management)
237
+ // ============================================================================
238
+
239
+ persistState() {
240
+ // Only save desktop state to cookie
241
+ if (!this.isMobile()) {
242
+ this.setCookie(
243
+ this.cookieNameValue,
244
+ this.openValue.toString(),
245
+ this.cookieMaxAgeValue
246
+ )
247
+ }
248
+ }
249
+
250
+ getCookie(name) {
251
+ const value = `; ${document.cookie}`
252
+ const parts = value.split(`; ${name}=`)
253
+ if (parts.length === 2) {
254
+ return parts.pop().split(";").shift()
255
+ }
256
+ return null
257
+ }
258
+
259
+ setCookie(name, value, maxAge) {
260
+ document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`
261
+ }
262
+
263
+ // ============================================================================
264
+ // Scroll Lock (for mobile overlay)
265
+ // ============================================================================
266
+
267
+ lockScroll() {
268
+ this.scrollPosition = window.pageYOffset
269
+ document.body.style.overflow = "hidden"
270
+ document.body.style.position = "fixed"
271
+ document.body.style.top = `-${this.scrollPosition}px`
272
+ document.body.style.width = "100%"
273
+ }
274
+
275
+ unlockScroll() {
276
+ document.body.style.overflow = ""
277
+ document.body.style.position = ""
278
+ document.body.style.top = ""
279
+ document.body.style.width = ""
280
+ if (this.scrollPosition !== undefined) {
281
+ window.scrollTo(0, this.scrollPosition)
282
+ }
283
+ }
284
+
285
+ // ============================================================================
286
+ // Events (Custom event dispatching)
287
+ // ============================================================================
288
+
289
+ dispatchStateChange() {
290
+ this.dispatch("stateChanged", {
291
+ detail: {
292
+ open: this.openValue,
293
+ mobile: this.isMobile(),
294
+ state: this.openValue ? "expanded" : "collapsed",
295
+ collapsible: this.sidebarTarget.getAttribute("data-collapsible")
296
+ },
297
+ bubbles: true
298
+ })
299
+ }
300
+
301
+ // ============================================================================
302
+ // Utilities
303
+ // ============================================================================
304
+
305
+ debounce(func, wait) {
306
+ let timeout
307
+ return function executedFunction(...args) {
308
+ const later = () => {
309
+ clearTimeout(timeout)
310
+ func(...args)
311
+ }
312
+ clearTimeout(timeout)
313
+ timeout = setTimeout(later, wait)
314
+ }
315
+ }
316
+ }
@@ -0,0 +1,32 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Sidebar Trigger Controller
5
+ *
6
+ * Triggers sidebar toggle via Stimulus outlets.
7
+ * Can be placed anywhere on the page - finds sidebar via outlet selector.
8
+ *
9
+ * @example
10
+ * <button
11
+ * data-controller="sidebar-trigger"
12
+ * data-action="click->sidebar-trigger#triggerClick"
13
+ * data-sidebar-trigger-sidebar-outlet="[data-outlet='sidebar']"
14
+ * >
15
+ * Toggle
16
+ * </button>
17
+ */
18
+ export default class extends Controller {
19
+ static outlets = ["sidebar"]
20
+
21
+ /**
22
+ * Toggle sidebar when trigger is clicked
23
+ * Works with multiple sidebars if multiple outlets are connected
24
+ */
25
+ triggerClick() {
26
+ if (this.hasSidebarOutlet) {
27
+ this.sidebarOutlets.forEach(outlet => {
28
+ outlet.toggle()
29
+ })
30
+ }
31
+ }
32
+ }