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.
- checksums.yaml +4 -4
- data/README.md +26 -0
- data/app/assets/stylesheets/calendar.css +222 -0
- data/app/assets/stylesheets/date_picker.css +172 -0
- data/app/assets/tailwind/maquina_components_engine/engine.css +16 -16
- data/app/helpers/maquina_components/calendar_helper.rb +196 -0
- data/app/helpers/maquina_components/icons_helper.rb +220 -0
- data/app/helpers/maquina_components/table_helper.rb +9 -10
- data/app/javascript/controllers/calendar_controller.js +394 -0
- data/app/javascript/controllers/date_picker_controller.js +261 -0
- data/app/views/components/_calendar.html.erb +121 -0
- data/app/views/components/_date_picker.html.erb +102 -0
- data/app/views/components/calendar/_header.html.erb +22 -0
- data/app/views/components/calendar/_week.html.erb +53 -0
- data/lib/maquina_components/version.rb +1 -1
- metadata +10 -1
|
@@ -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
|
+
}
|