hotwire_combobox 0.1.43 → 0.2.0

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.
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() {