hotwire_combobox 0.1.43 → 0.2.1

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/app/assets/javascripts/controllers/hw_combobox_controller.js +29 -3
  4. data/app/assets/javascripts/hotwire_combobox.esm.js +442 -104
  5. data/app/assets/javascripts/hotwire_combobox.umd.js +442 -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 +90 -19
  20. data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
  21. data/app/presenters/hotwire_combobox/component.rb +106 -32
  22. data/app/presenters/hotwire_combobox/listbox/group.rb +47 -0
  23. data/app/presenters/hotwire_combobox/listbox/item/collection.rb +14 -0
  24. data/app/presenters/hotwire_combobox/listbox/item.rb +111 -0
  25. data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
  26. data/app/views/hotwire_combobox/_component.html.erb +1 -0
  27. data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
  28. data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
  29. data/lib/hotwire_combobox/helper.rb +112 -91
  30. data/lib/hotwire_combobox/version.rb +1 -1
  31. metadata +11 -3
@@ -6,22 +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()
12
22
  this._clearInvalidQuery()
13
23
 
14
24
  this.expandedValue = false
15
25
 
16
- this._dispatchClosedEvent()
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
+ }
17
35
  }
18
36
  }
19
37
 
20
38
  toggle() {
21
39
  if (this.expandedValue) {
22
- this.close()
40
+ this._closeAndBlur("hw:toggle")
23
41
  } else {
24
- this._openByFocusing()
42
+ this.openByFocusing()
25
43
  }
26
44
  }
27
45
 
@@ -32,14 +50,14 @@ Combobox.Toggle = Base => class extends Base {
32
50
  if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
33
51
  if (this._withinElementBounds(event)) return
34
52
 
35
- this.close()
53
+ this._closeAndBlur("hw:clickOutside")
36
54
  }
37
55
 
38
56
  closeOnFocusOutside({ target }) {
39
57
  if (!this._isOpen) return
40
58
  if (this.element.contains(target)) return
41
59
 
42
- this.close()
60
+ this._closeAndBlur("hw:focusOutside")
43
61
  }
44
62
 
45
63
  clearOrToggleOnHandleClick() {
@@ -51,6 +69,11 @@ Combobox.Toggle = Base => class extends Base {
51
69
  }
52
70
  }
53
71
 
72
+ _closeAndBlur(inputType) {
73
+ this.close(inputType)
74
+ this._actingCombobox.blur()
75
+ }
76
+
54
77
  // Some browser extensions like 1Password overlay elements on top of the combobox.
55
78
  // Hovering over these elements emits a click event for some reason.
56
79
  // These events don't contain any telling information, so we use `_withinElementBounds`
@@ -62,18 +85,16 @@ Combobox.Toggle = Base => class extends Base {
62
85
  return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
63
86
  }
64
87
 
65
- _openByFocusing() {
66
- this._actingCombobox.focus()
67
- }
68
-
69
88
  _isDialogDismisser(target) {
70
89
  return target.closest("dialog") && target.role != "combobox"
71
90
  }
72
91
 
73
92
  _expand() {
74
- if (this._preselectOnExpansion) this._preselectOption()
93
+ if (this._isSync) {
94
+ this._preselectSingle()
95
+ }
75
96
 
76
- if (this._autocompletesList && this._smallViewport) {
97
+ if (this._autocompletesList && this._isSmallViewport) {
77
98
  this._openInDialog()
78
99
  } else {
79
100
  this._openInline()
@@ -134,8 +155,4 @@ Combobox.Toggle = Base => class extends Base {
134
155
  get _isOpen() {
135
156
  return this.expandedValue
136
157
  }
137
-
138
- get _preselectOnExpansion() {
139
- return !this._isAsync // async comboboxes preselect based on callbacks
140
- }
141
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._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,8 @@
1
1
  :root {
2
2
  --hw-active-bg-color: #F3F4F6;
3
3
  --hw-border-color: #D1D5DB;
4
+ --hw-group-color: #57595C;
5
+ --hw-group-bg-color: #FFFFFF;
4
6
  --hw-invalid-color: #EF4444;
5
7
  --hw-dialog-label-color: #1D1D1D;
6
8
  --hw-focus-color: #2563EB;
@@ -10,6 +12,9 @@
10
12
  --hw-border-width--slim: 1px;
11
13
  --hw-border-width--thick: 2px;
12
14
 
15
+ --hw-combobox-width: 10rem;
16
+ --hw-combobox-width--multiple: 30rem;
17
+
13
18
  --hw-dialog-font-size: 1.25rem;
14
19
  --hw-dialog-input-height: 2.5rem;
15
20
  --hw-dialog-label-alignment: center;
@@ -24,20 +29,21 @@
24
29
  --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
30
  --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
31
  --hw-handle-offset-right: 0.375rem;
27
- --hw-handle-width: 1.5em;
28
- --hw-handle-width--queried: 1em;
29
-
30
- --hw-combobox-width: 10rem;
32
+ --hw-handle-width: 1.5rem;
33
+ --hw-handle-width--queried: 1rem;
31
34
 
32
35
  --hw-line-height: 1.5rem;
36
+ --hw-line-height--multiple: 2.125rem;
33
37
 
34
38
  --hw-listbox-height: calc(var(--hw-line-height) * 10);
35
- --hw-listbox-offset-top: calc(var(--hw-line-height) * 1.625);
36
39
  --hw-listbox-z-index: 10;
37
40
 
41
+ --hw-padding--slimmer: 0.25rem;
38
42
  --hw-padding--slim: 0.375rem;
39
43
  --hw-padding--thick: 0.75rem;
40
44
 
45
+ --hw-selection-chip-font-size: 0.875rem;
46
+
41
47
  --hw-visual-viewport-height: 100vh;
42
48
  }
43
49
 
@@ -57,30 +63,38 @@
57
63
  }
58
64
 
59
65
  .hw-combobox__main__wrapper {
66
+ border: var(--hw-border-width--slim) solid var(--hw-border-color);
67
+ border-radius: var(--hw-border-radius);
68
+ padding: var(--hw-padding--slim) calc(var(--hw-handle-width) + var(--hw-padding--slimmer)) var(--hw-padding--slim) var(--hw-padding--thick);
60
69
  position: relative;
61
- width: min-content;
70
+ width: var(--hw-combobox-width);
71
+
72
+ &:focus-within {
73
+ box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
74
+ }
75
+
76
+ &:has(.hw-combobox__input--invalid) {
77
+ box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-invalid-color);
78
+ }
62
79
  }
63
80
 
64
81
  .hw-combobox__input {
65
- border: var(--hw-border-width--slim) solid var(--hw-border-color);
66
- border-radius: var(--hw-border-radius);
82
+ border: none;
67
83
  font-size: inherit;
68
84
  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);
85
+ min-width: 0;
86
+ padding: 0;
72
87
  text-overflow: ellipsis;
88
+ width: 100%;
73
89
  }
74
90
 
75
- .hw-combobox__input:focus-visible {
76
- box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
91
+ .hw-combobox__input:focus,
92
+ .hw-combobox__input:focus-visible,
93
+ .hw-combobox__input:focus-within {
94
+ box-shadow: none;
77
95
  outline: none;
78
96
  }
79
97
 
80
- .hw-combobox__input--invalid {
81
- border: var(--hw-border-width--slim) solid var(--hw-invalid-color);
82
- }
83
-
84
98
  .hw-combobox__handle {
85
99
  height: 100%;
86
100
  position: absolute;
@@ -105,7 +119,6 @@
105
119
  .hw-combobox__input[data-queried] + .hw-combobox__handle::before {
106
120
  background-image: var(--hw-handle-image--queried);
107
121
  background-size: var(--hw-handle-width--queried);
108
- cursor: pointer;
109
122
  }
110
123
 
111
124
  .hw-combobox__listbox {
@@ -121,7 +134,7 @@
121
134
  overflow: auto;
122
135
  padding: 0;
123
136
  position: absolute;
124
- top: var(--hw-listbox-offset-top);
137
+ top: calc(100% + 0.2rem);
125
138
  width: 100%;
126
139
  z-index: var(--hw-listbox-z-index);
127
140
 
@@ -130,6 +143,21 @@
130
143
  }
131
144
  }
132
145
 
146
+ .hw-combobox__group {
147
+ display: none;
148
+ padding: 0;
149
+ }
150
+
151
+ .hw-combobox__group__label {
152
+ background-color: var(--hw-group-bg-color);
153
+ color: var(--hw-group-color);
154
+ padding: var(--hw-padding--slim);
155
+ }
156
+
157
+ .hw-combobox__group:has(.hw-combobox__option:not([hidden])) {
158
+ display: block;
159
+ }
160
+
133
161
  .hw-combobox__option {
134
162
  background-color: var(--hw-option-bg-color);
135
163
  padding: var(--hw-padding--slim) var(--hw-padding--thick);
@@ -144,6 +172,7 @@
144
172
  }
145
173
 
146
174
  .hw-combobox__option:hover,
175
+ .hw-combobox__option--navigated,
147
176
  .hw-combobox__option--selected {
148
177
  background-color: var(--hw-active-bg-color);
149
178
  }
@@ -217,3 +246,45 @@
217
246
  padding: var(--hw-padding--thick);
218
247
  }
219
248
  }
249
+
250
+ .hw-combobox__chip {
251
+ align-items: center;
252
+ border: var(--hw-border-width--slim) solid var(--hw-border-color);
253
+ border-radius: var(--hw-border-radius);
254
+ display: flex;
255
+ font-size: var(--hw-selection-chip-font-size);
256
+ line-height: var(--hw-line-height);
257
+ padding: var(--hw-padding--slimmer);
258
+ padding-left: var(--hw-padding--slim);
259
+ }
260
+
261
+ .hw-combobox__chip__remover {
262
+ background-image: var(--hw-handle-image--queried);
263
+ background-size: var(--hw-handle-width--queried);
264
+ background-repeat: no-repeat;
265
+ margin-left: var(--hw-padding--slimmer);
266
+ min-height: var(--hw-handle-width--queried);
267
+ min-width: var(--hw-handle-width--queried);
268
+ }
269
+
270
+ .hw-combobox--multiple {
271
+ .hw-combobox__main__wrapper {
272
+ align-items: center;
273
+ display: flex;
274
+ flex-wrap: wrap;
275
+ gap: var(--hw-padding--slimmer);
276
+ width: var(--hw-combobox-width--multiple);
277
+
278
+ &:has([data-hw-combobox-chip]) .hw-combobox__input::placeholder {
279
+ color: transparent;
280
+ }
281
+ }
282
+
283
+ .hw-combobox__input {
284
+ min-width: calc(var(--hw-combobox-width) / 2);
285
+ flex-grow: 1;
286
+ line-height: var(--hw-line-height--multiple);
287
+ max-width: 100%;
288
+ width: 1rem;
289
+ }
290
+ }
@@ -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
@@ -2,33 +2,39 @@ require "securerandom"
2
2
 
3
3
  class HotwireCombobox::Component
4
4
  include Customizable
5
+ include ActiveModel::Validations
5
6
 
6
7
  attr_reader :options, :label
7
8
 
9
+ validate :name_when_new_on_multiselect_must_match_original_name
10
+
8
11
  def initialize \
9
- view, name,
10
- association_name: nil,
11
- async_src: nil,
12
- autocomplete: :both,
13
- data: {},
14
- dialog_label: nil,
15
- form: nil,
16
- id: nil,
17
- input: {},
18
- label: nil,
19
- mobile_at: "640px",
20
- name_when_new: nil,
21
- open: false,
22
- options: [],
23
- value: nil,
24
- **rest
12
+ view, name,
13
+ association_name: nil,
14
+ async_src: nil,
15
+ autocomplete: :both,
16
+ data: {},
17
+ dialog_label: nil,
18
+ form: nil,
19
+ id: nil,
20
+ input: {},
21
+ label: nil,
22
+ mobile_at: "640px",
23
+ multiselect_chip_src: nil,
24
+ name_when_new: nil,
25
+ open: false,
26
+ options: [],
27
+ value: nil,
28
+ **rest
25
29
  @view, @autocomplete, @id, @name, @value, @form, @async_src, @label,
26
- @name_when_new, @open, @data, @mobile_at, @options, @dialog_label =
30
+ @name_when_new, @open, @data, @mobile_at, @multiselect_chip_src, @options, @dialog_label =
27
31
  view, autocomplete, id, name.to_s, value, form, async_src, label,
28
- name_when_new, open, data, mobile_at, options, dialog_label
32
+ name_when_new, open, data, mobile_at, multiselect_chip_src, options, dialog_label
29
33
 
30
34
  @combobox_attrs = input.reverse_merge(rest).deep_symbolize_keys
31
35
  @association_name = association_name || infer_association_name
36
+
37
+ validate!
32
38
  end
33
39
 
34
40
  def render_in(view_context, &block)
@@ -39,7 +45,7 @@ class HotwireCombobox::Component
39
45
 
40
46
  def fieldset_attrs
41
47
  apply_customizations_to :fieldset, base: {
42
- class: "hw-combobox",
48
+ class: [ "hw-combobox", { "hw-combobox--multiple": multiselect? } ],
43
49
  data: fieldset_data
44
50
  }
45
51
  end
@@ -72,6 +78,23 @@ class HotwireCombobox::Component
72
78
  end
73
79
 
74
80
 
81
+ def announcer_attrs
82
+ {
83
+ style: "
84
+ position: absolute;
85
+ width: 1px;
86
+ height: 1px;
87
+ margin: -1px;
88
+ padding: 0;
89
+ overflow: hidden;
90
+ clip: rect(0, 0, 0, 0);
91
+ border: 0;".squish,
92
+ aria: announcer_aria,
93
+ data: announcer_data
94
+ }
95
+ end
96
+
97
+
75
98
  def input_attrs
76
99
  nested_attrs = %i[ data aria ]
77
100
 
@@ -103,7 +126,8 @@ class HotwireCombobox::Component
103
126
  role: :listbox,
104
127
  class: "hw-combobox__listbox",
105
128
  hidden: "",
106
- data: listbox_data
129
+ data: listbox_data,
130
+ aria: listbox_aria
107
131
  }
108
132
  end
109
133
 
@@ -150,7 +174,8 @@ class HotwireCombobox::Component
150
174
  id: dialog_listbox_id,
151
175
  class: "hw-combobox__dialog__listbox",
152
176
  role: :listbox,
153
- data: dialog_listbox_data
177
+ data: dialog_listbox_data,
178
+ aria: dialog_listbox_aria
154
179
  }
155
180
  end
156
181
 
@@ -173,10 +198,23 @@ class HotwireCombobox::Component
173
198
  private
174
199
  attr_reader :view, :autocomplete, :id, :name, :value, :form,
175
200
  :name_when_new, :open, :data, :combobox_attrs, :mobile_at,
176
- :association_name
201
+ :association_name, :multiselect_chip_src
202
+
203
+ def name_when_new_on_multiselect_must_match_original_name
204
+ return unless multiselect? && name_when_new.present?
205
+
206
+ unless name_when_new.to_s == name
207
+ errors.add :name_when_new, :must_match_original_name,
208
+ message: "must match the regular name ('#{name}', in this case) on multiselect comboboxes."
209
+ end
210
+ end
211
+
212
+ def multiselect?
213
+ multiselect_chip_src.present?
214
+ end
177
215
 
178
216
  def infer_association_name
179
- if name.include?("_id")
217
+ if name.end_with?("_id")
180
218
  name.sub(/_id\z/, "")
181
219
  end
182
220
  end
@@ -192,6 +230,7 @@ class HotwireCombobox::Component
192
230
  hw_combobox_small_viewport_max_width_value: mobile_at,
193
231
  hw_combobox_async_src_value: async_src,
194
232
  hw_combobox_prefilled_display_value: prefilled_display,
233
+ hw_combobox_selection_chip_src_value: multiselect_chip_src,
195
234
  hw_combobox_filterable_attribute_value: "data-filterable-as",
196
235
  hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
197
236
  hw_combobox_selected_class: "hw-combobox__option--selected",
@@ -199,21 +238,29 @@ class HotwireCombobox::Component
199
238
  end
200
239
 
201
240
  def prefilled_display
241
+ return if multiselect? || !hidden_field_value
242
+
202
243
  if async_src && associated_object
203
244
  associated_object.to_combobox_display
204
- elsif hidden_field_value
205
- options.find { |option| option.value == hidden_field_value }&.autocompletable_as
245
+ elsif async_src && form_object&.respond_to?(name)
246
+ form_object.public_send name
247
+ else
248
+ options.find_by_value(hidden_field_value)&.autocompletable_as
206
249
  end
207
250
  end
208
251
 
209
252
  def associated_object
210
253
  @associated_object ||= if association_exists?
211
- form.object.public_send association_name
254
+ form_object&.public_send association_name
212
255
  end
213
256
  end
214
257
 
215
258
  def association_exists?
216
- form&.object&.class&.reflect_on_association(association_name).present?
259
+ form_object&.class&.reflect_on_association(association_name).present?
260
+ end
261
+
262
+ def form_object
263
+ form&.object
217
264
  end
218
265
 
219
266
  def async_src
@@ -227,7 +274,22 @@ class HotwireCombobox::Component
227
274
 
228
275
 
229
276
  def main_wrapper_data
230
- { hw_combobox_target: "mainWrapper" }
277
+ {
278
+ action: ("click->hw-combobox#openByFocusing:self" if multiselect?),
279
+ hw_combobox_target: "mainWrapper"
280
+ }
281
+ end
282
+
283
+
284
+ def announcer_aria
285
+ {
286
+ live: :polite,
287
+ atomic: true
288
+ }
289
+ end
290
+
291
+ def announcer_data
292
+ { hw_combobox_target: "announcer" }
231
293
  end
232
294
 
233
295
 
@@ -246,10 +308,12 @@ class HotwireCombobox::Component
246
308
  def hidden_field_value
247
309
  return value if value
248
310
 
249
- if form&.object&.defined_enums&.try :[], name
250
- form.object.public_send "#{name}_before_type_cast"
311
+ if form_object&.defined_enums&.try :[], name
312
+ form_object.public_send "#{name}_before_type_cast"
251
313
  else
252
- form&.object&.try name
314
+ form_object&.try(name).then do |value|
315
+ value.respond_to?(:map) ? value.join(",") : value
316
+ end
253
317
  end
254
318
  end
255
319
 
@@ -270,7 +334,9 @@ class HotwireCombobox::Component
270
334
  keydown->hw-combobox#navigate
271
335
  click@window->hw-combobox#closeOnClickOutside
272
336
  focusin@window->hw-combobox#closeOnFocusOutside
273
- turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog".squish,
337
+ turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog
338
+ turbo:before-cache@document->hw-combobox#hideChipsForCache
339
+ turbo:morph-element->hw-combobox#idempotentConnect".squish,
274
340
  hw_combobox_target: "combobox",
275
341
  async_id: canonical_id
276
342
  end
@@ -301,6 +367,10 @@ class HotwireCombobox::Component
301
367
  { hw_combobox_target: "listbox" }
302
368
  end
303
369
 
370
+ def listbox_aria
371
+ { multiselectable: multiselect? }
372
+ end
373
+
304
374
 
305
375
  def dialog_data
306
376
  {
@@ -340,6 +410,10 @@ class HotwireCombobox::Component
340
410
  { hw_combobox_target: "dialogListbox" }
341
411
  end
342
412
 
413
+ def dialog_listbox_aria
414
+ { multiselectable: multiselect? }
415
+ end
416
+
343
417
  def dialog_focus_trap_data
344
418
  { hw_combobox_target: "dialogFocusTrap" }
345
419
  end
@@ -0,0 +1,47 @@
1
+ require "securerandom"
2
+
3
+ class HotwireCombobox::Listbox::Group
4
+ attr_reader :options
5
+
6
+ def initialize(name, options:)
7
+ @name = name
8
+ @options = options
9
+ end
10
+
11
+ def render_in(view)
12
+ view.tag.ul **group_attrs do
13
+ view.concat view.tag.li(name, **label_attrs)
14
+
15
+ options.map do |option|
16
+ view.concat view.render(option)
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+ attr_reader :name
23
+
24
+ def id
25
+ @id ||= SecureRandom.uuid
26
+ end
27
+
28
+ def group_attrs
29
+ {
30
+ class: "hw-combobox__group",
31
+ role: :group,
32
+ aria: group_aria
33
+ }
34
+ end
35
+
36
+ def group_aria
37
+ { labelledby: id }
38
+ end
39
+
40
+ def label_attrs
41
+ {
42
+ id: id,
43
+ class: "hw-combobox__group__label",
44
+ role: :presentation
45
+ }
46
+ end
47
+ end
@@ -0,0 +1,14 @@
1
+ class HotwireCombobox::Listbox::Item::Collection < Array
2
+ def find_by_value(value)
3
+ if grouped?
4
+ flat_map { |item| item.options }.find { |option| option.value == value }
5
+ else
6
+ find { |option| option.value == value }
7
+ end
8
+ end
9
+
10
+ private
11
+ def grouped?
12
+ first.is_a? HotwireCombobox::Listbox::Group
13
+ end
14
+ end