hotwire_combobox 0.1.10 → 0.1.12
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/MIT-LICENSE +1 -1
- data/README.md +1 -4
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +55 -211
- 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 +62 -73
- data/app/presenters/hotwire_combobox/component.rb +257 -0
- data/app/presenters/hotwire_combobox/listbox/option.rb +53 -0
- data/app/views/hotwire_combobox/_combobox.html.erb +5 -20
- 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/engine.rb +12 -2
- data/lib/hotwire_combobox/version.rb +1 -1
- data/lib/hotwire_combobox.rb +4 -2
- metadata +54 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f21dd202e0a59116a7d505c6b79df8ba287db06dfa66fd6c9159cbaad15f119d
|
4
|
+
data.tar.gz: 9eb4d23df658d14948200e88ee39f9dad471a61782cde5f4132767a6c81763e1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4013316a7bcbaf7080f25987c32f0f2dc4b2a38f11e8cfb62292155d626ab2bbc7d46f5116125b351eacabeb40856e50fb03335f69c95c7e09a919a0a0ed26ca
|
7
|
+
data.tar.gz: 6ca62cf9479f2d6951823affeb07320e572ff02dbb34681cf3ef33dabb579103410f7ad6123ee02cd21252c2cdcbb0e06c2efb564913c52b289beaae57e1fb36
|
data/MIT-LICENSE
CHANGED
data/README.md
CHANGED
@@ -59,9 +59,6 @@ The `options` argument takes an array of any objects which respond to:
|
|
59
59
|
| autocompletable_as | Used to autocomplete the input element when the user types into it. Falls back to calling `display` on the object if not provided. |
|
60
60
|
| display | Used as a short-hand for other attributes. See the rest of the list for details. |
|
61
61
|
|
62
|
-
> [!NOTE]
|
63
|
-
> The `id` attribute is required only if `value` is not provided.
|
64
|
-
|
65
62
|
You can use the `combobox_options` helper to create an array of option objects which respond to the above methods:
|
66
63
|
|
67
64
|
```ruby
|
@@ -89,7 +86,7 @@ Additionally, you can pass the following [Stimulus class values](https://stimulu
|
|
89
86
|
|
90
87
|
### Validity
|
91
88
|
|
92
|
-
|
89
|
+
Unless `name_when_new` is passed, the hidden input can't have a value that's not in the list of options.
|
93
90
|
|
94
91
|
If a nonexistent value is typed into the combobox, the value of the hidden input will be set empty.
|
95
92
|
|
@@ -1,225 +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
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
if (this.element.contains(target)) return
|
49
|
-
|
50
|
-
this.close()
|
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
|
+
|
36
|
+
static values = {
|
37
|
+
asyncSrc: String,
|
38
|
+
autocompletableAttribute: String,
|
39
|
+
autocomplete: String,
|
40
|
+
expanded: Boolean,
|
41
|
+
filterableAttribute: String,
|
42
|
+
nameWhenNew: String,
|
43
|
+
originalName: String,
|
44
|
+
smallViewportMaxWidth: String
|
45
|
+
}
|
46
|
+
|
47
|
+
initialize() {
|
48
|
+
this._initializeActors()
|
49
|
+
this._initializeFiltering()
|
51
50
|
}
|
52
51
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
this.close()
|
59
|
-
}
|
60
|
-
|
61
|
-
// private
|
62
|
-
|
63
|
-
keyHandlers = {
|
64
|
-
ArrowUp(event) {
|
65
|
-
this.selectIndex(this.selectedOptionIndex - 1)
|
66
|
-
cancel(event)
|
67
|
-
},
|
68
|
-
ArrowDown(event) {
|
69
|
-
this.selectIndex(this.selectedOptionIndex + 1)
|
70
|
-
cancel(event)
|
71
|
-
},
|
72
|
-
Home(event) {
|
73
|
-
this.selectIndex(0)
|
74
|
-
cancel(event)
|
75
|
-
},
|
76
|
-
End(event) {
|
77
|
-
this.selectIndex(this.visibleOptionElements.length - 1)
|
78
|
-
cancel(event)
|
79
|
-
},
|
80
|
-
Enter(event) {
|
81
|
-
this.close()
|
82
|
-
cancel(event)
|
83
|
-
}
|
52
|
+
connect() {
|
53
|
+
this._connectSelection()
|
54
|
+
this._connectListAutocomplete()
|
55
|
+
this._connectDialog()
|
84
56
|
}
|
85
57
|
|
86
|
-
|
87
|
-
this.
|
58
|
+
disconnect() {
|
59
|
+
this._disconnectDialog()
|
88
60
|
}
|
89
61
|
|
90
62
|
expandedValueChanged() {
|
91
63
|
if (this.expandedValue) {
|
92
|
-
this.
|
93
|
-
} else {
|
94
|
-
this.collapse()
|
95
|
-
}
|
96
|
-
}
|
97
|
-
|
98
|
-
expand() {
|
99
|
-
this.listboxTarget.hidden = false
|
100
|
-
this.comboboxTarget.setAttribute("aria-expanded", true)
|
101
|
-
}
|
102
|
-
|
103
|
-
collapse() {
|
104
|
-
this.listboxTarget.hidden = true
|
105
|
-
this.comboboxTarget.setAttribute("aria-expanded", false)
|
106
|
-
}
|
107
|
-
|
108
|
-
select(option, { force = false } = {}) {
|
109
|
-
this.allOptionElements.forEach(option => this.deselect(option))
|
110
|
-
|
111
|
-
if (option) {
|
112
|
-
if (this.hasSelectedClass) option.classList.add(this.selectedClass)
|
113
|
-
if (this.hasInvalidClass) this.comboboxTarget.classList.remove(this.invalidClass)
|
114
|
-
|
115
|
-
this.maybeAutocompleteWith(option, { force })
|
116
|
-
this.executeSelect(option, { selected: true })
|
117
|
-
} else {
|
118
|
-
if (this.valueIsInvalid) {
|
119
|
-
if (this.hasInvalidClass) this.comboboxTarget.classList.add(this.invalidClass)
|
120
|
-
|
121
|
-
this.comboboxTarget.setAttribute("aria-invalid", true)
|
122
|
-
this.comboboxTarget.setAttribute("aria-errormessage", `Please select a valid option for ${this.comboboxTarget.name}`)
|
123
|
-
}
|
124
|
-
}
|
125
|
-
}
|
126
|
-
|
127
|
-
selectIndex(index) {
|
128
|
-
const option = wrapAroundAccess(this.visibleOptionElements, index)
|
129
|
-
this.select(option, { force: true })
|
130
|
-
}
|
131
|
-
|
132
|
-
selectOptionByValue(value) {
|
133
|
-
this.allOptions.find(option => option.dataset.value === value)?.click()
|
134
|
-
}
|
135
|
-
|
136
|
-
deselect(option) {
|
137
|
-
if (option) {
|
138
|
-
if (this.hasSelectedClass) option.classList.remove(this.selectedClass)
|
139
|
-
this.executeSelect(option, { selected: false })
|
140
|
-
}
|
141
|
-
}
|
142
|
-
|
143
|
-
executeSelect(option, { selected }) {
|
144
|
-
if (selected) {
|
145
|
-
option.setAttribute("aria-selected", true)
|
146
|
-
this.valueFieldTarget.value = option.dataset.value
|
147
|
-
} else {
|
148
|
-
option.setAttribute("aria-selected", false)
|
149
|
-
this.valueFieldTarget.value = null
|
150
|
-
}
|
151
|
-
}
|
152
|
-
|
153
|
-
maybeAutocompleteWith(option, { force }) {
|
154
|
-
const typedValue = this.comboboxTarget.value
|
155
|
-
const autocompletedValue = option.dataset.autocompletableAs
|
156
|
-
|
157
|
-
if (force) {
|
158
|
-
this.comboboxTarget.value = autocompletedValue
|
159
|
-
this.comboboxTarget.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
|
160
|
-
} else if (autocompletedValue.toLowerCase().startsWith(typedValue.toLowerCase())) {
|
161
|
-
this.comboboxTarget.value = autocompletedValue
|
162
|
-
this.comboboxTarget.setSelectionRange(typedValue.length, autocompletedValue.length)
|
163
|
-
}
|
164
|
-
}
|
165
|
-
|
166
|
-
get allOptions() {
|
167
|
-
return Array.from(this.allOptionElements)
|
168
|
-
}
|
169
|
-
|
170
|
-
get allOptionElements() {
|
171
|
-
return this.listboxTarget.querySelectorAll(`[${this.filterableAttributeValue}]`)
|
172
|
-
}
|
173
|
-
|
174
|
-
get visibleOptionElements() {
|
175
|
-
return [ ...this.allOptionElements ].filter(visible)
|
176
|
-
}
|
177
|
-
|
178
|
-
get selectedOptionElement() {
|
179
|
-
return this.listboxTarget.querySelector("[role=option][aria-selected=true]")
|
180
|
-
}
|
181
|
-
|
182
|
-
get selectedOptionIndex() {
|
183
|
-
return [ ...this.visibleOptionElements ].indexOf(this.selectedOptionElement)
|
184
|
-
}
|
185
|
-
|
186
|
-
get isOpen() {
|
187
|
-
return this.expandedValue
|
188
|
-
}
|
189
|
-
|
190
|
-
get valueIsInvalid() {
|
191
|
-
const isRequiredAndEmpty = this.comboboxTarget.required && !this.valueFieldTarget.value
|
192
|
-
return isRequiredAndEmpty
|
193
|
-
}
|
194
|
-
}
|
195
|
-
|
196
|
-
function applyFilter(query, { matching }) {
|
197
|
-
return (target) => {
|
198
|
-
if (query) {
|
199
|
-
const value = target.getAttribute(matching) || ""
|
200
|
-
const match = value.toLowerCase().includes(query.toLowerCase())
|
201
|
-
|
202
|
-
target.hidden = !match
|
64
|
+
this._expand()
|
203
65
|
} else {
|
204
|
-
|
66
|
+
this._collapse()
|
205
67
|
}
|
206
68
|
}
|
207
69
|
}
|
208
|
-
|
209
|
-
function visible(target) {
|
210
|
-
return !(target.hidden || target.closest("[hidden]"))
|
211
|
-
}
|
212
|
-
|
213
|
-
function wrapAroundAccess(array, index) {
|
214
|
-
const first = 0
|
215
|
-
const last = array.length - 1
|
216
|
-
|
217
|
-
if (index < first) return array[last]
|
218
|
-
if (index > last) return array[first]
|
219
|
-
return array[index]
|
220
|
-
}
|
221
|
-
|
222
|
-
function cancel(event) {
|
223
|
-
event.stopPropagation()
|
224
|
-
event.preventDefault()
|
225
|
-
}
|
@@ -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
|
+
}
|