hotwire_combobox 0.1.11 → 0.1.13
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/MIT-LICENSE +1 -1
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +49 -242
- data/app/assets/javascripts/helpers.js +52 -0
- data/app/assets/javascripts/models/combobox/actors.js +24 -0
- data/app/assets/javascripts/models/combobox/async_loading.js +7 -0
- data/app/assets/javascripts/models/combobox/autocomplete.js +39 -0
- data/app/assets/javascripts/models/combobox/base.js +3 -0
- data/app/assets/javascripts/models/combobox/dialog.js +50 -0
- data/app/assets/javascripts/models/combobox/filtering.js +57 -0
- data/app/assets/javascripts/models/combobox/navigation.js +39 -0
- data/app/assets/javascripts/models/combobox/options.js +41 -0
- data/app/assets/javascripts/models/combobox/selection.js +62 -0
- data/app/assets/javascripts/models/combobox/toggle.js +104 -0
- data/app/assets/javascripts/models/combobox/validity.js +34 -0
- data/app/assets/javascripts/models/combobox.js +14 -0
- data/app/assets/javascripts/vendor/bodyScrollLock.js +299 -0
- data/app/assets/stylesheets/hotwire_combobox.css +181 -0
- data/app/helpers/hotwire_combobox/helper.rb +81 -26
- data/app/presenters/hotwire_combobox/component.rb +150 -20
- data/app/presenters/hotwire_combobox/listbox/option.rb +4 -2
- data/app/views/hotwire_combobox/_combobox.html.erb +4 -13
- data/app/views/hotwire_combobox/_next_page.turbo_stream.erb +5 -0
- data/app/views/hotwire_combobox/_paginated_options.turbo_stream.erb +7 -0
- data/app/views/hotwire_combobox/_pagination.html.erb +3 -0
- data/app/views/hotwire_combobox/combobox/_dialog.html.erb +7 -0
- data/app/views/hotwire_combobox/combobox/_hidden_field.html.erb +4 -0
- data/app/views/hotwire_combobox/combobox/_input.html.erb +2 -0
- data/app/views/hotwire_combobox/combobox/_paginated_listbox.html.erb +11 -0
- data/lib/hotwire_combobox/version.rb +1 -1
- data/lib/hotwire_combobox.rb +4 -2
- metadata +52 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4f868770ad8adc907437ea9d7c0f2a9550b848a1ab0ca3dddb3592aacd4ce75a
|
4
|
+
data.tar.gz: 6d1932f507fd650da412d2bacc1d6dc01faed6b8d3f2230fe9df1251b66baaf9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 31127e0fa02f231b604b10b25f5ff514b33065814a3283ae4d6c338ab59835ef8696e19496f867bec8ddc564066a1b5af2c6c7fa1a88e88507c1943bb741f6ce
|
7
|
+
data.tar.gz: 77070143486b82fa1b0d3179e1fb74fe43300988a6a6c2c3654dc883a06d1b4b3a9e069e10d8e71f543ee72ee6a242cba42ef7e595e6ba377a530e73954a1aca
|
data/MIT-LICENSE
CHANGED
@@ -1,262 +1,69 @@
|
|
1
|
+
import Combobox from "models/combobox"
|
2
|
+
import { Concerns } from "helpers"
|
1
3
|
import { Controller } from "@hotwired/stimulus"
|
2
4
|
|
3
|
-
|
4
|
-
|
5
|
-
|
5
|
+
const concerns = [
|
6
|
+
Controller,
|
7
|
+
Combobox.Actors,
|
8
|
+
Combobox.AsyncLoading,
|
9
|
+
Combobox.Autocomplete,
|
10
|
+
Combobox.Dialog,
|
11
|
+
Combobox.Filtering,
|
12
|
+
Combobox.Navigation,
|
13
|
+
Combobox.Options,
|
14
|
+
Combobox.Selection,
|
15
|
+
Combobox.Toggle,
|
16
|
+
Combobox.Validity
|
17
|
+
]
|
18
|
+
|
19
|
+
export default class HwComboboxController extends Concerns(...concerns) {
|
20
|
+
static classes = [
|
21
|
+
"invalid",
|
22
|
+
"selected"
|
23
|
+
]
|
24
|
+
|
25
|
+
static targets = [
|
26
|
+
"combobox",
|
27
|
+
"dialog",
|
28
|
+
"dialogCombobox",
|
29
|
+
"dialogFocusTrap",
|
30
|
+
"dialogListbox",
|
31
|
+
"handle",
|
32
|
+
"hiddenField",
|
33
|
+
"listbox"
|
34
|
+
]
|
35
|
+
|
6
36
|
static values = {
|
37
|
+
asyncSrc: String,
|
38
|
+
autocompletableAttribute: String,
|
39
|
+
autocomplete: String,
|
7
40
|
expanded: Boolean,
|
41
|
+
filterableAttribute: String,
|
8
42
|
nameWhenNew: String,
|
9
43
|
originalName: String,
|
10
|
-
|
11
|
-
autocompletableAttribute: String }
|
12
|
-
|
13
|
-
connect() {
|
14
|
-
if (this.hiddenFieldTarget.value) {
|
15
|
-
this.selectOptionByValue(this.hiddenFieldTarget.value)
|
16
|
-
}
|
17
|
-
}
|
18
|
-
|
19
|
-
open() {
|
20
|
-
this.expandedValue = true
|
21
|
-
}
|
22
|
-
|
23
|
-
close() {
|
24
|
-
if (!this.isOpen) return
|
25
|
-
this.commitSelection()
|
26
|
-
this.expandedValue = false
|
27
|
-
}
|
28
|
-
|
29
|
-
selectOption(event) {
|
30
|
-
this.select(event.currentTarget)
|
31
|
-
this.close()
|
44
|
+
smallViewportMaxWidth: String
|
32
45
|
}
|
33
46
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
this.open()
|
39
|
-
|
40
|
-
this.allOptionElements.forEach(applyFilter(query, { matching: this.filterableAttributeValue }))
|
41
|
-
|
42
|
-
if (this.isValidNewOption(query, { ignoreAutocomplete: isDeleting })) {
|
43
|
-
this.selectNew(query)
|
44
|
-
} else if (isDeleting) {
|
45
|
-
this.deselect(this.selectedOptionElement)
|
46
|
-
} else {
|
47
|
-
this.select(this.visibleOptionElements[0])
|
48
|
-
}
|
49
|
-
}
|
50
|
-
|
51
|
-
navigate(event) {
|
52
|
-
this.keyHandlers[event.key]?.call(this, event)
|
53
|
-
}
|
54
|
-
|
55
|
-
closeOnClickOutside({ target }) {
|
56
|
-
if (this.element.contains(target)) return
|
57
|
-
|
58
|
-
this.close()
|
47
|
+
initialize() {
|
48
|
+
this._initializeActors()
|
49
|
+
this._initializeFiltering()
|
59
50
|
}
|
60
51
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
this.close()
|
67
|
-
}
|
68
|
-
|
69
|
-
// private
|
70
|
-
|
71
|
-
keyHandlers = {
|
72
|
-
ArrowUp(event) {
|
73
|
-
this.selectIndex(this.selectedOptionIndex - 1)
|
74
|
-
cancel(event)
|
75
|
-
},
|
76
|
-
ArrowDown(event) {
|
77
|
-
this.selectIndex(this.selectedOptionIndex + 1)
|
78
|
-
cancel(event)
|
79
|
-
},
|
80
|
-
Home(event) {
|
81
|
-
this.selectIndex(0)
|
82
|
-
cancel(event)
|
83
|
-
},
|
84
|
-
End(event) {
|
85
|
-
this.selectIndex(this.visibleOptionElements.length - 1)
|
86
|
-
cancel(event)
|
87
|
-
},
|
88
|
-
Enter(event) {
|
89
|
-
this.close()
|
90
|
-
cancel(event)
|
91
|
-
}
|
52
|
+
connect() {
|
53
|
+
this._connectSelection()
|
54
|
+
this._connectListAutocomplete()
|
55
|
+
this._connectDialog()
|
92
56
|
}
|
93
57
|
|
94
|
-
|
95
|
-
|
96
|
-
this.select(this.selectedOptionElement, { force: true })
|
97
|
-
}
|
58
|
+
disconnect() {
|
59
|
+
this._disconnectDialog()
|
98
60
|
}
|
99
61
|
|
100
62
|
expandedValueChanged() {
|
101
63
|
if (this.expandedValue) {
|
102
|
-
this.
|
64
|
+
this._expand()
|
103
65
|
} else {
|
104
|
-
this.
|
66
|
+
this._collapse()
|
105
67
|
}
|
106
68
|
}
|
107
|
-
|
108
|
-
expand() {
|
109
|
-
this.listboxTarget.hidden = false
|
110
|
-
this.comboboxTarget.setAttribute("aria-expanded", true)
|
111
|
-
}
|
112
|
-
|
113
|
-
collapse() {
|
114
|
-
this.listboxTarget.hidden = true
|
115
|
-
this.comboboxTarget.setAttribute("aria-expanded", false)
|
116
|
-
}
|
117
|
-
|
118
|
-
select(option, { force = false } = {}) {
|
119
|
-
this.resetOptions()
|
120
|
-
|
121
|
-
if (option) {
|
122
|
-
if (this.hasSelectedClass) option.classList.add(this.selectedClass)
|
123
|
-
if (this.hasInvalidClass) this.comboboxTarget.classList.remove(this.invalidClass)
|
124
|
-
|
125
|
-
this.maybeAutocompleteWith(option, { force })
|
126
|
-
this.executeSelect(option, { selected: true })
|
127
|
-
} else {
|
128
|
-
if (this.valueIsInvalid) {
|
129
|
-
if (this.hasInvalidClass) this.comboboxTarget.classList.add(this.invalidClass)
|
130
|
-
|
131
|
-
this.comboboxTarget.setAttribute("aria-invalid", true)
|
132
|
-
this.comboboxTarget.setAttribute("aria-errormessage", `Please select a valid option for ${this.comboboxTarget.name}`)
|
133
|
-
}
|
134
|
-
}
|
135
|
-
}
|
136
|
-
|
137
|
-
selectNew(query) {
|
138
|
-
this.resetOptions()
|
139
|
-
this.hiddenFieldTarget.value = query
|
140
|
-
this.hiddenFieldTarget.name = this.nameWhenNewValue
|
141
|
-
}
|
142
|
-
|
143
|
-
resetOptions() {
|
144
|
-
this.allOptionElements.forEach(option => this.deselect(option))
|
145
|
-
this.hiddenFieldTarget.name = this.originalNameValue
|
146
|
-
}
|
147
|
-
|
148
|
-
selectIndex(index) {
|
149
|
-
const option = wrapAroundAccess(this.visibleOptionElements, index)
|
150
|
-
this.select(option, { force: true })
|
151
|
-
}
|
152
|
-
|
153
|
-
selectOptionByValue(value) {
|
154
|
-
this.allOptions.find(option => option.dataset.value === value)?.click()
|
155
|
-
}
|
156
|
-
|
157
|
-
deselect(option) {
|
158
|
-
if (option) {
|
159
|
-
if (this.hasSelectedClass) option.classList.remove(this.selectedClass)
|
160
|
-
this.executeSelect(option, { selected: false })
|
161
|
-
}
|
162
|
-
}
|
163
|
-
|
164
|
-
executeSelect(option, { selected }) {
|
165
|
-
if (selected) {
|
166
|
-
option.setAttribute("aria-selected", true)
|
167
|
-
this.hiddenFieldTarget.value = option.dataset.value
|
168
|
-
} else {
|
169
|
-
option.setAttribute("aria-selected", false)
|
170
|
-
this.hiddenFieldTarget.value = null
|
171
|
-
}
|
172
|
-
}
|
173
|
-
|
174
|
-
maybeAutocompleteWith(option, { force }) {
|
175
|
-
const typedValue = this.comboboxTarget.value
|
176
|
-
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
|
177
|
-
|
178
|
-
if (force) {
|
179
|
-
this.comboboxTarget.value = autocompletedValue
|
180
|
-
this.comboboxTarget.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
|
181
|
-
} else if (startsWith(autocompletedValue, typedValue)) {
|
182
|
-
this.comboboxTarget.value = autocompletedValue
|
183
|
-
this.comboboxTarget.setSelectionRange(typedValue.length, autocompletedValue.length)
|
184
|
-
}
|
185
|
-
}
|
186
|
-
|
187
|
-
isValidNewOption(query, { ignoreAutocomplete = false } = {}) {
|
188
|
-
const typedValue = this.comboboxTarget.value
|
189
|
-
const autocompletedValue = this.visibleOptionElements[0]?.getAttribute(this.autocompletableAttributeValue)
|
190
|
-
const insufficentAutocomplete = !autocompletedValue || !startsWith(autocompletedValue, typedValue)
|
191
|
-
|
192
|
-
return query.length > 0 && this.allowNew && (ignoreAutocomplete || insufficentAutocomplete)
|
193
|
-
}
|
194
|
-
|
195
|
-
get allOptions() {
|
196
|
-
return Array.from(this.allOptionElements)
|
197
|
-
}
|
198
|
-
|
199
|
-
get allOptionElements() {
|
200
|
-
return this.listboxTarget.querySelectorAll(`[${this.filterableAttributeValue}]`)
|
201
|
-
}
|
202
|
-
|
203
|
-
get visibleOptionElements() {
|
204
|
-
return [ ...this.allOptionElements ].filter(visible)
|
205
|
-
}
|
206
|
-
|
207
|
-
get selectedOptionElement() {
|
208
|
-
return this.listboxTarget.querySelector("[role=option][aria-selected=true]")
|
209
|
-
}
|
210
|
-
|
211
|
-
get selectedOptionIndex() {
|
212
|
-
return [ ...this.visibleOptionElements ].indexOf(this.selectedOptionElement)
|
213
|
-
}
|
214
|
-
|
215
|
-
get isOpen() {
|
216
|
-
return this.expandedValue
|
217
|
-
}
|
218
|
-
|
219
|
-
get valueIsInvalid() {
|
220
|
-
const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value
|
221
|
-
return isRequiredAndEmpty
|
222
|
-
}
|
223
|
-
|
224
|
-
get allowNew() {
|
225
|
-
return !!this.nameWhenNewValue
|
226
|
-
}
|
227
|
-
}
|
228
|
-
|
229
|
-
function applyFilter(query, { matching }) {
|
230
|
-
return (target) => {
|
231
|
-
if (query) {
|
232
|
-
const value = target.getAttribute(matching) || ""
|
233
|
-
const match = value.toLowerCase().includes(query.toLowerCase())
|
234
|
-
|
235
|
-
target.hidden = !match
|
236
|
-
} else {
|
237
|
-
target.hidden = false
|
238
|
-
}
|
239
|
-
}
|
240
|
-
}
|
241
|
-
|
242
|
-
function visible(target) {
|
243
|
-
return !(target.hidden || target.closest("[hidden]"))
|
244
|
-
}
|
245
|
-
|
246
|
-
function wrapAroundAccess(array, index) {
|
247
|
-
const first = 0
|
248
|
-
const last = array.length - 1
|
249
|
-
|
250
|
-
if (index < first) return array[last]
|
251
|
-
if (index > last) return array[first]
|
252
|
-
return array[index]
|
253
|
-
}
|
254
|
-
|
255
|
-
function cancel(event) {
|
256
|
-
event.stopPropagation()
|
257
|
-
event.preventDefault()
|
258
|
-
}
|
259
|
-
|
260
|
-
function startsWith(string, substring) {
|
261
|
-
return string.toLowerCase().startsWith(substring.toLowerCase())
|
262
69
|
}
|
@@ -0,0 +1,52 @@
|
|
1
|
+
export function Concerns(Base, ...mixins) {
|
2
|
+
return mixins.reduce((accumulator, current) => current(accumulator), Base)
|
3
|
+
}
|
4
|
+
|
5
|
+
export function visible(target) {
|
6
|
+
return !(target.hidden || target.closest("[hidden]"))
|
7
|
+
}
|
8
|
+
|
9
|
+
export function wrapAroundAccess(array, index) {
|
10
|
+
const first = 0
|
11
|
+
const last = array.length - 1
|
12
|
+
|
13
|
+
if (index < first) return array[last]
|
14
|
+
if (index > last) return array[first]
|
15
|
+
return array[index]
|
16
|
+
}
|
17
|
+
|
18
|
+
export function applyFilter(query, { matching }) {
|
19
|
+
return (target) => {
|
20
|
+
if (query) {
|
21
|
+
const value = target.getAttribute(matching) || ""
|
22
|
+
const match = value.toLowerCase().includes(query.toLowerCase())
|
23
|
+
|
24
|
+
target.hidden = !match
|
25
|
+
} else {
|
26
|
+
target.hidden = false
|
27
|
+
}
|
28
|
+
}
|
29
|
+
}
|
30
|
+
|
31
|
+
export function cancel(event) {
|
32
|
+
event.stopPropagation()
|
33
|
+
event.preventDefault()
|
34
|
+
}
|
35
|
+
|
36
|
+
export function startsWith(string, substring) {
|
37
|
+
return string.toLowerCase().startsWith(substring.toLowerCase())
|
38
|
+
}
|
39
|
+
|
40
|
+
export function nextFrame() {
|
41
|
+
return new Promise(requestAnimationFrame)
|
42
|
+
}
|
43
|
+
|
44
|
+
export function debounce(fn, delay = 150) {
|
45
|
+
let timeoutId = null
|
46
|
+
|
47
|
+
return (...args) => {
|
48
|
+
const callback = () => fn.apply(this, args)
|
49
|
+
clearTimeout(timeoutId)
|
50
|
+
timeoutId = setTimeout(callback, delay)
|
51
|
+
}
|
52
|
+
}
|
@@ -0,0 +1,24 @@
|
|
1
|
+
import Combobox from "models/combobox/base"
|
2
|
+
|
3
|
+
Combobox.Actors = Base => class extends Base {
|
4
|
+
_initializeActors() {
|
5
|
+
this._actingListbox = this.listboxTarget
|
6
|
+
this._actingCombobox = this.comboboxTarget
|
7
|
+
}
|
8
|
+
|
9
|
+
get _actingListbox() {
|
10
|
+
return this.actingListbox
|
11
|
+
}
|
12
|
+
|
13
|
+
set _actingListbox(listbox) {
|
14
|
+
this.actingListbox = listbox
|
15
|
+
}
|
16
|
+
|
17
|
+
get _actingCombobox() {
|
18
|
+
return this.actingCombobox
|
19
|
+
}
|
20
|
+
|
21
|
+
set _actingCombobox(combobox) {
|
22
|
+
this.actingCombobox = combobox
|
23
|
+
}
|
24
|
+
}
|
@@ -0,0 +1,39 @@
|
|
1
|
+
import Combobox from "models/combobox/base"
|
2
|
+
import { startsWith } from "helpers"
|
3
|
+
|
4
|
+
Combobox.Autocomplete = Base => class extends Base {
|
5
|
+
_connectListAutocomplete() {
|
6
|
+
if (!this._autocompletesList) {
|
7
|
+
this._visuallyHideListbox()
|
8
|
+
}
|
9
|
+
}
|
10
|
+
|
11
|
+
_maybeAutocompleteWith(option, { force }) {
|
12
|
+
if (!this._autocompletesInline && !force) return
|
13
|
+
|
14
|
+
const typedValue = this._actingCombobox.value
|
15
|
+
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
|
16
|
+
|
17
|
+
if (force) {
|
18
|
+
this._actingCombobox.value = autocompletedValue
|
19
|
+
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
|
20
|
+
} else if (startsWith(autocompletedValue, typedValue)) {
|
21
|
+
this._actingCombobox.value = autocompletedValue
|
22
|
+
this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length)
|
23
|
+
}
|
24
|
+
}
|
25
|
+
|
26
|
+
// +visuallyHideListbox+ hides the listbox from the user,
|
27
|
+
// but makes it still searchable by JS.
|
28
|
+
_visuallyHideListbox() {
|
29
|
+
this.listboxTarget.style.display = "none"
|
30
|
+
}
|
31
|
+
|
32
|
+
get _autocompletesList() {
|
33
|
+
return this.autocompleteValue === "both" || this.autocompleteValue === "list"
|
34
|
+
}
|
35
|
+
|
36
|
+
get _autocompletesInline() {
|
37
|
+
return this.autocompleteValue === "both" || this.autocompleteValue === "inline"
|
38
|
+
}
|
39
|
+
}
|
@@ -0,0 +1,50 @@
|
|
1
|
+
import Combobox from "models/combobox/base"
|
2
|
+
|
3
|
+
Combobox.Dialog = Base => class extends Base {
|
4
|
+
_connectDialog() {
|
5
|
+
if (window.visualViewport) {
|
6
|
+
window.visualViewport.addEventListener("resize", this._resizeDialog)
|
7
|
+
}
|
8
|
+
}
|
9
|
+
|
10
|
+
_disconnectDialog() {
|
11
|
+
if (window.visualViewport) {
|
12
|
+
window.visualViewport.removeEventListener("resize", this._resizeDialog)
|
13
|
+
}
|
14
|
+
}
|
15
|
+
|
16
|
+
_moveArtifactsToDialog() {
|
17
|
+
this.dialogComboboxTarget.value = this.actingCombobox.value
|
18
|
+
|
19
|
+
this._actingCombobox = this.dialogComboboxTarget
|
20
|
+
this._actingListbox = this.dialogListboxTarget
|
21
|
+
|
22
|
+
this.dialogListboxTarget.append(...this.listboxTarget.children)
|
23
|
+
}
|
24
|
+
|
25
|
+
_moveArtifactsInline() {
|
26
|
+
this.comboboxTarget.value = this.actingCombobox.value
|
27
|
+
|
28
|
+
this._actingCombobox = this.comboboxTarget
|
29
|
+
this._actingListbox = this.listboxTarget
|
30
|
+
|
31
|
+
this.listboxTarget.append(...this.dialogListboxTarget.children)
|
32
|
+
}
|
33
|
+
|
34
|
+
_resizeDialog = () => {
|
35
|
+
const fullHeight = window.innerHeight
|
36
|
+
const viewportHeight = window.visualViewport.height
|
37
|
+
this.dialogTarget.style.setProperty("--hw-dialog-bottom-padding", `${fullHeight - viewportHeight}px`)
|
38
|
+
}
|
39
|
+
|
40
|
+
// After closing a dialog, focus returns to the last focused element.
|
41
|
+
// +preventFocusingComboboxAfterClosingDialog+ focuses a placeholder element before opening
|
42
|
+
// the dialog, so that the combobox is not focused again after closing, which would reopen.
|
43
|
+
_preventFocusingComboboxAfterClosingDialog() {
|
44
|
+
this.dialogFocusTrapTarget.focus()
|
45
|
+
}
|
46
|
+
|
47
|
+
get _smallViewport() {
|
48
|
+
return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
|
49
|
+
}
|
50
|
+
}
|
@@ -0,0 +1,57 @@
|
|
1
|
+
|
2
|
+
import Combobox from "models/combobox/base"
|
3
|
+
import { applyFilter, nextFrame, debounce } from "helpers"
|
4
|
+
import { get } from "@rails/request.js"
|
5
|
+
|
6
|
+
Combobox.Filtering = Base => class extends Base {
|
7
|
+
filter(event) {
|
8
|
+
if (this._isAsync) {
|
9
|
+
this._debouncedFilterAsync(event)
|
10
|
+
} else {
|
11
|
+
this._filterSync(event)
|
12
|
+
}
|
13
|
+
}
|
14
|
+
|
15
|
+
_initializeFiltering() {
|
16
|
+
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this))
|
17
|
+
}
|
18
|
+
|
19
|
+
_debouncedFilterAsync(event) {
|
20
|
+
this._filterAsync(event)
|
21
|
+
}
|
22
|
+
|
23
|
+
async _filterAsync(event) {
|
24
|
+
const q = this._actingCombobox.value.trim()
|
25
|
+
|
26
|
+
await get(this.asyncSrcValue, { responseKind: "turbo-stream", query: { q } })
|
27
|
+
|
28
|
+
this._afterTurboStreamRender(() => this._commitFilter(q, event))
|
29
|
+
}
|
30
|
+
|
31
|
+
_filterSync(event) {
|
32
|
+
const query = this._actingCombobox.value.trim()
|
33
|
+
|
34
|
+
this.open()
|
35
|
+
|
36
|
+
this._allOptionElements.forEach(applyFilter(query, { matching: this.filterableAttributeValue }))
|
37
|
+
|
38
|
+
this._commitFilter(query, event)
|
39
|
+
}
|
40
|
+
|
41
|
+
_commitFilter(query, event) {
|
42
|
+
const isDeleting = event.inputType === "deleteContentBackward"
|
43
|
+
|
44
|
+
if (this._isValidNewOption(query, { ignoreAutocomplete: isDeleting })) {
|
45
|
+
this._selectNew(query)
|
46
|
+
} else if (isDeleting) {
|
47
|
+
this._deselect()
|
48
|
+
} else {
|
49
|
+
this._select(this._visibleOptionElements[0])
|
50
|
+
}
|
51
|
+
}
|
52
|
+
|
53
|
+
async _afterTurboStreamRender(callback) {
|
54
|
+
await nextFrame()
|
55
|
+
callback()
|
56
|
+
}
|
57
|
+
}
|
@@ -0,0 +1,39 @@
|
|
1
|
+
import Combobox from "models/combobox/base"
|
2
|
+
import { cancel } from "helpers"
|
3
|
+
|
4
|
+
Combobox.Navigation = Base => class extends Base {
|
5
|
+
navigate(event) {
|
6
|
+
if (this._autocompletesList) {
|
7
|
+
this._keyHandlers[event.key]?.call(this, event)
|
8
|
+
}
|
9
|
+
}
|
10
|
+
|
11
|
+
_keyHandlers = {
|
12
|
+
ArrowUp: (event) => {
|
13
|
+
this._selectIndex(this._selectedOptionIndex - 1)
|
14
|
+
cancel(event)
|
15
|
+
},
|
16
|
+
ArrowDown: (event) => {
|
17
|
+
this._selectIndex(this._selectedOptionIndex + 1)
|
18
|
+
cancel(event)
|
19
|
+
},
|
20
|
+
Home: (event) => {
|
21
|
+
this._selectIndex(0)
|
22
|
+
cancel(event)
|
23
|
+
},
|
24
|
+
End: (event) => {
|
25
|
+
this._selectIndex(this._visibleOptionElements.length - 1)
|
26
|
+
cancel(event)
|
27
|
+
},
|
28
|
+
Enter: (event) => {
|
29
|
+
this.close()
|
30
|
+
this._actingCombobox.blur()
|
31
|
+
cancel(event)
|
32
|
+
},
|
33
|
+
Escape: (event) => {
|
34
|
+
this.close()
|
35
|
+
this._actingCombobox.blur()
|
36
|
+
cancel(event)
|
37
|
+
}
|
38
|
+
}
|
39
|
+
}
|
@@ -0,0 +1,41 @@
|
|
1
|
+
import Combobox from "models/combobox/base"
|
2
|
+
import { visible, startsWith } from "helpers"
|
3
|
+
|
4
|
+
Combobox.Options = Base => class extends Base {
|
5
|
+
_resetOptions() {
|
6
|
+
this._deselect()
|
7
|
+
this.hiddenFieldTarget.name = this.originalNameValue
|
8
|
+
}
|
9
|
+
|
10
|
+
_isValidNewOption(query, { ignoreAutocomplete = false } = {}) {
|
11
|
+
const typedValue = this._actingCombobox.value
|
12
|
+
const autocompletedValue = this._visibleOptionElements[0]?.getAttribute(this.autocompletableAttributeValue)
|
13
|
+
const insufficentAutocomplete = !autocompletedValue || !startsWith(autocompletedValue, typedValue)
|
14
|
+
|
15
|
+
return query.length > 0 && this._allowNew && (ignoreAutocomplete || insufficentAutocomplete)
|
16
|
+
}
|
17
|
+
|
18
|
+
get _allowNew() {
|
19
|
+
return !!this.nameWhenNewValue
|
20
|
+
}
|
21
|
+
|
22
|
+
get _allOptions() {
|
23
|
+
return Array.from(this._allOptionElements)
|
24
|
+
}
|
25
|
+
|
26
|
+
get _allOptionElements() {
|
27
|
+
return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
|
28
|
+
}
|
29
|
+
|
30
|
+
get _visibleOptionElements() {
|
31
|
+
return [ ...this._allOptionElements ].filter(visible)
|
32
|
+
}
|
33
|
+
|
34
|
+
get _selectedOptionElement() {
|
35
|
+
return this._actingListbox.querySelector("[role=option][aria-selected=true]")
|
36
|
+
}
|
37
|
+
|
38
|
+
get _selectedOptionIndex() {
|
39
|
+
return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement)
|
40
|
+
}
|
41
|
+
}
|