hotwire_combobox 0.1.42 → 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 (32) 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 +531 -127
  5. data/app/assets/javascripts/hotwire_combobox.umd.js +531 -127
  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/async_loading.js +4 -0
  9. data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +8 -6
  10. data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -1
  11. data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -6
  12. data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +33 -28
  13. data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +74 -0
  14. data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +160 -0
  15. data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +15 -6
  16. data/app/assets/javascripts/hw_combobox/models/combobox/options.js +29 -9
  17. data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +103 -51
  18. data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +45 -16
  19. data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
  20. data/app/assets/javascripts/hw_combobox/models/combobox.js +3 -0
  21. data/app/assets/stylesheets/hotwire_combobox.css +84 -18
  22. data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
  23. data/app/presenters/hotwire_combobox/component.rb +95 -28
  24. data/app/presenters/hotwire_combobox/listbox/group.rb +45 -0
  25. data/app/presenters/hotwire_combobox/listbox/item.rb +104 -0
  26. data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
  27. data/app/views/hotwire_combobox/_component.html.erb +1 -0
  28. data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
  29. data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
  30. data/lib/hotwire_combobox/helper.rb +111 -86
  31. data/lib/hotwire_combobox/version.rb +1 -1
  32. metadata +9 -2
@@ -1,104 +1,156 @@
1
1
  import Combobox from "hw_combobox/models/combobox/base"
2
- import { wrapAroundAccess } from "hw_combobox/helpers"
2
+ import { wrapAroundAccess, isDeleteEvent } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Selection = Base => class extends Base {
5
- selectOptionOnClick(event) {
6
- this.filter(event)
7
- this._select(event.currentTarget, { forceAutocomplete: true })
8
- this.close()
5
+ selectOnClick({ currentTarget, inputType }) {
6
+ this._forceSelectionAndFilter(currentTarget, inputType)
7
+ this._closeAndBlur("hw:optionRoleClick")
9
8
  }
10
9
 
11
10
  _connectSelection() {
12
11
  if (this.hasPrefilledDisplayValue) {
13
12
  this._fullQuery = this.prefilledDisplayValue
13
+ this._markQueried()
14
14
  }
15
15
  }
16
16
 
17
- _select(option, { forceAutocomplete = false } = {}) {
18
- this._resetOptions()
19
-
20
- if (option) {
21
- this._autocompleteWith(option, { force: forceAutocomplete })
22
- this._commitSelection(option, { selected: true })
23
- this._markValid()
24
- } else {
17
+ _selectOnQuery(inputType) {
18
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
19
+ this._selectNew()
20
+ } else if (isDeleteEvent({ inputType: inputType })) {
21
+ this._deselect()
22
+ } else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
23
+ this._selectAndAutocompleteMissingPortion(this._ensurableOption)
24
+ } else if (this._isOpen && this._visibleOptionElements[0]) {
25
+ this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0])
26
+ } else if (this._isOpen) {
27
+ this._resetOptionsAndNotify()
25
28
  this._markInvalid()
29
+ } else {
30
+ // When selecting from an async dialog listbox: selection is forced, the listbox is filtered,
31
+ // and the dialog is closed. Filtering ends with an `endOfOptionsStream` target connected
32
+ // to the now invisible combobox, which is now closed because Turbo waits for "nextRepaint"
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
35
+ // new options. But we will noop here if it's none of the cases checked above.
26
36
  }
27
37
  }
28
38
 
29
- _commitSelection(option, { selected }) {
30
- this._markSelected(option, { selected })
39
+ _select(option, autocompleteStrategy) {
40
+ const previousValue = this._fieldValueString
31
41
 
32
- if (selected) {
33
- this.hiddenFieldTarget.value = option.dataset.value
34
- option.scrollIntoView({ block: "nearest" })
35
- }
42
+ this._resetOptionsSilently()
36
43
 
37
- this._dispatchSelectionEvent({ isNew: false })
44
+ autocompleteStrategy(option)
45
+
46
+ this._fieldValue = option.dataset.value
47
+ this._markSelected(option)
48
+ this._markValid()
49
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
50
+
51
+ option.scrollIntoView({ block: "nearest" })
38
52
  }
39
53
 
40
- _markSelected(option, { selected }) {
41
- if (this.hasSelectedClass) {
42
- option.classList.toggle(this.selectedClass, selected)
43
- }
54
+ _selectNew() {
55
+ const previousValue = this._fieldValueString
44
56
 
45
- option.setAttribute("aria-selected", selected)
46
- this._setActiveDescendant(selected ? option.id : "")
57
+ this._resetOptionsSilently()
58
+ this._fieldValue = this._fullQuery
59
+ this._fieldName = this.nameWhenNewValue
60
+ this._markValid()
61
+ this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue })
47
62
  }
48
63
 
49
64
  _deselect() {
50
- const option = this._selectedOptionElement
65
+ const previousValue = this._fieldValueString
51
66
 
52
- if (option) this._commitSelection(option, { selected: false })
67
+ if (this._selectedOptionElement) {
68
+ this._markNotSelected(this._selectedOptionElement)
69
+ }
53
70
 
54
- this.hiddenFieldTarget.value = null
71
+ this._fieldValue = ""
55
72
  this._setActiveDescendant("")
56
73
 
57
- if (!option) this._dispatchSelectionEvent({ isNew: false })
74
+ return previousValue
58
75
  }
59
76
 
60
- _selectNew() {
61
- this._resetOptions()
62
- this.hiddenFieldTarget.value = this._fullQuery
63
- this.hiddenFieldTarget.name = this.nameWhenNewValue
64
- this._markValid()
65
-
66
- this._dispatchSelectionEvent({ isNew: true })
77
+ _deselectAndNotify() {
78
+ const previousValue = this._deselect()
79
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
67
80
  }
68
81
 
69
82
  _selectIndex(index) {
70
83
  const option = wrapAroundAccess(this._visibleOptionElements, index)
71
- this._select(option, { forceAutocomplete: true })
84
+ this._forceSelectionWithoutFiltering(option)
72
85
  }
73
86
 
74
- _preselectOption() {
75
- if (this._hasValueButNoSelection && this._allOptions.length < 100) {
76
- const option = this._allOptions.find(option => {
77
- return option.dataset.value === this.hiddenFieldTarget.value
78
- })
87
+ _preselectSingle() {
88
+ if (this._isSingleSelect && this._hasValueButNoSelection && this._allOptions.length < 100) {
89
+ const option = this._optionElementWithValue(this._fieldValue)
90
+ if (option) this._markSelected(option)
91
+ }
92
+ }
79
93
 
80
- if (option) this._markSelected(option, { selected: true })
94
+ _preselectMultiple() {
95
+ if (this._isMultiselect && this._hasValueButNoSelection) {
96
+ this._requestChips(this._fieldValueString)
97
+ this._resetMultiselectionMarks()
81
98
  }
82
99
  }
83
100
 
101
+ _selectAndAutocompleteMissingPortion(option) {
102
+ this._select(option, this._autocompleteMissingPortion.bind(this))
103
+ }
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
+
84
118
  _lockInSelection() {
85
119
  if (this._shouldLockInSelection) {
86
- this._select(this._ensurableOption, { forceAutocomplete: true })
87
- this.filter({ inputType: "hw:lockInSelection" })
120
+ this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection")
88
121
  }
122
+ }
89
123
 
90
- if (this._isUnjustifiablyBlank) {
91
- this._deselect()
92
- this._clearQuery()
93
- }
124
+ _markSelected(option) {
125
+ if (this.hasSelectedClass) option.classList.add(this.selectedClass)
126
+ option.setAttribute("aria-selected", true)
127
+ this._setActiveDescendant(option.id)
128
+ }
129
+
130
+ _markNotSelected(option) {
131
+ if (this.hasSelectedClass) option.classList.remove(this.selectedClass)
132
+ option.removeAttribute("aria-selected")
133
+ this._removeActiveDescendant()
94
134
  }
95
135
 
96
136
  _setActiveDescendant(id) {
97
137
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id))
98
138
  }
99
139
 
140
+ _removeActiveDescendant() {
141
+ this._setActiveDescendant("")
142
+ }
143
+
100
144
  get _hasValueButNoSelection() {
101
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
145
+ return this._hasFieldValue && !this._hasSelection
146
+ }
147
+
148
+ get _hasSelection() {
149
+ if (this._isSingleSelect) {
150
+ this._selectedOptionElement
151
+ } else {
152
+ this._multiselectedOptionElements.length > 0
153
+ }
102
154
  }
103
155
 
104
156
  get _shouldLockInSelection() {
@@ -6,19 +6,40 @@ Combobox.Toggle = Base => class extends Base {
6
6
  this.expandedValue = true
7
7
  }
8
8
 
9
- close() {
9
+ openByFocusing() {
10
+ this._actingCombobox.focus()
11
+ }
12
+
13
+ close(inputType) {
10
14
  if (this._isOpen) {
15
+ const shouldReopen = this._isMultiselect &&
16
+ this._isSync &&
17
+ !this._isSmallViewport &&
18
+ inputType != "hw:clickOutside" &&
19
+ inputType != "hw:focusOutside"
20
+
11
21
  this._lockInSelection()
22
+ this._clearInvalidQuery()
23
+
12
24
  this.expandedValue = false
13
- this._dispatchClosedEvent()
25
+
26
+ this._dispatchSelectionEvent()
27
+
28
+ if (inputType != "hw:keyHandler:escape") {
29
+ this._createChip(shouldReopen)
30
+ }
31
+
32
+ if (this._isSingleSelect && this._selectedOptionElement) {
33
+ this._announceToScreenReader(this._displayForOptionElement(this._selectedOptionElement), "selected")
34
+ }
14
35
  }
15
36
  }
16
37
 
17
38
  toggle() {
18
39
  if (this.expandedValue) {
19
- this.close()
40
+ this._closeAndBlur("hw:toggle")
20
41
  } else {
21
- this._openByFocusing()
42
+ this.openByFocusing()
22
43
  }
23
44
  }
24
45
 
@@ -29,14 +50,14 @@ Combobox.Toggle = Base => class extends Base {
29
50
  if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
30
51
  if (this._withinElementBounds(event)) return
31
52
 
32
- this.close()
53
+ this._closeAndBlur("hw:clickOutside")
33
54
  }
34
55
 
35
56
  closeOnFocusOutside({ target }) {
36
57
  if (!this._isOpen) return
37
58
  if (this.element.contains(target)) return
38
59
 
39
- this.close()
60
+ this._closeAndBlur("hw:focusOutside")
40
61
  }
41
62
 
42
63
  clearOrToggleOnHandleClick() {
@@ -48,6 +69,11 @@ Combobox.Toggle = Base => class extends Base {
48
69
  }
49
70
  }
50
71
 
72
+ _closeAndBlur(inputType) {
73
+ this.close(inputType)
74
+ this._actingCombobox.blur()
75
+ }
76
+
51
77
  // Some browser extensions like 1Password overlay elements on top of the combobox.
52
78
  // Hovering over these elements emits a click event for some reason.
53
79
  // These events don't contain any telling information, so we use `_withinElementBounds`
@@ -59,18 +85,16 @@ Combobox.Toggle = Base => class extends Base {
59
85
  return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
60
86
  }
61
87
 
62
- _openByFocusing() {
63
- this._actingCombobox.focus()
64
- }
65
-
66
88
  _isDialogDismisser(target) {
67
89
  return target.closest("dialog") && target.role != "combobox"
68
90
  }
69
91
 
70
92
  _expand() {
71
- if (this._preselectOnExpansion) this._preselectOption()
93
+ if (this._isSync) {
94
+ this._preselectSingle()
95
+ }
72
96
 
73
- if (this._autocompletesList && this._smallViewport) {
97
+ if (this._autocompletesList && this._isSmallViewport) {
74
98
  this._openInDialog()
75
99
  } else {
76
100
  this._openInline()
@@ -79,6 +103,8 @@ Combobox.Toggle = Base => class extends Base {
79
103
  this._actingCombobox.setAttribute("aria-expanded", true) // needs to happen after setting acting combobox
80
104
  }
81
105
 
106
+ // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
107
+ // it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
82
108
  _collapse() {
83
109
  this._actingCombobox.setAttribute("aria-expanded", false) // needs to happen before resetting acting combobox
84
110
 
@@ -119,11 +145,14 @@ Combobox.Toggle = Base => class extends Base {
119
145
  enableBodyScroll(this.dialogListboxTarget)
120
146
  }
121
147
 
122
- get _isOpen() {
123
- return this.expandedValue
148
+ _clearInvalidQuery() {
149
+ if (this._isUnjustifiablyBlank) {
150
+ this._deselect()
151
+ this._clearQuery()
152
+ }
124
153
  }
125
154
 
126
- get _preselectOnExpansion() {
127
- return !this._isAsync // async comboboxes preselect based on callbacks
155
+ get _isOpen() {
156
+ return this.expandedValue
128
157
  }
129
158
  }
@@ -34,7 +34,7 @@ Combobox.Validity = Base => class extends Base {
34
34
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
35
35
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
36
36
  get _valueIsInvalid() {
37
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value
37
+ const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue
38
38
  return isRequiredAndEmpty
39
39
  }
40
40
  }
@@ -1,11 +1,14 @@
1
1
  import Combobox from "hw_combobox/models/combobox/base"
2
2
 
3
3
  import "hw_combobox/models/combobox/actors"
4
+ import "hw_combobox/models/combobox/announcements"
4
5
  import "hw_combobox/models/combobox/async_loading"
5
6
  import "hw_combobox/models/combobox/autocomplete"
6
7
  import "hw_combobox/models/combobox/dialog"
7
8
  import "hw_combobox/models/combobox/events"
8
9
  import "hw_combobox/models/combobox/filtering"
10
+ import "hw_combobox/models/combobox/form_field"
11
+ import "hw_combobox/models/combobox/multiselect"
9
12
  import "hw_combobox/models/combobox/navigation"
10
13
  import "hw_combobox/models/combobox/new_options"
11
14
  import "hw_combobox/models/combobox/options"
@@ -1,6 +1,7 @@
1
1
  :root {
2
2
  --hw-active-bg-color: #F3F4F6;
3
3
  --hw-border-color: #D1D5DB;
4
+ --hw-group-color: #57595C;
4
5
  --hw-invalid-color: #EF4444;
5
6
  --hw-dialog-label-color: #1D1D1D;
6
7
  --hw-focus-color: #2563EB;
@@ -10,6 +11,9 @@
10
11
  --hw-border-width--slim: 1px;
11
12
  --hw-border-width--thick: 2px;
12
13
 
14
+ --hw-combobox-width: 10rem;
15
+ --hw-combobox-width--multiple: 30rem;
16
+
13
17
  --hw-dialog-font-size: 1.25rem;
14
18
  --hw-dialog-input-height: 2.5rem;
15
19
  --hw-dialog-label-alignment: center;
@@ -24,20 +28,21 @@
24
28
  --hw-handle-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
25
29
  --hw-handle-image--queried: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 18 18 6M6 6l12 12'/%3E%3C/svg%3E");
26
30
  --hw-handle-offset-right: 0.375rem;
27
- --hw-handle-width: 1.5em;
28
- --hw-handle-width--queried: 1em;
29
-
30
- --hw-combobox-width: 10rem;
31
+ --hw-handle-width: 1.5rem;
32
+ --hw-handle-width--queried: 1rem;
31
33
 
32
34
  --hw-line-height: 1.5rem;
35
+ --hw-line-height--multiple: 2.125rem;
33
36
 
34
37
  --hw-listbox-height: calc(var(--hw-line-height) * 10);
35
- --hw-listbox-offset-top: calc(var(--hw-line-height) * 1.625);
36
38
  --hw-listbox-z-index: 10;
37
39
 
40
+ --hw-padding--slimmer: 0.25rem;
38
41
  --hw-padding--slim: 0.375rem;
39
42
  --hw-padding--thick: 0.75rem;
40
43
 
44
+ --hw-selection-chip-font-size: 0.875rem;
45
+
41
46
  --hw-visual-viewport-height: 100vh;
42
47
  }
43
48
 
@@ -57,30 +62,35 @@
57
62
  }
58
63
 
59
64
  .hw-combobox__main__wrapper {
65
+ border: var(--hw-border-width--slim) solid var(--hw-border-color);
66
+ border-radius: var(--hw-border-radius);
67
+ padding: var(--hw-padding--slim) calc(var(--hw-handle-width) + var(--hw-padding--slimmer)) var(--hw-padding--slim) var(--hw-padding--thick);
60
68
  position: relative;
61
- width: min-content;
69
+ width: var(--hw-combobox-width);
70
+
71
+ &:focus-within {
72
+ box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
73
+ }
74
+
75
+ &:has(.hw-combobox__input--invalid) {
76
+ box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-invalid-color);
77
+ }
62
78
  }
63
79
 
64
80
  .hw-combobox__input {
65
- border: var(--hw-border-width--slim) solid var(--hw-border-color);
66
- border-radius: var(--hw-border-radius);
81
+ border: 0;
67
82
  font-size: inherit;
68
83
  line-height: var(--hw-line-height);
69
- padding: var(--hw-padding--slim) var(--hw-handle-width) var(--hw-padding--slim) var(--hw-padding--thick);
70
- position: relative;
71
- width: var(--hw-combobox-width);
84
+ min-width: 0;
85
+ padding: 0;
72
86
  text-overflow: ellipsis;
87
+ width: 100%;
73
88
  }
74
89
 
75
90
  .hw-combobox__input:focus-visible {
76
- box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
77
91
  outline: none;
78
92
  }
79
93
 
80
- .hw-combobox__input--invalid {
81
- border: var(--hw-border-width--slim) solid var(--hw-invalid-color);
82
- }
83
-
84
94
  .hw-combobox__handle {
85
95
  height: 100%;
86
96
  position: absolute;
@@ -105,7 +115,6 @@
105
115
  .hw-combobox__input[data-queried] + .hw-combobox__handle::before {
106
116
  background-image: var(--hw-handle-image--queried);
107
117
  background-size: var(--hw-handle-width--queried);
108
- cursor: pointer;
109
118
  }
110
119
 
111
120
  .hw-combobox__listbox {
@@ -121,7 +130,7 @@
121
130
  overflow: auto;
122
131
  padding: 0;
123
132
  position: absolute;
124
- top: var(--hw-listbox-offset-top);
133
+ top: calc(100% + 0.2rem);
125
134
  width: 100%;
126
135
  z-index: var(--hw-listbox-z-index);
127
136
 
@@ -130,6 +139,20 @@
130
139
  }
131
140
  }
132
141
 
142
+ .hw-combobox__group {
143
+ display: none;
144
+ padding: 0;
145
+ }
146
+
147
+ .hw-combobox__group__label {
148
+ color: var(--hw-group-color);
149
+ padding: var(--hw-padding--slim);
150
+ }
151
+
152
+ .hw-combobox__group:has(.hw-combobox__option:not([hidden])) {
153
+ display: block;
154
+ }
155
+
133
156
  .hw-combobox__option {
134
157
  background-color: var(--hw-option-bg-color);
135
158
  padding: var(--hw-padding--slim) var(--hw-padding--thick);
@@ -144,6 +167,7 @@
144
167
  }
145
168
 
146
169
  .hw-combobox__option:hover,
170
+ .hw-combobox__option--navigated,
147
171
  .hw-combobox__option--selected {
148
172
  background-color: var(--hw-active-bg-color);
149
173
  }
@@ -217,3 +241,45 @@
217
241
  padding: var(--hw-padding--thick);
218
242
  }
219
243
  }
244
+
245
+ .hw-combobox__chip {
246
+ align-items: center;
247
+ border: var(--hw-border-width--slim) solid var(--hw-border-color);
248
+ border-radius: var(--hw-border-radius);
249
+ display: flex;
250
+ font-size: var(--hw-selection-chip-font-size);
251
+ line-height: var(--hw-line-height);
252
+ padding: var(--hw-padding--slimmer);
253
+ padding-left: var(--hw-padding--slim);
254
+ }
255
+
256
+ .hw-combobox__chip__remover {
257
+ background-image: var(--hw-handle-image--queried);
258
+ background-size: var(--hw-handle-width--queried);
259
+ background-repeat: no-repeat;
260
+ margin-left: var(--hw-padding--slimmer);
261
+ min-height: var(--hw-handle-width--queried);
262
+ min-width: var(--hw-handle-width--queried);
263
+ }
264
+
265
+ .hw-combobox--multiple {
266
+ .hw-combobox__main__wrapper {
267
+ align-items: center;
268
+ display: flex;
269
+ flex-wrap: wrap;
270
+ gap: var(--hw-padding--slimmer);
271
+ width: var(--hw-combobox-width--multiple);
272
+
273
+ &:has([data-hw-combobox-chip]) .hw-combobox__input::placeholder {
274
+ color: transparent;
275
+ }
276
+ }
277
+
278
+ .hw-combobox__input {
279
+ min-width: calc(var(--hw-combobox-width) / 2);
280
+ flex-grow: 1;
281
+ line-height: var(--hw-line-height--multiple);
282
+ max-width: 100%;
283
+ width: 1rem;
284
+ }
285
+ }
@@ -44,7 +44,15 @@ module HotwireCombobox::Component::Customizable
44
44
 
45
45
  def apply_customizations_to(element, base: {})
46
46
  custom = custom_attrs[element]
47
- coalesce = ->(k, v) { v.is_a?(String) ? view.token_list(v, custom.delete(k)) : v }
47
+
48
+ coalesce = ->(key, value) do
49
+ if value.is_a?(String) || value.is_a?(Array)
50
+ view.token_list(value, custom.delete(key))
51
+ else
52
+ value
53
+ end
54
+ end
55
+
48
56
  default = base.map { |k, v| [ k, coalesce.(k, v) ] }.to_h
49
57
 
50
58
  custom.deep_merge default