hotwire_combobox 0.1.43 → 0.2.1

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