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
@@ -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,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
@@ -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,6 +238,8 @@ class HotwireCombobox::Component
199
238
  end
200
239
 
201
240
  def prefilled_display
241
+ return if multiselect?
242
+
202
243
  if async_src && associated_object
203
244
  associated_object.to_combobox_display
204
245
  elsif hidden_field_value
@@ -227,7 +268,22 @@ class HotwireCombobox::Component
227
268
 
228
269
 
229
270
  def main_wrapper_data
230
- { hw_combobox_target: "mainWrapper" }
271
+ {
272
+ action: ("click->hw-combobox#openByFocusing:self" if multiselect?),
273
+ hw_combobox_target: "mainWrapper"
274
+ }
275
+ end
276
+
277
+
278
+ def announcer_aria
279
+ {
280
+ live: :polite,
281
+ atomic: true
282
+ }
283
+ end
284
+
285
+ def announcer_data
286
+ { hw_combobox_target: "announcer" }
231
287
  end
232
288
 
233
289
 
@@ -249,7 +305,9 @@ class HotwireCombobox::Component
249
305
  if form&.object&.defined_enums&.try :[], name
250
306
  form.object.public_send "#{name}_before_type_cast"
251
307
  else
252
- form&.object&.try name
308
+ form&.object&.try(name).then do |value|
309
+ value.respond_to?(:map) ? value.join(",") : value
310
+ end
253
311
  end
254
312
  end
255
313
 
@@ -270,7 +328,8 @@ class HotwireCombobox::Component
270
328
  keydown->hw-combobox#navigate
271
329
  click@window->hw-combobox#closeOnClickOutside
272
330
  focusin@window->hw-combobox#closeOnFocusOutside
273
- turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog".squish,
331
+ turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog
332
+ turbo:before-cache@document->hw-combobox#hideChipsForCache".squish,
274
333
  hw_combobox_target: "combobox",
275
334
  async_id: canonical_id
276
335
  end
@@ -301,6 +360,10 @@ class HotwireCombobox::Component
301
360
  { hw_combobox_target: "listbox" }
302
361
  end
303
362
 
363
+ def listbox_aria
364
+ { multiselectable: multiselect? }
365
+ end
366
+
304
367
 
305
368
  def dialog_data
306
369
  {
@@ -340,6 +403,10 @@ class HotwireCombobox::Component
340
403
  { hw_combobox_target: "dialogListbox" }
341
404
  end
342
405
 
406
+ def dialog_listbox_aria
407
+ { multiselectable: multiselect? }
408
+ end
409
+
343
410
  def dialog_focus_trap_data
344
411
  { hw_combobox_target: "dialogFocusTrap" }
345
412
  end
@@ -0,0 +1,45 @@
1
+ require "securerandom"
2
+
3
+ class HotwireCombobox::Listbox::Group
4
+ def initialize(name, options:)
5
+ @name = name
6
+ @options = options
7
+ end
8
+
9
+ def render_in(view)
10
+ view.tag.ul **group_attrs do
11
+ view.concat view.tag.li(name, **label_attrs)
12
+
13
+ options.map do |option|
14
+ view.concat view.render(option)
15
+ end
16
+ end
17
+ end
18
+
19
+ private
20
+ attr_reader :name, :options
21
+
22
+ def id
23
+ @id ||= SecureRandom.uuid
24
+ end
25
+
26
+ def group_attrs
27
+ {
28
+ class: "hw-combobox__group",
29
+ role: :group,
30
+ aria: group_aria
31
+ }
32
+ end
33
+
34
+ def group_aria
35
+ { labelledby: id }
36
+ end
37
+
38
+ def label_attrs
39
+ {
40
+ id: id,
41
+ class: "hw-combobox__group__label",
42
+ role: :presentation
43
+ }
44
+ end
45
+ end
@@ -0,0 +1,104 @@
1
+ class HotwireCombobox::Listbox::Item
2
+ class << self
3
+ def collection_for(view, options, render_in:, include_blank:, **custom_methods)
4
+ new(view, options, render_in: render_in, include_blank: include_blank, **custom_methods).items
5
+ end
6
+ end
7
+
8
+ def initialize(view, options, render_in:, include_blank:, **custom_methods)
9
+ @view = view
10
+ @options = options
11
+ @render_in = render_in
12
+ @include_blank = include_blank
13
+ @custom_methods = custom_methods
14
+ end
15
+
16
+ def items
17
+ items = groups_or_options
18
+ items.unshift(blank_option) if include_blank.present?
19
+ items
20
+ end
21
+
22
+ private
23
+ attr_reader :view, :options, :render_in, :include_blank, :custom_methods
24
+
25
+ def groups_or_options
26
+ if grouped?
27
+ create_listbox_group options
28
+ else
29
+ create_listbox_options options
30
+ end
31
+ end
32
+
33
+ def grouped?
34
+ key, value = options.to_a.first
35
+ value.is_a? Array
36
+ end
37
+
38
+ def create_listbox_group(options)
39
+ options.map do |group_name, group_options|
40
+ HotwireCombobox::Listbox::Group.new group_name,
41
+ options: create_listbox_options(group_options)
42
+ end
43
+ end
44
+
45
+ def create_listbox_options(options)
46
+ options.map do |option|
47
+ HotwireCombobox::Listbox::Option.new **option_attrs(option)
48
+ end
49
+ end
50
+
51
+ def option_attrs(option)
52
+ case option
53
+ when Hash
54
+ option.tap do |attrs|
55
+ attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
56
+ end
57
+ when String
58
+ {}.tap do |attrs|
59
+ attrs[:display] = option
60
+ attrs[:value] = option
61
+ attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
62
+ end
63
+ when Array
64
+ {}.tap do |attrs|
65
+ attrs[:display] = option.first
66
+ attrs[:value] = option.last
67
+ attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
68
+ end
69
+ else
70
+ {}.tap do |attrs|
71
+ attrs[:id] = view.hw_call_method_or_proc(option, custom_methods[:id]) if custom_methods[:id]
72
+ attrs[:display] = view.hw_call_method_or_proc(option, custom_methods[:display]) if custom_methods[:display]
73
+ attrs[:value] = view.hw_call_method_or_proc(option, custom_methods[:value] || :id)
74
+
75
+ if render_in.present?
76
+ attrs[:content] = render_content(object: option, attrs: attrs)
77
+ elsif custom_methods[:content]
78
+ attrs[:content] = view.hw_call_method_or_proc(option, custom_methods[:content])
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ def render_content(render_opts: render_in, object:, attrs:)
85
+ view.render **render_opts.reverse_merge(
86
+ object: object,
87
+ locals: { combobox_display: attrs[:display], combobox_value: attrs[:value] })
88
+ end
89
+
90
+ def blank_option
91
+ display, content = extract_blank_display_and_content
92
+ HotwireCombobox::Listbox::Option.new display: display, content: content, value: "", blank: true
93
+ end
94
+
95
+ def extract_blank_display_and_content
96
+ if include_blank.is_a? Hash
97
+ text = include_blank.delete(:text)
98
+
99
+ [ text, render_content(render_opts: include_blank, object: text, attrs: { display: text, value: "" }) ]
100
+ else
101
+ [ include_blank, include_blank ]
102
+ end
103
+ end
104
+ end