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