maquina-components 0.2.0 → 0.3.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.
@@ -0,0 +1,325 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Combobox Controller
5
+ *
6
+ * Handles autocomplete/search functionality with:
7
+ * - HTML5 Popover API for light-dismiss
8
+ * - Type-ahead filtering
9
+ * - Single selection with toggle
10
+ * - Keyboard navigation
11
+ * - Focus management
12
+ */
13
+ export default class extends Controller {
14
+ static targets = ["trigger", "content", "input", "option", "empty", "label"]
15
+
16
+ static values = {
17
+ value: { type: String, default: "" },
18
+ name: String,
19
+ placeholder: { type: String, default: "Select..." }
20
+ }
21
+
22
+ connect() {
23
+ this.handlePopoverToggle = this.handlePopoverToggle.bind(this)
24
+ this.handleContentKeydown = this.handleContentKeydown.bind(this)
25
+
26
+ if (this.hasContentTarget) {
27
+ this.contentTarget.addEventListener("toggle", this.handlePopoverToggle)
28
+ this.contentTarget.addEventListener("keydown", this.handleContentKeydown)
29
+ }
30
+
31
+ // Set initial selection if value is provided
32
+ if (this.valueValue) {
33
+ this.updateSelectionFromValue()
34
+ }
35
+
36
+ // Update trigger state
37
+ this.updateTriggerState()
38
+ }
39
+
40
+ disconnect() {
41
+ if (this.hasContentTarget) {
42
+ this.contentTarget.removeEventListener("toggle", this.handlePopoverToggle)
43
+ this.contentTarget.removeEventListener("keydown", this.handleContentKeydown)
44
+ }
45
+ }
46
+
47
+ // Popover toggle handling
48
+ toggle(event) {
49
+ // Popover API handles toggle via popovertarget attribute
50
+ // This method is here for programmatic control if needed
51
+ }
52
+
53
+ handlePopoverToggle(event) {
54
+ const isOpen = event.newState === "open"
55
+
56
+ if (this.hasTriggerTarget) {
57
+ this.triggerTarget.setAttribute("aria-expanded", isOpen)
58
+ }
59
+
60
+ if (isOpen) {
61
+ // Position the popover relative to the trigger
62
+ this.positionPopover()
63
+
64
+ // Focus input when opened
65
+ if (this.hasInputTarget) {
66
+ requestAnimationFrame(() => {
67
+ this.inputTarget.focus()
68
+ this.inputTarget.value = ""
69
+ this.resetFilter()
70
+ })
71
+ }
72
+ }
73
+ }
74
+
75
+ positionPopover() {
76
+ if (!this.hasTriggerTarget || !this.hasContentTarget) return
77
+
78
+ const trigger = this.triggerTarget
79
+ const content = this.contentTarget
80
+ const triggerRect = trigger.getBoundingClientRect()
81
+
82
+ // Get alignment from data attribute
83
+ const align = content.dataset.align || "start"
84
+
85
+ // Calculate position
86
+ let left = triggerRect.left
87
+ const top = triggerRect.bottom + 4 // 4px gap
88
+
89
+ // Adjust for alignment
90
+ if (align === "end") {
91
+ left = triggerRect.right - content.offsetWidth
92
+ } else if (align === "center") {
93
+ left = triggerRect.left + (triggerRect.width / 2) - (content.offsetWidth / 2)
94
+ }
95
+
96
+ // Ensure popover doesn't go off-screen horizontally
97
+ const viewportWidth = window.innerWidth
98
+ if (left + content.offsetWidth > viewportWidth - 8) {
99
+ left = viewportWidth - content.offsetWidth - 8
100
+ }
101
+ if (left < 8) {
102
+ left = 8
103
+ }
104
+
105
+ // Apply position
106
+ content.style.position = "fixed"
107
+ content.style.top = `${top}px`
108
+ content.style.left = `${left}px`
109
+ content.style.margin = "0"
110
+
111
+ // Set width to match trigger if content width is "default"
112
+ const width = content.dataset.width
113
+ if (width === "default" || !width) {
114
+ content.style.minWidth = `${triggerRect.width}px`
115
+ }
116
+ }
117
+
118
+ // Filtering
119
+ filter() {
120
+ if (!this.hasInputTarget) return
121
+
122
+ const query = this.inputTarget.value.toLowerCase().trim()
123
+ let visibleCount = 0
124
+
125
+ this.optionTargets.forEach(option => {
126
+ const text = option.textContent.toLowerCase()
127
+ const matches = query === "" || text.includes(query)
128
+ option.hidden = !matches
129
+ if (matches) visibleCount++
130
+ })
131
+
132
+ // Show/hide empty state
133
+ if (this.hasEmptyTarget) {
134
+ this.emptyTarget.hidden = visibleCount > 0
135
+ }
136
+ }
137
+
138
+ resetFilter() {
139
+ this.optionTargets.forEach(option => {
140
+ option.hidden = false
141
+ })
142
+
143
+ if (this.hasEmptyTarget) {
144
+ this.emptyTarget.hidden = true
145
+ }
146
+ }
147
+
148
+ // Selection
149
+ select(event) {
150
+ const option = event.currentTarget
151
+ const value = option.dataset.value
152
+ const label = this.getOptionLabel(option)
153
+
154
+ // Toggle selection (clicking selected item deselects)
155
+ if (this.valueValue === value) {
156
+ this.valueValue = ""
157
+ this.updateLabel(this.placeholderValue)
158
+ } else {
159
+ this.valueValue = value
160
+ this.updateLabel(label)
161
+ }
162
+
163
+ this.updateSelection()
164
+ this.updateTriggerState()
165
+
166
+ // Close popover
167
+ if (this.hasContentTarget) {
168
+ this.contentTarget.hidePopover()
169
+ }
170
+
171
+ // Dispatch change event for external listeners
172
+ this.dispatch("change", {
173
+ detail: {
174
+ value: this.valueValue,
175
+ label: this.valueValue ? label : null
176
+ }
177
+ })
178
+ }
179
+
180
+ getOptionLabel(option) {
181
+ // Get text content excluding the check icon
182
+ const clone = option.cloneNode(true)
183
+ const check = clone.querySelector('[data-combobox-part="check"]')
184
+ if (check) check.remove()
185
+ return clone.textContent.trim()
186
+ }
187
+
188
+ updateSelection() {
189
+ this.optionTargets.forEach(option => {
190
+ const isSelected = option.dataset.value === this.valueValue
191
+ option.dataset.selected = isSelected
192
+ option.setAttribute("aria-selected", isSelected)
193
+
194
+ const check = option.querySelector('[data-combobox-part="check"]')
195
+ if (check) {
196
+ check.classList.toggle("invisible", !isSelected)
197
+ }
198
+ })
199
+ }
200
+
201
+ updateSelectionFromValue() {
202
+ const selectedOption = this.optionTargets.find(
203
+ option => option.dataset.value === this.valueValue
204
+ )
205
+
206
+ if (selectedOption) {
207
+ const label = this.getOptionLabel(selectedOption)
208
+ this.updateLabel(label)
209
+ this.updateSelection()
210
+ }
211
+ }
212
+
213
+ updateLabel(text) {
214
+ if (this.hasLabelTarget) {
215
+ this.labelTarget.textContent = text || this.placeholderValue
216
+ }
217
+ }
218
+
219
+ updateTriggerState() {
220
+ if (this.hasTriggerTarget) {
221
+ this.triggerTarget.dataset.hasValue = this.valueValue !== ""
222
+ }
223
+ }
224
+
225
+ // Keyboard navigation from input field
226
+ keydown(event) {
227
+ this.handleKeyboardNavigation(event)
228
+ }
229
+
230
+ // Keyboard navigation from content (options)
231
+ handleContentKeydown(event) {
232
+ this.handleKeyboardNavigation(event)
233
+ }
234
+
235
+ handleKeyboardNavigation(event) {
236
+ switch (event.key) {
237
+ case "ArrowDown":
238
+ event.preventDefault()
239
+ this.focusNextOption()
240
+ break
241
+
242
+ case "ArrowUp":
243
+ event.preventDefault()
244
+ this.focusPreviousOption()
245
+ break
246
+
247
+ case "Enter":
248
+ event.preventDefault()
249
+ this.selectFocusedOption()
250
+ break
251
+
252
+ case "Home":
253
+ event.preventDefault()
254
+ this.focusFirstOption()
255
+ break
256
+
257
+ case "End":
258
+ event.preventDefault()
259
+ this.focusLastOption()
260
+ break
261
+
262
+ case "Escape":
263
+ event.preventDefault()
264
+ if (this.hasContentTarget) {
265
+ this.contentTarget.hidePopover()
266
+ }
267
+ break
268
+ }
269
+ }
270
+
271
+ get visibleOptions() {
272
+ return this.optionTargets.filter(opt => !opt.hidden && !opt.hasAttribute("aria-disabled"))
273
+ }
274
+
275
+ get focusedOptionIndex() {
276
+ const options = this.visibleOptions
277
+ const focused = document.activeElement
278
+ return options.indexOf(focused)
279
+ }
280
+
281
+ focusFirstOption() {
282
+ const options = this.visibleOptions
283
+ if (options.length > 0) {
284
+ options[0].focus()
285
+ }
286
+ }
287
+
288
+ focusLastOption() {
289
+ const options = this.visibleOptions
290
+ if (options.length > 0) {
291
+ options[options.length - 1].focus()
292
+ }
293
+ }
294
+
295
+ focusNextOption() {
296
+ const options = this.visibleOptions
297
+ if (options.length === 0) return
298
+
299
+ const currentIndex = this.focusedOptionIndex
300
+ const nextIndex = currentIndex < options.length - 1 ? currentIndex + 1 : 0
301
+ options[nextIndex].focus()
302
+ }
303
+
304
+ focusPreviousOption() {
305
+ const options = this.visibleOptions
306
+ if (options.length === 0) return
307
+
308
+ const currentIndex = this.focusedOptionIndex
309
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : options.length - 1
310
+ options[prevIndex].focus()
311
+ }
312
+
313
+ selectFocusedOption() {
314
+ const focused = document.activeElement
315
+ if (this.optionTargets.includes(focused)) {
316
+ this.select({ currentTarget: focused })
317
+ }
318
+ }
319
+
320
+ // Value change callback
321
+ valueValueChanged() {
322
+ this.updateSelection()
323
+ this.updateTriggerState()
324
+ }
325
+ }
@@ -0,0 +1,115 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Toast Controller
5
+ *
6
+ * Manages individual toast lifecycle:
7
+ * - Auto-dismiss timer
8
+ * - Pause on hover
9
+ * - Dismiss animation
10
+ * - Action button callback
11
+ */
12
+ export default class extends Controller {
13
+ static values = {
14
+ duration: { type: Number, default: 5000 },
15
+ dismissible: { type: Boolean, default: true },
16
+ actionCallback: { type: Boolean, default: false }
17
+ }
18
+
19
+ connect() {
20
+ this.timeoutId = null
21
+ this.remainingTime = this.durationValue
22
+ this.startTime = null
23
+
24
+ // Bind event handlers
25
+ this.handleMouseEnter = this.handleMouseEnter.bind(this)
26
+ this.handleMouseLeave = this.handleMouseLeave.bind(this)
27
+
28
+ // Add hover listeners
29
+ this.element.addEventListener("mouseenter", this.handleMouseEnter)
30
+ this.element.addEventListener("mouseleave", this.handleMouseLeave)
31
+
32
+ // Start animation, then start timer
33
+ requestAnimationFrame(() => {
34
+ // Wait for enter animation to complete
35
+ setTimeout(() => {
36
+ this.element.dataset.state = "visible"
37
+ this.startTimer()
38
+ }, 200) // Match CSS animation duration
39
+ })
40
+ }
41
+
42
+ disconnect() {
43
+ this.clearTimer()
44
+ this.element.removeEventListener("mouseenter", this.handleMouseEnter)
45
+ this.element.removeEventListener("mouseleave", this.handleMouseLeave)
46
+ }
47
+
48
+ startTimer() {
49
+ if (this.durationValue <= 0) return // No auto-dismiss
50
+
51
+ this.startTime = Date.now()
52
+ this.timeoutId = setTimeout(() => {
53
+ this.dismiss()
54
+ }, this.remainingTime)
55
+ }
56
+
57
+ clearTimer() {
58
+ if (this.timeoutId) {
59
+ clearTimeout(this.timeoutId)
60
+ this.timeoutId = null
61
+ }
62
+ }
63
+
64
+ pauseTimer() {
65
+ if (this.durationValue <= 0) return
66
+
67
+ this.clearTimer()
68
+
69
+ // Calculate remaining time
70
+ if (this.startTime) {
71
+ const elapsed = Date.now() - this.startTime
72
+ this.remainingTime = Math.max(0, this.remainingTime - elapsed)
73
+ }
74
+ }
75
+
76
+ resumeTimer() {
77
+ if (this.durationValue <= 0) return
78
+ if (this.remainingTime <= 0) return
79
+
80
+ this.startTimer()
81
+ }
82
+
83
+ handleMouseEnter() {
84
+ this.pauseTimer()
85
+ }
86
+
87
+ handleMouseLeave() {
88
+ this.resumeTimer()
89
+ }
90
+
91
+ dismiss() {
92
+ this.clearTimer()
93
+
94
+ // Start exit animation
95
+ this.element.dataset.state = "exiting"
96
+
97
+ // Remove after animation completes
98
+ setTimeout(() => {
99
+ this.element.remove()
100
+ }, 150) // Match CSS animation duration
101
+ }
102
+
103
+ handleAction(event) {
104
+ // If this toast was created via JS API with an action callback,
105
+ // the callback is stored on the toaster controller or needs to be
106
+ // dispatched as an event for the parent to handle
107
+ this.dispatch("action", {
108
+ detail: { toastId: this.element.id },
109
+ bubbles: true
110
+ })
111
+
112
+ // Dismiss after action
113
+ this.dismiss()
114
+ }
115
+ }
@@ -0,0 +1,226 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Toaster Controller
5
+ *
6
+ * Manages the toast container and provides a global JavaScript API
7
+ * for creating toasts programmatically.
8
+ *
9
+ * Usage:
10
+ * Toast.success("Message saved!")
11
+ * Toast.error("Something went wrong", { description: "Please try again" })
12
+ * Toast.info("New update available", { duration: 10000 })
13
+ * Toast.warning("Session expiring soon")
14
+ * Toast.show("Custom message", { variant: "default" })
15
+ * Toast.dismiss(toastId)
16
+ * Toast.dismissAll()
17
+ */
18
+ export default class extends Controller {
19
+ static targets = ["container"]
20
+
21
+ static values = {
22
+ maxVisible: { type: Number, default: 5 }
23
+ }
24
+
25
+ connect() {
26
+ // Expose global Toast API
27
+ this.setupGlobalApi()
28
+ }
29
+
30
+ disconnect() {
31
+ // Clean up global API
32
+ if (window.Toast && window.Toast._controller === this) {
33
+ delete window.Toast
34
+ }
35
+ }
36
+
37
+ setupGlobalApi() {
38
+ const controller = this
39
+
40
+ window.Toast = {
41
+ _controller: controller,
42
+
43
+ show(title, options = {}) {
44
+ return controller.createToast({ title, ...options })
45
+ },
46
+
47
+ success(title, options = {}) {
48
+ return controller.createToast({ title, variant: "success", ...options })
49
+ },
50
+
51
+ info(title, options = {}) {
52
+ return controller.createToast({ title, variant: "info", ...options })
53
+ },
54
+
55
+ warning(title, options = {}) {
56
+ return controller.createToast({ title, variant: "warning", ...options })
57
+ },
58
+
59
+ error(title, options = {}) {
60
+ return controller.createToast({ title, variant: "error", ...options })
61
+ },
62
+
63
+ dismiss(toastId) {
64
+ controller.dismissToast(toastId)
65
+ },
66
+
67
+ dismissAll() {
68
+ controller.dismissAllToasts()
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Create a toast programmatically
75
+ *
76
+ * @param {Object} options
77
+ * @param {string} options.title - Toast title
78
+ * @param {string} options.description - Optional description
79
+ * @param {string} options.variant - default, success, info, warning, error
80
+ * @param {number} options.duration - Auto-dismiss in ms (0 = no auto-dismiss)
81
+ * @param {boolean} options.dismissible - Show close button
82
+ * @param {Object} options.action - { label: string, onClick: function }
83
+ * @returns {string} Toast ID
84
+ */
85
+ createToast(options = {}) {
86
+ const {
87
+ title = "",
88
+ description = "",
89
+ variant = "default",
90
+ duration = 5000,
91
+ dismissible = true,
92
+ action = null
93
+ } = options
94
+
95
+ const toastId = `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
96
+
97
+ // Build toast HTML
98
+ const toastHtml = this.buildToastHtml({
99
+ id: toastId,
100
+ title,
101
+ description,
102
+ variant,
103
+ duration,
104
+ dismissible,
105
+ action
106
+ })
107
+
108
+ // Insert into container
109
+ this.element.insertAdjacentHTML("beforeend", toastHtml)
110
+
111
+ // Enforce max visible
112
+ this.enforceMaxVisible()
113
+
114
+ return toastId
115
+ }
116
+
117
+ buildToastHtml({ id, title, description, variant, duration, dismissible, action }) {
118
+ const icons = {
119
+ default: "",
120
+ success: this.getIconSvg("circle_check"),
121
+ info: this.getIconSvg("info"),
122
+ warning: this.getIconSvg("triangle_alert"),
123
+ error: this.getIconSvg("circle_x")
124
+ }
125
+
126
+ const iconHtml = icons[variant]
127
+ ? `<div data-toast-part="icon">${icons[variant]}</div>`
128
+ : ""
129
+
130
+ const titleHtml = title
131
+ ? `<div data-toast-part="title">${this.escapeHtml(title)}</div>`
132
+ : ""
133
+
134
+ const descriptionHtml = description
135
+ ? `<div data-toast-part="description">${this.escapeHtml(description)}</div>`
136
+ : ""
137
+
138
+ const actionHtml = action
139
+ ? `<button type="button" data-toast-part="action" data-action="click->toast#handleAction">${this.escapeHtml(action.label)}</button>`
140
+ : ""
141
+
142
+ const closeHtml = dismissible
143
+ ? `<button type="button" data-toast-part="close" data-action="toast#dismiss" aria-label="Dismiss notification">${this.getIconSvg("x")}</button>`
144
+ : ""
145
+
146
+ return `
147
+ <div id="${id}"
148
+ role="alert"
149
+ data-component="toast"
150
+ data-controller="toast"
151
+ data-variant="${variant}"
152
+ data-toast-duration-value="${duration}"
153
+ data-toast-dismissible-value="${dismissible}"
154
+ data-toast-action-callback="${action ? "true" : "false"}"
155
+ data-state="entering">
156
+ ${iconHtml}
157
+ <div data-toast-part="content">
158
+ ${titleHtml}
159
+ ${descriptionHtml}
160
+ ${actionHtml}
161
+ </div>
162
+ ${closeHtml}
163
+ </div>
164
+ `.trim()
165
+ }
166
+
167
+ getIconSvg(name) {
168
+ const icons = {
169
+ circle_check: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>',
170
+ info: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
171
+ triangle_alert: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>',
172
+ circle_x: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>',
173
+ x: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>'
174
+ }
175
+ return icons[name] || ""
176
+ }
177
+
178
+ escapeHtml(text) {
179
+ const div = document.createElement("div")
180
+ div.textContent = text
181
+ return div.innerHTML
182
+ }
183
+
184
+ dismissToast(toastId) {
185
+ const toast = document.getElementById(toastId)
186
+ if (toast) {
187
+ // Trigger the toast controller's dismiss method
188
+ const toastController = this.application.getControllerForElementAndIdentifier(toast, "toast")
189
+ if (toastController) {
190
+ toastController.dismiss()
191
+ } else {
192
+ toast.remove()
193
+ }
194
+ }
195
+ }
196
+
197
+ dismissAllToasts() {
198
+ const toasts = this.element.querySelectorAll('[data-component="toast"]')
199
+ toasts.forEach(toast => {
200
+ const toastController = this.application.getControllerForElementAndIdentifier(toast, "toast")
201
+ if (toastController) {
202
+ toastController.dismiss()
203
+ } else {
204
+ toast.remove()
205
+ }
206
+ })
207
+ }
208
+
209
+ enforceMaxVisible() {
210
+ const toasts = this.element.querySelectorAll('[data-component="toast"]')
211
+ const excess = toasts.length - this.maxVisibleValue
212
+
213
+ if (excess > 0) {
214
+ // Remove oldest toasts (first in DOM)
215
+ for (let i = 0; i < excess; i++) {
216
+ const toast = toasts[i]
217
+ const toastController = this.application.getControllerForElementAndIdentifier(toast, "toast")
218
+ if (toastController) {
219
+ toastController.dismiss()
220
+ } else {
221
+ toast.remove()
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,13 @@
1
+ <%# locals: (id: nil, name: nil, value: nil, placeholder: "Select...", css_classes: "", **html_options) %>
2
+ <% combobox_id = id || "combobox-#{SecureRandom.hex(4)}" %>
3
+ <% merged_data = (html_options.delete(:data) || {}).merge(
4
+ component: :combobox,
5
+ controller: "combobox",
6
+ combobox_value_value: value,
7
+ combobox_name_value: name,
8
+ combobox_placeholder_value: placeholder
9
+ ) %>
10
+
11
+ <%= content_tag :div, class: css_classes.presence, data: merged_data, **html_options do %>
12
+ <%= yield combobox_id %>
13
+ <% end %>