maquina-components 0.1.1 → 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 +410 -13
- 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 +178 -0
- 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/maquina_components/icons_helper.rb +161 -0
- 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 +12 -0
- 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 +6 -0
- 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 +13 -0
- data/app/views/components/_separator.html.erb +11 -0
- data/app/views/components/_sidebar.html.erb +40 -0
- 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 +121 -5
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Toggle Group Controller
|
|
5
|
+
*
|
|
6
|
+
* Manages a group of toggle buttons with single or multiple selection.
|
|
7
|
+
*
|
|
8
|
+
* @example Single selection
|
|
9
|
+
* <div data-controller="toggle-group"
|
|
10
|
+
* data-toggle-group-type-value="single"
|
|
11
|
+
* data-toggle-group-selected-value='["bold"]'>
|
|
12
|
+
* <button data-toggle-group-target="item" data-value="bold">Bold</button>
|
|
13
|
+
* <button data-toggle-group-target="item" data-value="italic">Italic</button>
|
|
14
|
+
* </div>
|
|
15
|
+
*
|
|
16
|
+
* @example Multiple selection
|
|
17
|
+
* <div data-controller="toggle-group"
|
|
18
|
+
* data-toggle-group-type-value="multiple"
|
|
19
|
+
* data-toggle-group-selected-value='["bold", "italic"]'>
|
|
20
|
+
* ...
|
|
21
|
+
* </div>
|
|
22
|
+
*/
|
|
23
|
+
export default class extends Controller {
|
|
24
|
+
static targets = ["item"]
|
|
25
|
+
|
|
26
|
+
static values = {
|
|
27
|
+
type: { type: String, default: "single" },
|
|
28
|
+
selected: { type: Array, default: [] }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
connect() {
|
|
32
|
+
this.syncItemStates()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Toggle an item's state
|
|
37
|
+
* @param {Event} event - Click event from toggle item
|
|
38
|
+
*/
|
|
39
|
+
toggle(event) {
|
|
40
|
+
const item = event.currentTarget
|
|
41
|
+
if (item.disabled) return
|
|
42
|
+
|
|
43
|
+
const value = item.dataset.value
|
|
44
|
+
const isPressed = item.dataset.state === "on"
|
|
45
|
+
|
|
46
|
+
if (this.typeValue === "single") {
|
|
47
|
+
if (isPressed) {
|
|
48
|
+
this.selectedValue = []
|
|
49
|
+
} else {
|
|
50
|
+
this.selectedValue = [value]
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
if (isPressed) {
|
|
54
|
+
this.selectedValue = this.selectedValue.filter(v => v !== value)
|
|
55
|
+
} else {
|
|
56
|
+
this.selectedValue = [...this.selectedValue, value]
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.syncItemStates()
|
|
61
|
+
this.dispatchChange()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Handle keyboard navigation
|
|
66
|
+
* @param {KeyboardEvent} event
|
|
67
|
+
*/
|
|
68
|
+
handleKeydown(event) {
|
|
69
|
+
const item = event.currentTarget
|
|
70
|
+
const items = this.itemTargets.filter(i => !i.disabled)
|
|
71
|
+
const currentIndex = items.indexOf(item)
|
|
72
|
+
|
|
73
|
+
let nextIndex = currentIndex
|
|
74
|
+
|
|
75
|
+
switch (event.key) {
|
|
76
|
+
case "ArrowRight":
|
|
77
|
+
case "ArrowDown":
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
nextIndex = (currentIndex + 1) % items.length
|
|
80
|
+
break
|
|
81
|
+
case "ArrowLeft":
|
|
82
|
+
case "ArrowUp":
|
|
83
|
+
event.preventDefault()
|
|
84
|
+
nextIndex = (currentIndex - 1 + items.length) % items.length
|
|
85
|
+
break
|
|
86
|
+
case "Home":
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
nextIndex = 0
|
|
89
|
+
break
|
|
90
|
+
case "End":
|
|
91
|
+
event.preventDefault()
|
|
92
|
+
nextIndex = items.length - 1
|
|
93
|
+
break
|
|
94
|
+
case " ":
|
|
95
|
+
case "Enter":
|
|
96
|
+
return
|
|
97
|
+
default:
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
items[nextIndex]?.focus()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sync visual states with selectedValue
|
|
106
|
+
*/
|
|
107
|
+
syncItemStates() {
|
|
108
|
+
this.itemTargets.forEach(item => {
|
|
109
|
+
const value = item.dataset.value
|
|
110
|
+
const isSelected = this.selectedValue.includes(value)
|
|
111
|
+
|
|
112
|
+
item.dataset.state = isSelected ? "on" : "off"
|
|
113
|
+
item.setAttribute("aria-pressed", isSelected)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Dispatch change event
|
|
119
|
+
*/
|
|
120
|
+
dispatchChange() {
|
|
121
|
+
const detail = {
|
|
122
|
+
type: this.typeValue,
|
|
123
|
+
value: this.typeValue === "single"
|
|
124
|
+
? (this.selectedValue[0] || null)
|
|
125
|
+
: this.selectedValue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.dispatch("change", { detail })
|
|
129
|
+
|
|
130
|
+
this.element.dispatchEvent(new CustomEvent("toggle-group:change", {
|
|
131
|
+
bubbles: true,
|
|
132
|
+
detail
|
|
133
|
+
}))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Programmatically select a value
|
|
138
|
+
* @param {string} value - Value to select
|
|
139
|
+
*/
|
|
140
|
+
select(value) {
|
|
141
|
+
if (this.typeValue === "single") {
|
|
142
|
+
this.selectedValue = [value]
|
|
143
|
+
} else if (!this.selectedValue.includes(value)) {
|
|
144
|
+
this.selectedValue = [...this.selectedValue, value]
|
|
145
|
+
}
|
|
146
|
+
this.syncItemStates()
|
|
147
|
+
this.dispatchChange()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Programmatically deselect a value
|
|
152
|
+
* @param {string} value - Value to deselect
|
|
153
|
+
*/
|
|
154
|
+
deselect(value) {
|
|
155
|
+
this.selectedValue = this.selectedValue.filter(v => v !== value)
|
|
156
|
+
this.syncItemStates()
|
|
157
|
+
this.dispatchChange()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Clear all selections
|
|
162
|
+
*/
|
|
163
|
+
clear() {
|
|
164
|
+
this.selectedValue = []
|
|
165
|
+
this.syncItemStates()
|
|
166
|
+
this.dispatchChange()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get current value(s)
|
|
171
|
+
* @returns {string|string[]|null}
|
|
172
|
+
*/
|
|
173
|
+
getValue() {
|
|
174
|
+
return this.typeValue === "single"
|
|
175
|
+
? (this.selectedValue[0] || null)
|
|
176
|
+
: this.selectedValue
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<%# locals: (variant: :default, icon: nil, css_classes: "", **html_options) %>
|
|
2
|
+
<% merged_data = (html_options.delete(:data) || {}).merge(
|
|
3
|
+
component: :alert,
|
|
4
|
+
variant: variant,
|
|
5
|
+
has_icon: icon.present? || nil
|
|
6
|
+
).compact %>
|
|
7
|
+
|
|
8
|
+
<%= content_tag :div, role: :alert, class: css_classes.presence, data: merged_data, **html_options do %>
|
|
9
|
+
<%= icon_for(icon) if icon.present? %>
|
|
10
|
+
|
|
11
|
+
<div><%= yield %></div>
|
|
12
|
+
<% end %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%# locals: (variant: :default, size: :md, css_classes: "", **html_options) %>
|
|
2
|
+
<% merged_data = (html_options.delete(:data) || {}).merge(
|
|
3
|
+
component: :badge,
|
|
4
|
+
variant: variant,
|
|
5
|
+
size: size
|
|
6
|
+
) %>
|
|
7
|
+
|
|
8
|
+
<%= content_tag :span, class: css_classes, data: merged_data, **html_options do %>
|
|
9
|
+
<%= yield %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# locals: (css_classes: "", responsive: false, **html_options) %>
|
|
2
|
+
<% merged_data = (html_options.delete(:data) || {}).merge(
|
|
3
|
+
component: :breadcrumbs
|
|
4
|
+
)
|
|
5
|
+
|
|
6
|
+
if responsive
|
|
7
|
+
merged_data[:controller] = "breadcrumb"
|
|
8
|
+
end %>
|
|
9
|
+
|
|
10
|
+
<nav
|
|
11
|
+
aria-label="Breadcrumb"
|
|
12
|
+
class="<%= css_classes %>"
|
|
13
|
+
<%= tag.attributes(data: merged_data, **html_options) %>
|
|
14
|
+
>
|
|
15
|
+
<%= yield %>
|
|
16
|
+
</nav>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<div
|
|
2
|
+
data-menu-button-target="content"
|
|
3
|
+
class="
|
|
4
|
+
absolute z-10 overflow-hidden border border-border p-1 text-popover-foreground
|
|
5
|
+
min-w-56 max-w-56 content-fit shadow-md bottom-full rounded-lg hidden
|
|
6
|
+
peer-data-[state=closed]/menu-button:animate-out
|
|
7
|
+
peer-data-[state=closed]/menu-button:fade-out-0
|
|
8
|
+
peer-data-[state=open]/menu-button:fade-in-0
|
|
9
|
+
peer-data-[state=closed]/menu-button:zoom-out-95
|
|
10
|
+
peer-data-[state=open]/menu-button:zoom-in-95
|
|
11
|
+
peer-data-[side=bottom]/menu-button:slide-in-from-top-2
|
|
12
|
+
peer-data-[side=left]/menu-button:slide-in-from-right-2
|
|
13
|
+
peer-data-[side=right]/menu-button:slide-in-from-left-2
|
|
14
|
+
peer-data-[side=top]/menu-button:slide-in-from-bottom-2
|
|
15
|
+
"
|
|
16
|
+
role="menu"
|
|
17
|
+
aria-orientation="vertical"
|
|
18
|
+
aria-labelledby="menu-button"
|
|
19
|
+
tabindex="-1"
|
|
20
|
+
>
|
|
21
|
+
<%= yield %>
|
|
22
|
+
</div>
|
|
23
|
+
<!--
|
|
24
|
+
<div role="separator" aria-orientation="horizontal" class="separator"></div>
|
|
25
|
+
-->
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<%# locals: (css_classes: "", **html_options) %>
|
|
2
|
+
<% merged_data = (html_options.delete(:data) || {}).merge(
|
|
3
|
+
component: "dropdown-menu",
|
|
4
|
+
controller: "dropdown-menu"
|
|
5
|
+
) %>
|
|
6
|
+
|
|
7
|
+
<%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
|
|
8
|
+
<%= yield %>
|
|
9
|
+
<% end %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%# locals: (variant: :default, size: :default, css_classes: "", **html_options) %>
|
|
2
|
+
<% merged_data = (html_options.delete(:data) || {}).merge(
|
|
3
|
+
component: :empty,
|
|
4
|
+
variant: variant,
|
|
5
|
+
size: size
|
|
6
|
+
) %>
|
|
7
|
+
|
|
8
|
+
<%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
|
|
9
|
+
<%= yield %>
|
|
10
|
+
<% end %>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%# locals: (css_classes: "", **html_options) %>
|
|
2
|
+
<% merged_data = (html_options.delete(:data) || {}).merge(
|
|
3
|
+
component: :header
|
|
4
|
+
) %>
|
|
5
|
+
|
|
6
|
+
<%= content_tag :header, class: css_classes.presence, data: merged_data, **html_options do %>
|
|
7
|
+
<div data-header-part="inner"><%= yield %></div>
|
|
8
|
+
<% end %>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<%# locals: (title: "", subtitle: nil, icon: nil, text_icon: nil, icon_classes: "", submenu: false) %>
|
|
2
|
+
|
|
3
|
+
<ul class="flex w-full min-w-0 flex-col gap-1">
|
|
4
|
+
<li class="group/menu-item relative" data-controller="menu-button">
|
|
5
|
+
<button
|
|
6
|
+
data-state="closed"
|
|
7
|
+
class="
|
|
8
|
+
peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2
|
|
9
|
+
text-left outline-none ring-sidebar-ring transition-[width,height,padding]
|
|
10
|
+
focus-visible:ring-2 active:bg-sidebar-accent
|
|
11
|
+
active:text-sidebar-accent-foreground disabled:pointer-events-none
|
|
12
|
+
disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50
|
|
13
|
+
data-[state=open]:bg-sidebar-accent
|
|
14
|
+
data-[state=open]text-sidebar-accent-foreground
|
|
15
|
+
group-data-[collapsible=icon]:size-8! [&>span:last-child]:truncate
|
|
16
|
+
[&>svg]:size-4 [&>svg]:shrink-0 hover:bg-sidebar-accent
|
|
17
|
+
hover:text-sidebar-accent-foreground h-12 text-sm
|
|
18
|
+
group-data-[collapsible=icon]:p-0!
|
|
19
|
+
", data-action="menu-button#toggle", data-menu-button-target="button" }
|
|
20
|
+
>
|
|
21
|
+
<% if icon.present? %>
|
|
22
|
+
<%= image_tag icon, alt: "maquina", class: "#{icon_classes}" %>
|
|
23
|
+
<% elsif text_icon.present? %>
|
|
24
|
+
<div
|
|
25
|
+
class="
|
|
26
|
+
flex aspect-square size-8 items-center justify-center rounded-lg
|
|
27
|
+
bg-sidebar-primary text-sidebar-primary-foreground
|
|
28
|
+
"
|
|
29
|
+
>
|
|
30
|
+
<span class="<%= icon_classes %>"><%= text_icon %></span>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
<div class="grid flex-1 text-left text-sm leading-tight">
|
|
34
|
+
<span class="truncate font-semibold"><%= title %></span>
|
|
35
|
+
<% if subtitle.present? %>
|
|
36
|
+
<span class="truncate text-xs"><%= subtitle %></span>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
<%= icon_for(:chevron_up_down, class: "ml-auto") if submenu %>
|
|
40
|
+
</button>
|
|
41
|
+
|
|
42
|
+
<%= yield %>
|
|
43
|
+
</li>
|
|
44
|
+
</ul>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<%# locals: (css_classes: "", **html_options) %>
|
|
2
|
+
<% merged_data = (html_options.delete(:data) || {}).merge(
|
|
3
|
+
component: :pagination
|
|
4
|
+
) %>
|
|
5
|
+
|
|
6
|
+
<%= content_tag :nav,
|
|
7
|
+
role: "navigation",
|
|
8
|
+
"aria-label": html_options.delete(:"aria-label") || "Pagination",
|
|
9
|
+
class: css_classes.presence,
|
|
10
|
+
data: merged_data,
|
|
11
|
+
**html_options do %>
|
|
12
|
+
<%= yield %>
|
|
13
|
+
<% end %>
|