hotwire_combobox 0.1.43 → 0.2.0

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