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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +77 -0
  3. data/app/assets/stylesheets/calendar.css +222 -0
  4. data/app/assets/stylesheets/combobox.css +218 -0
  5. data/app/assets/stylesheets/date_picker.css +172 -0
  6. data/app/assets/stylesheets/toast.css +433 -0
  7. data/app/assets/tailwind/maquina_components_engine/engine.css +16 -14
  8. data/app/helpers/maquina_components/calendar_helper.rb +196 -0
  9. data/app/helpers/maquina_components/combobox_helper.rb +300 -0
  10. data/app/helpers/maquina_components/icons_helper.rb +220 -0
  11. data/app/helpers/maquina_components/table_helper.rb +9 -10
  12. data/app/helpers/maquina_components/toast_helper.rb +115 -0
  13. data/app/javascript/controllers/calendar_controller.js +394 -0
  14. data/app/javascript/controllers/combobox_controller.js +325 -0
  15. data/app/javascript/controllers/date_picker_controller.js +261 -0
  16. data/app/javascript/controllers/toast_controller.js +115 -0
  17. data/app/javascript/controllers/toaster_controller.js +226 -0
  18. data/app/views/components/_calendar.html.erb +121 -0
  19. data/app/views/components/_combobox.html.erb +13 -0
  20. data/app/views/components/_date_picker.html.erb +102 -0
  21. data/app/views/components/_toast.html.erb +53 -0
  22. data/app/views/components/_toaster.html.erb +17 -0
  23. data/app/views/components/calendar/_header.html.erb +22 -0
  24. data/app/views/components/calendar/_week.html.erb +53 -0
  25. data/app/views/components/combobox/_content.html.erb +17 -0
  26. data/app/views/components/combobox/_empty.html.erb +9 -0
  27. data/app/views/components/combobox/_group.html.erb +8 -0
  28. data/app/views/components/combobox/_input.html.erb +18 -0
  29. data/app/views/components/combobox/_label.html.erb +8 -0
  30. data/app/views/components/combobox/_list.html.erb +8 -0
  31. data/app/views/components/combobox/_option.html.erb +24 -0
  32. data/app/views/components/combobox/_separator.html.erb +6 -0
  33. data/app/views/components/combobox/_trigger.html.erb +22 -0
  34. data/app/views/components/toast/_action.html.erb +14 -0
  35. data/app/views/components/toast/_description.html.erb +8 -0
  36. data/app/views/components/toast/_title.html.erb +8 -0
  37. data/lib/maquina_components/version.rb +1 -1
  38. 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
+ }