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.
- checksums.yaml +4 -4
- data/README.md +51 -0
- data/app/assets/stylesheets/combobox.css +218 -0
- data/app/assets/stylesheets/toast.css +433 -0
- data/app/assets/tailwind/maquina_components_engine/engine.css +2 -0
- data/app/helpers/maquina_components/combobox_helper.rb +300 -0
- data/app/helpers/maquina_components/toast_helper.rb +115 -0
- data/app/javascript/controllers/combobox_controller.js +325 -0
- data/app/javascript/controllers/toast_controller.js +115 -0
- data/app/javascript/controllers/toaster_controller.js +226 -0
- data/app/views/components/_combobox.html.erb +13 -0
- data/app/views/components/_toast.html.erb +53 -0
- data/app/views/components/_toaster.html.erb +17 -0
- data/app/views/components/combobox/_content.html.erb +17 -0
- data/app/views/components/combobox/_empty.html.erb +9 -0
- data/app/views/components/combobox/_group.html.erb +8 -0
- data/app/views/components/combobox/_input.html.erb +18 -0
- data/app/views/components/combobox/_label.html.erb +8 -0
- data/app/views/components/combobox/_list.html.erb +8 -0
- data/app/views/components/combobox/_option.html.erb +24 -0
- data/app/views/components/combobox/_separator.html.erb +6 -0
- data/app/views/components/combobox/_trigger.html.erb +22 -0
- data/app/views/components/toast/_action.html.erb +14 -0
- data/app/views/components/toast/_description.html.erb +8 -0
- data/app/views/components/toast/_title.html.erb +8 -0
- data/lib/maquina_components/version.rb +1 -1
- metadata +24 -2
|
@@ -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 %>
|