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.
- checksums.yaml +4 -4
- data/README.md +349 -138
- data/app/assets/images/maquina.svg +1 -0
- data/app/assets/stylesheets/alert.css +143 -0
- data/app/assets/stylesheets/badge.css +145 -0
- data/app/assets/stylesheets/breadcrumbs.css +163 -0
- data/app/assets/stylesheets/card.css +128 -0
- data/app/assets/stylesheets/dropdown_menu.css +248 -0
- data/app/assets/stylesheets/empty.css +133 -0
- data/app/assets/stylesheets/form.css +617 -0
- data/app/assets/stylesheets/header.css +61 -0
- data/app/assets/stylesheets/maquina_components.css +143 -64
- data/app/assets/stylesheets/pagination.css +154 -0
- data/app/assets/stylesheets/sidebar.css +477 -0
- data/app/assets/stylesheets/table.css +205 -0
- data/app/assets/stylesheets/toggle_group.css +151 -0
- data/app/assets/tailwind/maquina_components_engine/engine.css +16 -0
- data/app/helpers/maquina_components/breadcrumbs_helper.rb +118 -0
- data/app/helpers/maquina_components/dropdown_menu_helper.rb +249 -0
- data/app/helpers/maquina_components/empty_helper.rb +102 -0
- data/app/helpers/{components → maquina_components}/icons_helper.rb +40 -3
- data/app/helpers/maquina_components/pagination_helper.rb +153 -0
- data/app/helpers/maquina_components/sidebar_helper.rb +63 -0
- data/app/helpers/maquina_components/table_helper.rb +144 -0
- data/app/helpers/maquina_components/toggle_group_helper.rb +172 -0
- data/app/javascript/controllers/breadcrumb_controller.js +71 -0
- data/app/javascript/controllers/dropdown_menu_controller.js +203 -0
- data/app/javascript/controllers/menu_button_controller.js +59 -0
- data/app/javascript/controllers/sidebar_controller.js +316 -0
- data/app/javascript/controllers/sidebar_trigger_controller.js +32 -0
- data/app/javascript/controllers/toggle_group_controller.js +178 -0
- data/app/views/components/_alert.html.erb +11 -10
- data/app/views/components/_badge.html.erb +10 -0
- data/app/views/components/_breadcrumbs.html.erb +16 -0
- data/app/views/components/_card.html.erb +4 -8
- data/app/views/components/_dropdown.html.erb +25 -0
- data/app/views/components/_dropdown_menu.html.erb +9 -0
- data/app/views/components/_empty.html.erb +10 -0
- data/app/views/components/_header.html.erb +8 -0
- data/app/views/components/_menu_button.html.erb +44 -0
- data/app/views/components/_pagination.html.erb +12 -33
- data/app/views/components/_separator.html.erb +11 -0
- data/app/views/components/_sidebar.html.erb +30 -20
- data/app/views/components/_simple_table.html.erb +49 -0
- data/app/views/components/_table.html.erb +21 -0
- data/app/views/components/_toggle_group.html.erb +24 -0
- data/app/views/components/alert/_description.html.erb +6 -0
- data/app/views/components/alert/_title.html.erb +6 -0
- data/app/views/components/breadcrumbs/_ellipsis.html.erb +9 -0
- data/app/views/components/breadcrumbs/_item.html.erb +8 -0
- data/app/views/components/breadcrumbs/_link.html.erb +8 -0
- data/app/views/components/breadcrumbs/_list.html.erb +8 -0
- data/app/views/components/breadcrumbs/_page.html.erb +8 -0
- data/app/views/components/breadcrumbs/_separator.html.erb +17 -0
- data/app/views/components/card/_action.html.erb +6 -0
- data/app/views/components/card/_content.html.erb +9 -0
- data/app/views/components/card/_description.html.erb +6 -0
- data/app/views/components/card/_footer.html.erb +17 -0
- data/app/views/components/card/_header.html.erb +9 -0
- data/app/views/components/card/_title.html.erb +9 -0
- data/app/views/components/dropdown_menu/_content.html.erb +20 -0
- data/app/views/components/dropdown_menu/_group.html.erb +12 -0
- data/app/views/components/dropdown_menu/_item.html.erb +29 -0
- data/app/views/components/dropdown_menu/_label.html.erb +13 -0
- data/app/views/components/dropdown_menu/_separator.html.erb +11 -0
- data/app/views/components/dropdown_menu/_shortcut.html.erb +12 -0
- data/app/views/components/dropdown_menu/_trigger.html.erb +24 -0
- data/app/views/components/empty/_content.html.erb +8 -0
- data/app/views/components/empty/_description.html.erb +12 -0
- data/app/views/components/empty/_header.html.erb +8 -0
- data/app/views/components/empty/_media.html.erb +13 -0
- data/app/views/components/empty/_title.html.erb +12 -0
- data/app/views/components/pagination/_content.html.erb +8 -0
- data/app/views/components/pagination/_ellipsis.html.erb +28 -0
- data/app/views/components/pagination/_item.html.erb +8 -0
- data/app/views/components/pagination/_link.html.erb +23 -0
- data/app/views/components/pagination/_next.html.erb +57 -0
- data/app/views/components/pagination/_previous.html.erb +57 -0
- data/app/views/components/sidebar/_content.html.erb +8 -0
- data/app/views/components/sidebar/_footer.html.erb +8 -0
- data/app/views/components/sidebar/_group.html.erb +12 -0
- data/app/views/components/sidebar/_header.html.erb +8 -0
- data/app/views/components/sidebar/_inset.html.erb +8 -0
- data/app/views/components/sidebar/_menu.html.erb +8 -0
- data/app/views/components/sidebar/_menu_button.html.erb +14 -0
- data/app/views/components/sidebar/_menu_item.html.erb +7 -0
- data/app/views/components/sidebar/_menu_link.html.erb +32 -0
- data/app/views/components/sidebar/_provider.html.erb +16 -0
- data/app/views/components/sidebar/_trigger.html.erb +12 -0
- data/app/views/components/stats/_stats_card.html.erb +100 -0
- data/app/views/components/stats/_stats_grid.html.erb +38 -0
- data/app/views/components/table/_body.html.erb +5 -0
- data/app/views/components/table/_caption.html.erb +5 -0
- data/app/views/components/table/_cell.html.erb +5 -0
- data/app/views/components/table/_footer.html.erb +5 -0
- data/app/views/components/table/_head.html.erb +8 -0
- data/app/views/components/table/_header.html.erb +8 -0
- data/app/views/components/table/_row.html.erb +8 -0
- data/app/views/components/toggle_group/_item.html.erb +19 -0
- data/config/importmap.rb +1 -0
- data/lib/generators/maquina_components/install/USAGE +39 -0
- data/lib/generators/maquina_components/install/install_generator.rb +123 -0
- data/lib/generators/maquina_components/install/templates/maquina_components_helper.rb.tt +68 -0
- data/lib/generators/maquina_components/install/templates/theme.css.tt +179 -0
- data/lib/maquina_components/engine.rb +10 -0
- data/lib/maquina_components/version.rb +1 -1
- metadata +116 -12
- data/app/helpers/components/pagination_helper.rb +0 -15
- data/app/views/components/_card_content.html.erb +0 -5
- data/app/views/components/_card_header.html.erb +0 -8
- data/app/views/components/_sidebar_content.html.erb +0 -8
- data/app/views/components/_sidebar_group.html.erb +0 -42
- 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
|
+
}
|