hotwire_combobox 0.1.10 → 0.1.12
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/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
|
+
}
|