maquina-components 0.3.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.
@@ -0,0 +1,394 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["day", "input", "inputEnd", "prevButton", "nextButton", "grid", "caption"]
5
+
6
+ static values = {
7
+ month: Number,
8
+ year: Number,
9
+ selected: String,
10
+ selectedEnd: String,
11
+ minDate: String,
12
+ maxDate: String,
13
+ mode: { type: String, default: "single" },
14
+ weekStartsOn: { type: String, default: "sunday" }
15
+ }
16
+
17
+ connect() {
18
+ this.updateNavigationState()
19
+ this.updateDayStates()
20
+ }
21
+
22
+ previousMonth() {
23
+ let newMonth = this.monthValue - 1
24
+ let newYear = this.yearValue
25
+
26
+ if (newMonth < 1) {
27
+ newMonth = 12
28
+ newYear -= 1
29
+ }
30
+
31
+ this.navigateToMonth(newMonth, newYear)
32
+ }
33
+
34
+ nextMonth() {
35
+ let newMonth = this.monthValue + 1
36
+ let newYear = this.yearValue
37
+
38
+ if (newMonth > 12) {
39
+ newMonth = 1
40
+ newYear += 1
41
+ }
42
+
43
+ this.navigateToMonth(newMonth, newYear)
44
+ }
45
+
46
+ navigateToMonth(month, year) {
47
+ this.monthValue = month
48
+ this.yearValue = year
49
+
50
+ this.rebuildCalendar()
51
+ this.updateNavigationState()
52
+
53
+ this.dispatch("navigate", {
54
+ detail: {
55
+ month,
56
+ year,
57
+ selected: this.selectedValue,
58
+ selectedEnd: this.selectedEndValue
59
+ }
60
+ })
61
+ }
62
+
63
+ rebuildCalendar() {
64
+ const grid = this.element.querySelector("[data-calendar-part='grid']")
65
+ const caption = this.element.querySelector("[data-calendar-part='caption']")
66
+ if (!grid) return
67
+
68
+ const year = this.yearValue
69
+ const month = this.monthValue
70
+ const firstOfMonth = new Date(year, month - 1, 1)
71
+ const lastOfMonth = new Date(year, month, 0)
72
+
73
+ // Update caption
74
+ if (caption) {
75
+ caption.textContent = firstOfMonth.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
76
+ }
77
+
78
+ // Calculate start of calendar grid
79
+ const weekStart = this.weekStartsOnValue === "monday" ? 1 : 0
80
+ const daysBefore = (firstOfMonth.getDay() - weekStart + 7) % 7
81
+ const calendarStart = new Date(firstOfMonth)
82
+ calendarStart.setDate(calendarStart.getDate() - daysBefore)
83
+
84
+ // Calculate weeks needed
85
+ const totalDays = daysBefore + lastOfMonth.getDate()
86
+ const weeksNeeded = Math.min(Math.ceil(totalDays / 7), 6)
87
+
88
+ // Build weeks
89
+ const weeks = []
90
+ const currentDate = new Date(calendarStart)
91
+ for (let w = 0; w < weeksNeeded; w++) {
92
+ const week = []
93
+ for (let d = 0; d < 7; d++) {
94
+ week.push(new Date(currentDate))
95
+ currentDate.setDate(currentDate.getDate() + 1)
96
+ }
97
+ weeks.push(week)
98
+ }
99
+
100
+ // Parse dates for comparison
101
+ const selectedDate = this.selectedValue ? new Date(this.selectedValue) : null
102
+ const selectedEndDate = this.selectedEndValue ? new Date(this.selectedEndValue) : null
103
+ const minDate = this.minDateValue ? new Date(this.minDateValue) : null
104
+ const maxDate = this.maxDateValue ? new Date(this.maxDateValue) : null
105
+
106
+ // Rebuild grid HTML
107
+ grid.innerHTML = weeks.map(week => this.buildWeekHTML(week, month, selectedDate, selectedEndDate, minDate, maxDate)).join('')
108
+ }
109
+
110
+ buildWeekHTML(days, displayMonth, selectedDate, selectedEndDate, minDate, maxDate) {
111
+ // Use string comparisons for range to avoid timezone issues
112
+ const selectedStr = this.selectedValue || ""
113
+ const selectedEndStr = this.selectedEndValue || ""
114
+
115
+ const daysHTML = days.map(day => {
116
+ const dateStr = this.formatDate(day)
117
+ const isOutside = day.getMonth() + 1 !== displayMonth
118
+ const isToday = this.isSameDate(day, new Date())
119
+ const isSelected = selectedStr && dateStr === selectedStr
120
+ const isRangeEnd = selectedEndStr && dateStr === selectedEndStr
121
+ // ISO date strings are lexicographically sortable
122
+ const isRangeMiddle = selectedStr && selectedEndStr && dateStr > selectedStr && dateStr < selectedEndStr
123
+ const isDisabled = (minDate && day < minDate) || (maxDate && day > maxDate)
124
+
125
+ let dayState = null
126
+ if (isSelected && this.modeValue === "range" && selectedEndStr) {
127
+ dayState = "range-start"
128
+ } else if (isRangeEnd) {
129
+ dayState = "range-end"
130
+ } else if (isRangeMiddle) {
131
+ dayState = "range-middle"
132
+ } else if (isSelected) {
133
+ dayState = "selected"
134
+ }
135
+
136
+ const attrs = [
137
+ 'type="button"',
138
+ 'data-calendar-part="day"',
139
+ 'data-calendar-target="day"',
140
+ `data-date="${dateStr}"`,
141
+ `data-action="click->calendar#selectDay keydown->calendar#handleKeydown"`,
142
+ `tabindex="${isToday ? '0' : '-1'}"`,
143
+ ]
144
+
145
+ if (dayState) {
146
+ attrs.push(`data-state="${dayState}"`)
147
+ attrs.push('aria-selected="true"')
148
+ }
149
+ if (isOutside) attrs.push('data-outside')
150
+ if (isToday) attrs.push('data-today aria-current="date"')
151
+ if (isDisabled) attrs.push('disabled')
152
+
153
+ return `<button ${attrs.join(' ')}>${day.getDate()}</button>`
154
+ }).join('')
155
+
156
+ return `<div data-calendar-part="week" role="row">${daysHTML}</div>`
157
+ }
158
+
159
+ isSameDate(date1, date2) {
160
+ return date1.getFullYear() === date2.getFullYear() &&
161
+ date1.getMonth() === date2.getMonth() &&
162
+ date1.getDate() === date2.getDate()
163
+ }
164
+
165
+ selectDay(event) {
166
+ const button = event.currentTarget
167
+ if (button.disabled) return
168
+
169
+ const dateStr = button.dataset.date
170
+
171
+ if (this.modeValue === "range") {
172
+ this.handleRangeSelection(dateStr)
173
+ } else {
174
+ this.handleSingleSelection(dateStr)
175
+ }
176
+ }
177
+
178
+ handleSingleSelection(dateStr) {
179
+ if (this.selectedValue === dateStr) {
180
+ this.selectedValue = ""
181
+ } else {
182
+ this.selectedValue = dateStr
183
+ }
184
+
185
+ this.updateDayStates()
186
+ this.updateInputs()
187
+ this.dispatchChange()
188
+ }
189
+
190
+ handleRangeSelection(dateStr) {
191
+ // Use string comparisons to avoid timezone issues
192
+ // ISO date strings are lexicographically sortable
193
+ const selectedStr = this.selectedValue || ""
194
+ const selectedEndStr = this.selectedEndValue || ""
195
+
196
+ if (!selectedStr || (selectedStr && selectedEndStr)) {
197
+ // No selection or complete range - start new selection
198
+ this.selectedValue = dateStr
199
+ this.selectedEndValue = ""
200
+ } else {
201
+ // Have start but no end
202
+ if (dateStr < selectedStr) {
203
+ // Clicked before start - swap
204
+ this.selectedEndValue = this.selectedValue
205
+ this.selectedValue = dateStr
206
+ } else if (dateStr === selectedStr) {
207
+ // Clicked same date - clear
208
+ this.selectedValue = ""
209
+ this.selectedEndValue = ""
210
+ } else {
211
+ // Clicked after start - set end
212
+ this.selectedEndValue = dateStr
213
+ }
214
+ }
215
+
216
+ this.updateDayStates()
217
+ this.updateInputs()
218
+ this.dispatchChange()
219
+ }
220
+
221
+ handleKeydown(event) {
222
+ const currentButton = event.currentTarget
223
+ const currentDate = new Date(currentButton.dataset.date)
224
+ let targetDate = null
225
+
226
+ switch (event.key) {
227
+ case "ArrowRight":
228
+ event.preventDefault()
229
+ targetDate = new Date(currentDate)
230
+ targetDate.setDate(targetDate.getDate() + 1)
231
+ break
232
+ case "ArrowLeft":
233
+ event.preventDefault()
234
+ targetDate = new Date(currentDate)
235
+ targetDate.setDate(targetDate.getDate() - 1)
236
+ break
237
+ case "ArrowDown":
238
+ event.preventDefault()
239
+ targetDate = new Date(currentDate)
240
+ targetDate.setDate(targetDate.getDate() + 7)
241
+ break
242
+ case "ArrowUp":
243
+ event.preventDefault()
244
+ targetDate = new Date(currentDate)
245
+ targetDate.setDate(targetDate.getDate() - 7)
246
+ break
247
+ case "Home":
248
+ event.preventDefault()
249
+ targetDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)
250
+ break
251
+ case "End":
252
+ event.preventDefault()
253
+ targetDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0)
254
+ break
255
+ case "Enter":
256
+ case " ":
257
+ return
258
+ default:
259
+ return
260
+ }
261
+
262
+ if (targetDate) {
263
+ this.focusDate(targetDate)
264
+ }
265
+ }
266
+
267
+ focusDate(date) {
268
+ const dateStr = this.formatDate(date)
269
+ const targetButton = this.dayTargets.find(btn => btn.dataset.date === dateStr)
270
+
271
+ if (targetButton && !targetButton.disabled) {
272
+ targetButton.focus()
273
+ } else {
274
+ this.navigateToMonth(date.getMonth() + 1, date.getFullYear())
275
+ requestAnimationFrame(() => {
276
+ const newButton = this.dayTargets.find(btn => btn.dataset.date === dateStr)
277
+ if (newButton && !newButton.disabled) {
278
+ newButton.focus()
279
+ }
280
+ })
281
+ }
282
+ }
283
+
284
+ updateDayStates() {
285
+ // Use string comparisons to avoid timezone issues
286
+ const selectedStr = this.selectedValue || ""
287
+ const selectedEndStr = this.selectedEndValue || ""
288
+
289
+ this.dayTargets.forEach(button => {
290
+ const dateStr = button.dataset.date
291
+ let state = null
292
+
293
+ if (this.modeValue === "range" && selectedStr && selectedEndStr) {
294
+ if (dateStr === selectedStr) {
295
+ state = "range-start"
296
+ } else if (dateStr === selectedEndStr) {
297
+ state = "range-end"
298
+ } else if (dateStr > selectedStr && dateStr < selectedEndStr) {
299
+ // ISO date strings are lexicographically sortable
300
+ state = "range-middle"
301
+ }
302
+ } else if (this.modeValue === "range" && selectedStr && !selectedEndStr) {
303
+ if (dateStr === selectedStr) {
304
+ state = "range-start"
305
+ }
306
+ } else if (selectedStr && dateStr === selectedStr) {
307
+ state = "selected"
308
+ }
309
+
310
+ if (state) {
311
+ button.dataset.state = state
312
+ button.setAttribute("aria-selected", "true")
313
+ } else {
314
+ delete button.dataset.state
315
+ button.removeAttribute("aria-selected")
316
+ }
317
+ })
318
+ }
319
+
320
+ updateInputs() {
321
+ if (this.hasInputTarget) {
322
+ this.inputTarget.value = this.selectedValue || ""
323
+ }
324
+ if (this.hasInputEndTarget) {
325
+ this.inputEndTarget.value = this.selectedEndValue || ""
326
+ }
327
+ }
328
+
329
+ updateNavigationState() {
330
+ if (this.minDateValue && this.hasPrevButtonTarget) {
331
+ const minDate = new Date(this.minDateValue)
332
+ const firstOfMonth = new Date(this.yearValue, this.monthValue - 1, 1)
333
+ this.prevButtonTarget.disabled = firstOfMonth <= minDate
334
+ }
335
+
336
+ if (this.maxDateValue && this.hasNextButtonTarget) {
337
+ const maxDate = new Date(this.maxDateValue)
338
+ const lastOfMonth = new Date(this.yearValue, this.monthValue, 0)
339
+ this.nextButtonTarget.disabled = lastOfMonth >= maxDate
340
+ }
341
+ }
342
+
343
+ dispatchChange() {
344
+ const detail = {
345
+ mode: this.modeValue,
346
+ selected: this.selectedValue || null,
347
+ selectedEnd: this.selectedEndValue || null
348
+ }
349
+
350
+ this.dispatch("change", { detail })
351
+
352
+ this.element.dispatchEvent(new CustomEvent("calendar:change", {
353
+ bubbles: true,
354
+ detail
355
+ }))
356
+ }
357
+
358
+ formatDate(date) {
359
+ const year = date.getFullYear()
360
+ const month = String(date.getMonth() + 1).padStart(2, "0")
361
+ const day = String(date.getDate()).padStart(2, "0")
362
+ return `${year}-${month}-${day}`
363
+ }
364
+
365
+ getValue() {
366
+ return {
367
+ selected: this.selectedValue || null,
368
+ selectedEnd: this.selectedEndValue || null
369
+ }
370
+ }
371
+
372
+ select(date) {
373
+ const dateStr = date instanceof Date ? this.formatDate(date) : date
374
+
375
+ if (this.modeValue === "single") {
376
+ this.selectedValue = dateStr
377
+ } else {
378
+ this.handleRangeSelection(dateStr)
379
+ return
380
+ }
381
+
382
+ this.updateDayStates()
383
+ this.updateInputs()
384
+ this.dispatchChange()
385
+ }
386
+
387
+ clear() {
388
+ this.selectedValue = ""
389
+ this.selectedEndValue = ""
390
+ this.updateDayStates()
391
+ this.updateInputs()
392
+ this.dispatchChange()
393
+ }
394
+ }
@@ -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
+ }