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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/README.md +1 -4
  4. data/app/assets/javascripts/controllers/hw_combobox_controller.js +55 -211
  5. data/app/assets/javascripts/helpers.js +52 -0
  6. data/app/assets/javascripts/models/combobox/actors.js +24 -0
  7. data/app/assets/javascripts/models/combobox/async_loading.js +7 -0
  8. data/app/assets/javascripts/models/combobox/autocomplete.js +39 -0
  9. data/app/assets/javascripts/models/combobox/base.js +3 -0
  10. data/app/assets/javascripts/models/combobox/dialog.js +50 -0
  11. data/app/assets/javascripts/models/combobox/filtering.js +57 -0
  12. data/app/assets/javascripts/models/combobox/navigation.js +39 -0
  13. data/app/assets/javascripts/models/combobox/options.js +41 -0
  14. data/app/assets/javascripts/models/combobox/selection.js +62 -0
  15. data/app/assets/javascripts/models/combobox/toggle.js +104 -0
  16. data/app/assets/javascripts/models/combobox/validity.js +34 -0
  17. data/app/assets/javascripts/models/combobox.js +14 -0
  18. data/app/assets/javascripts/vendor/bodyScrollLock.js +299 -0
  19. data/app/assets/stylesheets/hotwire_combobox.css +181 -0
  20. data/app/helpers/hotwire_combobox/helper.rb +62 -73
  21. data/app/presenters/hotwire_combobox/component.rb +257 -0
  22. data/app/presenters/hotwire_combobox/listbox/option.rb +53 -0
  23. data/app/views/hotwire_combobox/_combobox.html.erb +5 -20
  24. data/app/views/hotwire_combobox/_next_page.turbo_stream.erb +5 -0
  25. data/app/views/hotwire_combobox/_paginated_options.turbo_stream.erb +7 -0
  26. data/app/views/hotwire_combobox/_pagination.html.erb +3 -0
  27. data/app/views/hotwire_combobox/combobox/_dialog.html.erb +7 -0
  28. data/app/views/hotwire_combobox/combobox/_hidden_field.html.erb +4 -0
  29. data/app/views/hotwire_combobox/combobox/_input.html.erb +2 -0
  30. data/app/views/hotwire_combobox/combobox/_paginated_listbox.html.erb +11 -0
  31. data/lib/hotwire_combobox/engine.rb +12 -2
  32. data/lib/hotwire_combobox/version.rb +1 -1
  33. data/lib/hotwire_combobox.rb +4 -2
  34. metadata +54 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 115007b81c7f19287376a49f41aa34c43e2fb331a18459b9f47877bf282d15cf
4
- data.tar.gz: b5eb9f21bd0f46f39a434ef8dea3d82a639e00b0dfb7f131c8264c77a55ac2ae
3
+ metadata.gz: f21dd202e0a59116a7d505c6b79df8ba287db06dfa66fd6c9159cbaad15f119d
4
+ data.tar.gz: 9eb4d23df658d14948200e88ee39f9dad471a61782cde5f4132767a6c81763e1
5
5
  SHA512:
6
- metadata.gz: fd39e53b94270d271b0b397d4e983346ea3b99a8054d0f2ab59756a38e440b23d7bf53d63cd34e1422530aaa1fa2fe0fec57aa5d0f693778d52f0f3a2e722b18
7
- data.tar.gz: b8ef213bc60a3b722d25bf4532e562151b4c660ebac0c5c75283cb045532787ff51bcb12ecafdeac28af54dc20262646d4953d5a7f306bdd43950442160831bc
6
+ metadata.gz: 4013316a7bcbaf7080f25987c32f0f2dc4b2a38f11e8cfb62292155d626ab2bbc7d46f5116125b351eacabeb40856e50fb03335f69c95c7e09a919a0a0ed26ca
7
+ data.tar.gz: 6ca62cf9479f2d6951823affeb07320e572ff02dbb34681cf3ef33dabb579103410f7ad6123ee02cd21252c2cdcbb0e06c2efb564913c52b289beaae57e1fb36
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2023 Jose Farias
1
+ Copyright 2024 Jose Farias
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
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
- The hidden input can't have a value that's not in the list of options.
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
- export default class extends Controller {
4
- static classes = [ "selected", "invalid" ]
5
- static targets = [ "combobox", "listbox", "valueField" ]
6
- static values = { expanded: Boolean, filterableAttribute: String, autocompletableAttribute: String }
7
-
8
- connect() {
9
- if (this.valueFieldTarget.value) {
10
- this.selectOptionByValue(this.valueFieldTarget.value)
11
- }
12
- }
13
-
14
- open() {
15
- this.expandedValue = true
16
- }
17
-
18
- close() {
19
- if (!this.isOpen) return
20
- this.commitSelection()
21
- this.expandedValue = false
22
- }
23
-
24
- selectOption(event) {
25
- this.select(event.currentTarget)
26
- this.close()
27
- }
28
-
29
- filter(event) {
30
- const query = this.comboboxTarget.value.trim()
31
-
32
- this.open()
33
-
34
- this.allOptionElements.forEach(applyFilter(query, { matching: this.filterableAttributeValue }))
35
-
36
- if (event.inputType === "deleteContentBackward") {
37
- this.deselect(this.selectedOptionElement)
38
- } else {
39
- this.select(this.visibleOptionElements[0])
40
- }
41
- }
42
-
43
- navigate(event) {
44
- this.keyHandlers[event.key]?.call(this, event)
45
- }
46
-
47
- closeOnClickOutside({ target }) {
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
- closeOnFocusOutside({ target }) {
54
- if (!this.isOpen) return
55
- if (this.element.contains(target)) return
56
- if (target.matches("main")) return
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
- commitSelection() {
87
- this.select(this.selectedOptionElement, { force: true })
58
+ disconnect() {
59
+ this._disconnectDialog()
88
60
  }
89
61
 
90
62
  expandedValueChanged() {
91
63
  if (this.expandedValue) {
92
- this.expand()
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
- target.hidden = false
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,7 @@
1
+ import Combobox from "models/combobox/base"
2
+
3
+ Combobox.AsyncLoading = Base => class extends Base {
4
+ get _isAsync() {
5
+ return this.hasAsyncSrcValue
6
+ }
7
+ }
@@ -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,3 @@
1
+ const Combobox = {}
2
+
3
+ export default Combobox
@@ -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
+ }