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,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,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
|
+
}
|