hotwire_combobox 0.1.42 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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