maquina-components 0.2.0 → 0.3.1
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 +77 -0
- data/app/assets/stylesheets/calendar.css +222 -0
- data/app/assets/stylesheets/combobox.css +218 -0
- data/app/assets/stylesheets/date_picker.css +172 -0
- data/app/assets/stylesheets/toast.css +433 -0
- data/app/assets/tailwind/maquina_components_engine/engine.css +16 -14
- data/app/helpers/maquina_components/calendar_helper.rb +196 -0
- data/app/helpers/maquina_components/combobox_helper.rb +300 -0
- data/app/helpers/maquina_components/icons_helper.rb +220 -0
- data/app/helpers/maquina_components/table_helper.rb +9 -10
- data/app/helpers/maquina_components/toast_helper.rb +115 -0
- data/app/javascript/controllers/calendar_controller.js +394 -0
- data/app/javascript/controllers/combobox_controller.js +325 -0
- data/app/javascript/controllers/date_picker_controller.js +261 -0
- data/app/javascript/controllers/toast_controller.js +115 -0
- data/app/javascript/controllers/toaster_controller.js +226 -0
- data/app/views/components/_calendar.html.erb +121 -0
- data/app/views/components/_combobox.html.erb +13 -0
- data/app/views/components/_date_picker.html.erb +102 -0
- data/app/views/components/_toast.html.erb +53 -0
- data/app/views/components/_toaster.html.erb +17 -0
- data/app/views/components/calendar/_header.html.erb +22 -0
- data/app/views/components/calendar/_week.html.erb +53 -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 +33 -2
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DatePicker Controller
|
|
5
|
+
*
|
|
6
|
+
* Coordinates the trigger button, popover, and calendar.
|
|
7
|
+
* Uses native Popover API - minimal JS needed.
|
|
8
|
+
*
|
|
9
|
+
* @example Single date
|
|
10
|
+
* <div data-controller="date-picker"
|
|
11
|
+
* data-date-picker-mode-value="single">
|
|
12
|
+
* <button popovertarget="popover-id">Pick date</button>
|
|
13
|
+
* <div id="popover-id" popover>
|
|
14
|
+
* <!-- Calendar component -->
|
|
15
|
+
* </div>
|
|
16
|
+
* </div>
|
|
17
|
+
*/
|
|
18
|
+
export default class extends Controller {
|
|
19
|
+
static targets = ["trigger", "popover", "calendar", "input", "inputEnd", "display"]
|
|
20
|
+
|
|
21
|
+
static values = {
|
|
22
|
+
mode: { type: String, default: "single" },
|
|
23
|
+
selected: String,
|
|
24
|
+
selectedEnd: String,
|
|
25
|
+
format: { type: String, default: "long" },
|
|
26
|
+
placeholder: String,
|
|
27
|
+
placeholderRange: String
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
connect() {
|
|
31
|
+
this.setupPopoverEvents()
|
|
32
|
+
this.updateDisplay()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
disconnect() {
|
|
36
|
+
this.teardownPopoverEvents()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Setup popover toggle events for aria-expanded
|
|
41
|
+
*/
|
|
42
|
+
setupPopoverEvents() {
|
|
43
|
+
if (this.hasPopoverTarget) {
|
|
44
|
+
this.boundHandleToggle = this.handlePopoverToggle.bind(this)
|
|
45
|
+
this.popoverTarget.addEventListener("toggle", this.boundHandleToggle)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
teardownPopoverEvents() {
|
|
50
|
+
if (this.hasPopoverTarget && this.boundHandleToggle) {
|
|
51
|
+
this.popoverTarget.removeEventListener("toggle", this.boundHandleToggle)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Handle popover toggle event
|
|
57
|
+
* Updates aria-expanded on trigger
|
|
58
|
+
*/
|
|
59
|
+
handlePopoverToggle(event) {
|
|
60
|
+
const isOpen = event.newState === "open"
|
|
61
|
+
|
|
62
|
+
if (this.hasTriggerTarget) {
|
|
63
|
+
this.triggerTarget.setAttribute("aria-expanded", isOpen.toString())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isOpen && this.hasCalendarTarget) {
|
|
67
|
+
// Focus first focusable element in calendar after opening
|
|
68
|
+
requestAnimationFrame(() => {
|
|
69
|
+
const focusable = this.calendarTarget.querySelector("[data-today], [data-calendar-part='day']")
|
|
70
|
+
focusable?.focus()
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handle calendar change event
|
|
77
|
+
* @param {CustomEvent} event - calendar:change event
|
|
78
|
+
*/
|
|
79
|
+
handleChange(event) {
|
|
80
|
+
const { selected, selectedEnd } = event.detail
|
|
81
|
+
|
|
82
|
+
this.selectedValue = selected || ""
|
|
83
|
+
this.selectedEndValue = selectedEnd || ""
|
|
84
|
+
|
|
85
|
+
this.updateInputs()
|
|
86
|
+
this.updateDisplay()
|
|
87
|
+
|
|
88
|
+
// Close popover after selection in single mode
|
|
89
|
+
// In range mode, close after both dates selected
|
|
90
|
+
if (this.modeValue === "single" && selected) {
|
|
91
|
+
this.closePopover()
|
|
92
|
+
} else if (this.modeValue === "range" && selected && selectedEnd) {
|
|
93
|
+
this.closePopover()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Dispatch change event
|
|
97
|
+
this.dispatch("change", {
|
|
98
|
+
detail: {
|
|
99
|
+
selected: this.selectedValue || null,
|
|
100
|
+
selectedEnd: this.selectedEndValue || null
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Handle calendar navigate event
|
|
107
|
+
* Can be used to fetch events for the new month
|
|
108
|
+
*/
|
|
109
|
+
handleNavigate(event) {
|
|
110
|
+
this.dispatch("navigate", { detail: event.detail })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Update hidden input values
|
|
115
|
+
*/
|
|
116
|
+
updateInputs() {
|
|
117
|
+
if (this.hasInputTarget) {
|
|
118
|
+
this.inputTarget.value = this.selectedValue || ""
|
|
119
|
+
}
|
|
120
|
+
if (this.hasInputEndTarget) {
|
|
121
|
+
this.inputEndTarget.value = this.selectedEndValue || ""
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Update display text
|
|
127
|
+
*/
|
|
128
|
+
updateDisplay() {
|
|
129
|
+
if (!this.hasDisplayTarget) return
|
|
130
|
+
|
|
131
|
+
const selected = this.selectedValue
|
|
132
|
+
const selectedEnd = this.selectedEndValue
|
|
133
|
+
|
|
134
|
+
let displayText = ""
|
|
135
|
+
|
|
136
|
+
if (this.modeValue === "range") {
|
|
137
|
+
if (selected && selectedEnd) {
|
|
138
|
+
displayText = `${this.formatDate(selected, "short")} - ${this.formatDate(selectedEnd, "short")}`
|
|
139
|
+
} else if (selected) {
|
|
140
|
+
displayText = `${this.formatDate(selected, "short")} - ...`
|
|
141
|
+
} else {
|
|
142
|
+
displayText = this.placeholderRangeValue || "Select date range"
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
if (selected) {
|
|
146
|
+
displayText = this.formatDate(selected, this.formatValue)
|
|
147
|
+
} else {
|
|
148
|
+
displayText = this.placeholderValue || "Select date"
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.displayTarget.textContent = displayText
|
|
153
|
+
|
|
154
|
+
// Toggle placeholder styling
|
|
155
|
+
const placeholderIndicator = this.triggerTarget?.querySelector("[data-date-picker-part='placeholder-indicator']")
|
|
156
|
+
if (placeholderIndicator) {
|
|
157
|
+
placeholderIndicator.remove()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!selected) {
|
|
161
|
+
const indicator = document.createElement("span")
|
|
162
|
+
indicator.setAttribute("data-date-picker-part", "placeholder-indicator")
|
|
163
|
+
this.displayTarget.after(indicator)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Format date for display
|
|
169
|
+
*/
|
|
170
|
+
formatDate(dateStr, format = "long") {
|
|
171
|
+
if (!dateStr) return ""
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const date = new Date(dateStr + "T00:00:00")
|
|
175
|
+
const options = format === "short"
|
|
176
|
+
? { month: "short", day: "numeric", year: "numeric" }
|
|
177
|
+
: { weekday: "long", month: "long", day: "numeric", year: "numeric" }
|
|
178
|
+
|
|
179
|
+
return date.toLocaleDateString(undefined, options)
|
|
180
|
+
} catch {
|
|
181
|
+
return dateStr
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Close the popover
|
|
187
|
+
*/
|
|
188
|
+
closePopover() {
|
|
189
|
+
if (this.hasPopoverTarget) {
|
|
190
|
+
this.popoverTarget.hidePopover()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Open the popover
|
|
196
|
+
*/
|
|
197
|
+
openPopover() {
|
|
198
|
+
if (this.hasPopoverTarget) {
|
|
199
|
+
this.popoverTarget.showPopover()
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Toggle the popover
|
|
205
|
+
*/
|
|
206
|
+
toggle() {
|
|
207
|
+
if (this.hasPopoverTarget) {
|
|
208
|
+
this.popoverTarget.togglePopover()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Clear the selection
|
|
214
|
+
*/
|
|
215
|
+
clear() {
|
|
216
|
+
this.selectedValue = ""
|
|
217
|
+
this.selectedEndValue = ""
|
|
218
|
+
this.updateInputs()
|
|
219
|
+
this.updateDisplay()
|
|
220
|
+
|
|
221
|
+
// Also clear the calendar
|
|
222
|
+
if (this.hasCalendarTarget) {
|
|
223
|
+
const calendarController = this.application.getControllerForElementAndIdentifier(
|
|
224
|
+
this.calendarTarget,
|
|
225
|
+
"calendar"
|
|
226
|
+
)
|
|
227
|
+
calendarController?.clear()
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.dispatch("change", {
|
|
231
|
+
detail: { selected: null, selectedEnd: null }
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get current value(s)
|
|
237
|
+
*/
|
|
238
|
+
getValue() {
|
|
239
|
+
return {
|
|
240
|
+
selected: this.selectedValue || null,
|
|
241
|
+
selectedEnd: this.selectedEndValue || null
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Programmatically set value
|
|
247
|
+
*/
|
|
248
|
+
setValue(selected, selectedEnd = null) {
|
|
249
|
+
this.selectedValue = selected || ""
|
|
250
|
+
this.selectedEndValue = selectedEnd || ""
|
|
251
|
+
this.updateInputs()
|
|
252
|
+
this.updateDisplay()
|
|
253
|
+
|
|
254
|
+
this.dispatch("change", {
|
|
255
|
+
detail: {
|
|
256
|
+
selected: this.selectedValue || null,
|
|
257
|
+
selectedEnd: this.selectedEndValue || null
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
}
|
|
@@ -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
|
+
}
|