hotwire_combobox 0.1.43 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/javascripts/controllers/hw_combobox_controller.js +25 -3
  4. data/app/assets/javascripts/hotwire_combobox.esm.js +438 -104
  5. data/app/assets/javascripts/hotwire_combobox.umd.js +438 -104
  6. data/app/assets/javascripts/hw_combobox/helpers.js +16 -0
  7. data/app/assets/javascripts/hw_combobox/models/combobox/announcements.js +7 -0
  8. data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -1
  9. data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -11
  10. data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +20 -12
  11. data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +74 -0
  12. data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +160 -0
  13. data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +15 -6
  14. data/app/assets/javascripts/hw_combobox/models/combobox/options.js +19 -7
  15. data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +50 -49
  16. data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +33 -16
  17. data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
  18. data/app/assets/javascripts/hw_combobox/models/combobox.js +3 -0
  19. data/app/assets/stylesheets/hotwire_combobox.css +84 -18
  20. data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
  21. data/app/presenters/hotwire_combobox/component.rb +93 -26
  22. data/app/presenters/hotwire_combobox/listbox/group.rb +45 -0
  23. data/app/presenters/hotwire_combobox/listbox/item.rb +104 -0
  24. data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
  25. data/app/views/hotwire_combobox/_component.html.erb +1 -0
  26. data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
  27. data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
  28. data/lib/hotwire_combobox/helper.rb +111 -86
  29. data/lib/hotwire_combobox/version.rb +1 -1
  30. metadata +9 -2
@@ -79,3 +79,19 @@ export function dispatch(eventName, { target, cancelable, detail } = {}) {
79
79
 
80
80
  return event
81
81
  }
82
+
83
+ export function nextRepaint() {
84
+ if (document.visibilityState === "hidden") {
85
+ return nextEventLoopTick()
86
+ } else {
87
+ return nextAnimationFrame()
88
+ }
89
+ }
90
+
91
+ export function nextAnimationFrame() {
92
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()))
93
+ }
94
+
95
+ export function nextEventLoopTick() {
96
+ return new Promise((resolve) => setTimeout(() => resolve(), 0))
97
+ }
@@ -0,0 +1,7 @@
1
+ import Combobox from "hw_combobox/models/combobox/base"
2
+
3
+ Combobox.Announcements = Base => class extends Base {
4
+ _announceToScreenReader(display, action) {
5
+ this.announcerTarget.innerText = `${display} ${action}`
6
+ }
7
+ }
@@ -53,7 +53,7 @@ Combobox.Dialog = Base => class extends Base {
53
53
  this.dialogFocusTrapTarget.focus()
54
54
  }
55
55
 
56
- get _smallViewport() {
56
+ get _isSmallViewport() {
57
57
  return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
58
58
  }
59
59
 
@@ -2,25 +2,35 @@ import Combobox from "hw_combobox/models/combobox/base"
2
2
  import { dispatch } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Events = Base => class extends Base {
5
- _dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
6
- if (previousValue !== this._value) {
7
- dispatch("hw-combobox:selection", {
8
- target: this.element,
9
- detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
10
- })
11
- }
5
+ _dispatchPreselectionEvent({ isNewAndAllowed, previousValue }) {
6
+ if (previousValue === this._incomingFieldValueString) return
7
+
8
+ dispatch("hw-combobox:preselection", {
9
+ target: this.element,
10
+ detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
11
+ })
12
+ }
13
+
14
+ _dispatchSelectionEvent() {
15
+ dispatch("hw-combobox:selection", {
16
+ target: this.element,
17
+ detail: this._eventableDetails
18
+ })
12
19
  }
13
20
 
14
- _dispatchClosedEvent() {
15
- dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails })
21
+ _dispatchRemovalEvent({ removedDisplay, removedValue }) {
22
+ dispatch("hw-combobox:removal", {
23
+ target: this.element,
24
+ detail: { ...this._eventableDetails, removedDisplay, removedValue }
25
+ })
16
26
  }
17
27
 
18
28
  get _eventableDetails() {
19
29
  return {
20
- value: this._value,
30
+ value: this._incomingFieldValueString,
21
31
  display: this._fullQuery,
22
32
  query: this._typedQuery,
23
- fieldName: this.hiddenFieldTarget.name,
33
+ fieldName: this._fieldName,
24
34
  isValid: this._valueIsValid
25
35
  }
26
36
  }
@@ -4,11 +4,11 @@ import { applyFilter, debounce, unselectedPortion } from "hw_combobox/helpers"
4
4
  import { get } from "hw_combobox/vendor/requestjs"
5
5
 
6
6
  Combobox.Filtering = Base => class extends Base {
7
- filterAndSelect(event) {
8
- this._filter(event)
7
+ filterAndSelect({ inputType }) {
8
+ this._filter(inputType)
9
9
 
10
10
  if (this._isSync) {
11
- this._selectBasedOnQuery(event)
11
+ this._selectOnQuery(inputType)
12
12
  } else {
13
13
  // noop, async selection is handled by stimulus callbacks
14
14
  }
@@ -18,24 +18,24 @@ Combobox.Filtering = Base => class extends Base {
18
18
  this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this))
19
19
  }
20
20
 
21
- _filter(event) {
21
+ _filter(inputType) {
22
22
  if (this._isAsync) {
23
- this._debouncedFilterAsync(event)
23
+ this._debouncedFilterAsync(inputType)
24
24
  } else {
25
25
  this._filterSync()
26
26
  }
27
27
 
28
- this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
28
+ this._markQueried()
29
29
  }
30
30
 
31
- _debouncedFilterAsync(event) {
32
- this._filterAsync(event)
31
+ _debouncedFilterAsync(inputType) {
32
+ this._filterAsync(inputType)
33
33
  }
34
34
 
35
- async _filterAsync(event) {
35
+ async _filterAsync(inputType) {
36
36
  const query = {
37
37
  q: this._fullQuery,
38
- input_type: event.inputType,
38
+ input_type: inputType,
39
39
  for_id: this.element.dataset.asyncId
40
40
  }
41
41
 
@@ -43,8 +43,12 @@ Combobox.Filtering = Base => class extends Base {
43
43
  }
44
44
 
45
45
  _filterSync() {
46
- this.open()
47
- this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }))
46
+ this._allFilterableOptionElements.forEach(
47
+ applyFilter(
48
+ this._fullQuery,
49
+ { matching: this.filterableAttributeValue }
50
+ )
51
+ )
48
52
  }
49
53
 
50
54
  _clearQuery() {
@@ -52,6 +56,10 @@ Combobox.Filtering = Base => class extends Base {
52
56
  this.filterAndSelect({ inputType: "deleteContentBackward" })
53
57
  }
54
58
 
59
+ _markQueried() {
60
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
61
+ }
62
+
55
63
  get _isQueried() {
56
64
  return this._fullQuery.length > 0
57
65
  }
@@ -0,0 +1,74 @@
1
+ import Combobox from "hw_combobox/models/combobox/base"
2
+
3
+ Combobox.FormField = Base => class extends Base {
4
+ get _fieldValue() {
5
+ if (this._isMultiselect) {
6
+ const currentValue = this.hiddenFieldTarget.value
7
+ const arrayFromValue = currentValue ? currentValue.split(",") : []
8
+
9
+ return new Set(arrayFromValue)
10
+ } else {
11
+ return this.hiddenFieldTarget.value
12
+ }
13
+ }
14
+
15
+ get _fieldValueString() {
16
+ if (this._isMultiselect) {
17
+ return this._fieldValueArray.join(",")
18
+ } else {
19
+ return this.hiddenFieldTarget.value
20
+ }
21
+ }
22
+
23
+ get _incomingFieldValueString() {
24
+ if (this._isMultiselect) {
25
+ const array = this._fieldValueArray
26
+
27
+ if (this.hiddenFieldTarget.dataset.valueForMultiselect) {
28
+ array.push(this.hiddenFieldTarget.dataset.valueForMultiselect)
29
+ }
30
+
31
+ return array.join(",")
32
+ } else {
33
+ return this.hiddenFieldTarget.value
34
+ }
35
+ }
36
+
37
+ get _fieldValueArray() {
38
+ if (this._isMultiselect) {
39
+ return Array.from(this._fieldValue)
40
+ } else {
41
+ return [ this.hiddenFieldTarget.value ]
42
+ }
43
+ }
44
+
45
+ set _fieldValue(value) {
46
+ if (this._isMultiselect) {
47
+ this.hiddenFieldTarget.dataset.valueForMultiselect = value?.replace(/,/g, "")
48
+ this.hiddenFieldTarget.dataset.displayForMultiselect = this._fullQuery
49
+ } else {
50
+ this.hiddenFieldTarget.value = value
51
+ }
52
+ }
53
+
54
+ get _hasEmptyFieldValue() {
55
+ if (this._isMultiselect) {
56
+ return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
57
+ this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
58
+ } else {
59
+ return this.hiddenFieldTarget.value === ""
60
+ }
61
+ }
62
+
63
+ get _hasFieldValue() {
64
+ return !this._hasEmptyFieldValue
65
+ }
66
+
67
+ get _fieldName() {
68
+ return this.hiddenFieldTarget.name
69
+ }
70
+
71
+ set _fieldName(value) {
72
+ this.hiddenFieldTarget.name = value
73
+ }
74
+ }
@@ -0,0 +1,160 @@
1
+ import Combobox from "hw_combobox/models/combobox/base"
2
+ import { cancel, nextRepaint } from "hw_combobox/helpers"
3
+ import { get } from "hw_combobox/vendor/requestjs"
4
+
5
+ Combobox.Multiselect = Base => class extends Base {
6
+ navigateChip(event) {
7
+ this._chipKeyHandlers[event.key]?.call(this, event)
8
+ }
9
+
10
+ removeChip({ currentTarget, params }) {
11
+ let display
12
+ const option = this._optionElementWithValue(params.value)
13
+
14
+ if (option) {
15
+ display = option.getAttribute(this.autocompletableAttributeValue)
16
+ this._markNotSelected(option)
17
+ this._markNotMultiselected(option)
18
+ } else {
19
+ display = params.value // for new options
20
+ }
21
+
22
+ this._removeFromFieldValue(params.value)
23
+ this._filter("hw:multiselectSync")
24
+
25
+ currentTarget.closest("[data-hw-combobox-chip]").remove()
26
+
27
+ if (!this._isSmallViewport) {
28
+ this.openByFocusing()
29
+ }
30
+
31
+ this._announceToScreenReader(display, "removed")
32
+ this._dispatchRemovalEvent({ removedDisplay: display, removedValue: params.value })
33
+ }
34
+
35
+ hideChipsForCache() {
36
+ this.element.querySelectorAll("[data-hw-combobox-chip]").forEach(chip => chip.hidden = true)
37
+ }
38
+
39
+ _chipKeyHandlers = {
40
+ Backspace: (event) => {
41
+ this.removeChip(event)
42
+ cancel(event)
43
+ },
44
+ Enter: (event) => {
45
+ this.removeChip(event)
46
+ cancel(event)
47
+ },
48
+ Space: (event) => {
49
+ this.removeChip(event)
50
+ cancel(event)
51
+ },
52
+ Escape: (event) => {
53
+ this.openByFocusing()
54
+ cancel(event)
55
+ }
56
+ }
57
+
58
+ _initializeMultiselect() {
59
+ if (!this._isMultiPreselected) {
60
+ this._preselectMultiple()
61
+ this._markMultiPreselected()
62
+ }
63
+ }
64
+
65
+ async _createChip(shouldReopen) {
66
+ if (!this._isMultiselect) return
67
+
68
+ this._beforeClearingMultiselectQuery(async (display, value) => {
69
+ this._fullQuery = ""
70
+ this._filter("hw:multiselectSync")
71
+ this._requestChips(value)
72
+ this._addToFieldValue(value)
73
+ if (shouldReopen) {
74
+ await nextRepaint()
75
+ this.openByFocusing()
76
+ }
77
+ this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.")
78
+ })
79
+ }
80
+
81
+ async _requestChips(values) {
82
+ await get(this.selectionChipSrcValue, {
83
+ responseKind: "turbo-stream",
84
+ query: {
85
+ for_id: this.element.dataset.asyncId,
86
+ combobox_values: values
87
+ }
88
+ })
89
+ }
90
+
91
+ _beforeClearingMultiselectQuery(callback) {
92
+ const display = this.hiddenFieldTarget.dataset.displayForMultiselect
93
+ const value = this.hiddenFieldTarget.dataset.valueForMultiselect
94
+
95
+ if (value && !this._fieldValue.has(value)) {
96
+ callback(display, value)
97
+ }
98
+
99
+ this.hiddenFieldTarget.dataset.displayForMultiselect = ""
100
+ this.hiddenFieldTarget.dataset.valueForMultiselect = ""
101
+ }
102
+
103
+ _resetMultiselectionMarks() {
104
+ if (!this._isMultiselect) return
105
+
106
+ this._fieldValueArray.forEach(value => {
107
+ const option = this._optionElementWithValue(value)
108
+
109
+ if (option) {
110
+ option.setAttribute("data-multiselected", "")
111
+ option.hidden = true
112
+ }
113
+ })
114
+ }
115
+
116
+ _markNotMultiselected(option) {
117
+ if (!this._isMultiselect) return
118
+
119
+ option.removeAttribute("data-multiselected")
120
+ option.hidden = false
121
+ }
122
+
123
+ _addToFieldValue(value) {
124
+ const newValue = this._fieldValue
125
+
126
+ newValue.add(String(value))
127
+ this.hiddenFieldTarget.value = Array.from(newValue).join(",")
128
+
129
+ if (this._isSync) this._resetMultiselectionMarks()
130
+ }
131
+
132
+ _removeFromFieldValue(value) {
133
+ const newValue = this._fieldValue
134
+
135
+ newValue.delete(String(value))
136
+ this.hiddenFieldTarget.value = Array.from(newValue).join(",")
137
+
138
+ if (this._isSync) this._resetMultiselectionMarks()
139
+ }
140
+
141
+ _focusLastChipDismisser() {
142
+ this.chipDismisserTargets[this.chipDismisserTargets.length - 1]?.focus()
143
+ }
144
+
145
+ _markMultiPreselected() {
146
+ this.element.dataset.multiPreselected = ""
147
+ }
148
+
149
+ get _isMultiselect() {
150
+ return this.hasSelectionChipSrcValue
151
+ }
152
+
153
+ get _isSingleSelect() {
154
+ return !this._isMultiselect
155
+ }
156
+
157
+ get _isMultiPreselected() {
158
+ return this.element.hasAttribute("data-multi-preselected")
159
+ }
160
+ }
@@ -4,17 +4,22 @@ import { cancel } from "hw_combobox/helpers"
4
4
  Combobox.Navigation = Base => class extends Base {
5
5
  navigate(event) {
6
6
  if (this._autocompletesList) {
7
- this._keyHandlers[event.key]?.call(this, event)
7
+ this._navigationKeyHandlers[event.key]?.call(this, event)
8
8
  }
9
9
  }
10
10
 
11
- _keyHandlers = {
11
+ _navigationKeyHandlers = {
12
12
  ArrowUp: (event) => {
13
13
  this._selectIndex(this._selectedOptionIndex - 1)
14
14
  cancel(event)
15
15
  },
16
16
  ArrowDown: (event) => {
17
17
  this._selectIndex(this._selectedOptionIndex + 1)
18
+
19
+ if (this._selectedOptionIndex === 0) {
20
+ this._actingListbox.scrollTop = 0
21
+ }
22
+
18
23
  cancel(event)
19
24
  },
20
25
  Home: (event) => {
@@ -26,14 +31,18 @@ Combobox.Navigation = Base => class extends Base {
26
31
  cancel(event)
27
32
  },
28
33
  Enter: (event) => {
29
- this.close()
30
- this._actingCombobox.blur()
34
+ this._closeAndBlur("hw:keyHandler:enter")
31
35
  cancel(event)
32
36
  },
33
37
  Escape: (event) => {
34
- this.close()
35
- this._actingCombobox.blur()
38
+ this._closeAndBlur("hw:keyHandler:escape")
36
39
  cancel(event)
40
+ },
41
+ Backspace: (event) => {
42
+ if (this._isMultiselect && !this._fullQuery) {
43
+ this._focusLastChipDismisser()
44
+ cancel(event)
45
+ }
37
46
  }
38
47
  }
39
48
  }
@@ -11,28 +11,40 @@ Combobox.Options = Base => class extends Base {
11
11
  }
12
12
 
13
13
  _resetOptions(deselectionStrategy) {
14
- this._setName(this.originalNameValue)
14
+ this._fieldName = this.originalNameValue
15
15
  deselectionStrategy()
16
16
  }
17
17
 
18
+ _optionElementWithValue(value) {
19
+ return this._actingListbox.querySelector(`[${this.filterableAttributeValue}][data-value='${value}']`)
20
+ }
21
+
22
+ _displayForOptionElement(element) {
23
+ return element.getAttribute(this.autocompletableAttributeValue)
24
+ }
25
+
18
26
  get _allowNew() {
19
27
  return !!this.nameWhenNewValue
20
28
  }
21
29
 
22
30
  get _allOptions() {
23
- return Array.from(this._allOptionElements)
31
+ return Array.from(this._allFilterableOptionElements)
24
32
  }
25
33
 
26
- get _allOptionElements() {
27
- return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
34
+ get _allFilterableOptionElements() {
35
+ return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`)
28
36
  }
29
37
 
30
38
  get _visibleOptionElements() {
31
- return [ ...this._allOptionElements ].filter(visible)
39
+ return [ ...this._allFilterableOptionElements ].filter(visible)
32
40
  }
33
41
 
34
42
  get _selectedOptionElement() {
35
- return this._actingListbox.querySelector("[role=option][aria-selected=true]")
43
+ return this._actingListbox.querySelector("[role=option][aria-selected=true]:not([data-multiselected])")
44
+ }
45
+
46
+ get _multiselectedOptionElements() {
47
+ return this._actingListbox.querySelectorAll("[role=option][data-multiselected]")
36
48
  }
37
49
 
38
50
  get _selectedOptionIndex() {
@@ -40,7 +52,7 @@ Combobox.Options = Base => class extends Base {
40
52
  }
41
53
 
42
54
  get _isUnjustifiablyBlank() {
43
- const valueIsMissing = !this._value
55
+ const valueIsMissing = this._hasEmptyFieldValue
44
56
  const noBlankOptionSelected = !this._selectedOptionElement
45
57
 
46
58
  return valueIsMissing && noBlankOptionSelected
@@ -2,23 +2,24 @@ import Combobox from "hw_combobox/models/combobox/base"
2
2
  import { wrapAroundAccess, isDeleteEvent } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Selection = Base => class extends Base {
5
- selectOptionOnClick(event) {
6
- this._forceSelectionAndFilter(event.currentTarget, event)
7
- this.close()
5
+ selectOnClick({ currentTarget, inputType }) {
6
+ this._forceSelectionAndFilter(currentTarget, inputType)
7
+ this._closeAndBlur("hw:optionRoleClick")
8
8
  }
9
9
 
10
10
  _connectSelection() {
11
11
  if (this.hasPrefilledDisplayValue) {
12
12
  this._fullQuery = this.prefilledDisplayValue
13
+ this._markQueried()
13
14
  }
14
15
  }
15
16
 
16
- _selectBasedOnQuery(event) {
17
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
17
+ _selectOnQuery(inputType) {
18
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
18
19
  this._selectNew()
19
- } else if (isDeleteEvent(event)) {
20
+ } else if (isDeleteEvent({ inputType: inputType })) {
20
21
  this._deselect()
21
- } else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
22
+ } else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
22
23
  this._selectAndAutocompleteMissingPortion(this._ensurableOption)
23
24
  } else if (this._isOpen && this._visibleOptionElements[0]) {
24
25
  this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0])
@@ -29,45 +30,45 @@ Combobox.Selection = Base => class extends Base {
29
30
  // When selecting from an async dialog listbox: selection is forced, the listbox is filtered,
30
31
  // and the dialog is closed. Filtering ends with an `endOfOptionsStream` target connected
31
32
  // to the now invisible combobox, which is now closed because Turbo waits for "nextRepaint"
32
- // before rendering turbo streams. This ultimately calls +_selectBasedOnQuery+. We do want
33
- // to call +_selectBasedOnQuery+ in this case to account for e.g. selection of
33
+ // before rendering turbo streams. This ultimately calls +_selectOnQuery+. We do want
34
+ // to call +_selectOnQuery+ in this case to account for e.g. selection of
34
35
  // new options. But we will noop here if it's none of the cases checked above.
35
36
  }
36
37
  }
37
38
 
38
39
  _select(option, autocompleteStrategy) {
39
- const previousValue = this._value
40
+ const previousValue = this._fieldValueString
40
41
 
41
42
  this._resetOptionsSilently()
42
43
 
43
44
  autocompleteStrategy(option)
44
45
 
45
- this._setValue(option.dataset.value)
46
+ this._fieldValue = option.dataset.value
46
47
  this._markSelected(option)
47
48
  this._markValid()
48
- this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
49
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
49
50
 
50
51
  option.scrollIntoView({ block: "nearest" })
51
52
  }
52
53
 
53
54
  _selectNew() {
54
- const previousValue = this._value
55
+ const previousValue = this._fieldValueString
55
56
 
56
57
  this._resetOptionsSilently()
57
- this._setValue(this._fullQuery)
58
- this._setName(this.nameWhenNewValue)
58
+ this._fieldValue = this._fullQuery
59
+ this._fieldName = this.nameWhenNewValue
59
60
  this._markValid()
60
- this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue })
61
+ this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue })
61
62
  }
62
63
 
63
64
  _deselect() {
64
- const previousValue = this._value
65
+ const previousValue = this._fieldValueString
65
66
 
66
67
  if (this._selectedOptionElement) {
67
68
  this._markNotSelected(this._selectedOptionElement)
68
69
  }
69
70
 
70
- this._setValue(null)
71
+ this._fieldValue = ""
71
72
  this._setActiveDescendant("")
72
73
 
73
74
  return previousValue
@@ -75,16 +76,7 @@ Combobox.Selection = Base => class extends Base {
75
76
 
76
77
  _deselectAndNotify() {
77
78
  const previousValue = this._deselect()
78
- this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
79
- }
80
-
81
- _forceSelectionAndFilter(option, event) {
82
- this._forceSelectionWithoutFiltering(option)
83
- this._filter(event)
84
- }
85
-
86
- _forceSelectionWithoutFiltering(option) {
87
- this._selectAndReplaceFullQuery(option)
79
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
88
80
  }
89
81
 
90
82
  _selectIndex(index) {
@@ -92,27 +84,40 @@ Combobox.Selection = Base => class extends Base {
92
84
  this._forceSelectionWithoutFiltering(option)
93
85
  }
94
86
 
95
- _preselectOption() {
96
- if (this._hasValueButNoSelection && this._allOptions.length < 100) {
97
- const option = this._allOptions.find(option => {
98
- return option.dataset.value === this._value
99
- })
100
-
87
+ _preselectSingle() {
88
+ if (this._isSingleSelect && this._hasValueButNoSelection && this._allOptions.length < 100) {
89
+ const option = this._optionElementWithValue(this._fieldValue)
101
90
  if (option) this._markSelected(option)
102
91
  }
103
92
  }
104
93
 
105
- _selectAndReplaceFullQuery(option) {
106
- this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this))
94
+ _preselectMultiple() {
95
+ if (this._isMultiselect && this._hasValueButNoSelection) {
96
+ this._requestChips(this._fieldValueString)
97
+ this._resetMultiselectionMarks()
98
+ }
107
99
  }
108
100
 
109
101
  _selectAndAutocompleteMissingPortion(option) {
110
102
  this._select(option, this._autocompleteMissingPortion.bind(this))
111
103
  }
112
104
 
105
+ _selectAndAutocompleteFullQuery(option) {
106
+ this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this))
107
+ }
108
+
109
+ _forceSelectionAndFilter(option, inputType) {
110
+ this._forceSelectionWithoutFiltering(option)
111
+ this._filter(inputType)
112
+ }
113
+
114
+ _forceSelectionWithoutFiltering(option) {
115
+ this._selectAndAutocompleteFullQuery(option)
116
+ }
117
+
113
118
  _lockInSelection() {
114
119
  if (this._shouldLockInSelection) {
115
- this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" })
120
+ this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection")
116
121
  }
117
122
  }
118
123
 
@@ -136,20 +141,16 @@ Combobox.Selection = Base => class extends Base {
136
141
  this._setActiveDescendant("")
137
142
  }
138
143
 
139
- _setValue(value) {
140
- this.hiddenFieldTarget.value = value
141
- }
142
-
143
- _setName(value) {
144
- this.hiddenFieldTarget.name = value
145
- }
146
-
147
- get _value() {
148
- return this.hiddenFieldTarget.value
144
+ get _hasValueButNoSelection() {
145
+ return this._hasFieldValue && !this._hasSelection
149
146
  }
150
147
 
151
- get _hasValueButNoSelection() {
152
- return this._value && !this._selectedOptionElement
148
+ get _hasSelection() {
149
+ if (this._isSingleSelect) {
150
+ this._selectedOptionElement
151
+ } else {
152
+ this._multiselectedOptionElements.length > 0
153
+ }
153
154
  }
154
155
 
155
156
  get _shouldLockInSelection() {