hotwire_combobox 0.1.11 → 0.1.12

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/app/assets/javascripts/controllers/hw_combobox_controller.js +49 -242
  4. data/app/assets/javascripts/helpers.js +52 -0
  5. data/app/assets/javascripts/models/combobox/actors.js +24 -0
  6. data/app/assets/javascripts/models/combobox/async_loading.js +7 -0
  7. data/app/assets/javascripts/models/combobox/autocomplete.js +39 -0
  8. data/app/assets/javascripts/models/combobox/base.js +3 -0
  9. data/app/assets/javascripts/models/combobox/dialog.js +50 -0
  10. data/app/assets/javascripts/models/combobox/filtering.js +57 -0
  11. data/app/assets/javascripts/models/combobox/navigation.js +39 -0
  12. data/app/assets/javascripts/models/combobox/options.js +41 -0
  13. data/app/assets/javascripts/models/combobox/selection.js +62 -0
  14. data/app/assets/javascripts/models/combobox/toggle.js +104 -0
  15. data/app/assets/javascripts/models/combobox/validity.js +34 -0
  16. data/app/assets/javascripts/models/combobox.js +14 -0
  17. data/app/assets/javascripts/vendor/bodyScrollLock.js +299 -0
  18. data/app/assets/stylesheets/hotwire_combobox.css +181 -0
  19. data/app/helpers/hotwire_combobox/helper.rb +76 -26
  20. data/app/presenters/hotwire_combobox/component.rb +150 -20
  21. data/app/presenters/hotwire_combobox/listbox/option.rb +5 -2
  22. data/app/views/hotwire_combobox/_combobox.html.erb +4 -13
  23. data/app/views/hotwire_combobox/_next_page.turbo_stream.erb +5 -0
  24. data/app/views/hotwire_combobox/_paginated_options.turbo_stream.erb +7 -0
  25. data/app/views/hotwire_combobox/_pagination.html.erb +3 -0
  26. data/app/views/hotwire_combobox/combobox/_dialog.html.erb +7 -0
  27. data/app/views/hotwire_combobox/combobox/_hidden_field.html.erb +4 -0
  28. data/app/views/hotwire_combobox/combobox/_input.html.erb +2 -0
  29. data/app/views/hotwire_combobox/combobox/_paginated_listbox.html.erb +11 -0
  30. data/lib/hotwire_combobox/version.rb +1 -1
  31. data/lib/hotwire_combobox.rb +4 -2
  32. metadata +52 -2
@@ -1,44 +1,94 @@
1
1
  module HotwireCombobox
2
2
  module Helper
3
- NonCollectionOptions = Class.new StandardError
4
-
5
3
  class << self
6
4
  delegate :bypass_convenience_methods?, to: :HotwireCombobox
7
5
 
8
- def hw_combobox_options(options, **methods)
9
- unless options.respond_to?(:map)
10
- raise NonCollectionOptions, "options must be an Array or an ActiveRecord::Relation"
6
+ def hw(method_name)
7
+ unless bypass_convenience_methods?
8
+ alias_method method_name.to_s.sub(/^hw_/, ""), method_name
11
9
  end
10
+ end
11
+ end
12
12
 
13
- if ActiveRecord::Relation === options || ActiveRecord::Base === options.first
14
- options.map do |option|
15
- attrs = {}.tap do |attrs|
16
- attrs[:id] = option.public_send(methods[:id]) if methods[:id]
17
- attrs[:value] = option.public_send(methods[:value] || :id)
18
- attrs[:display] = option.public_send(methods[:display]) if methods[:display]
19
- end
13
+ hw def hw_combobox_style_tag(*args, **kwargs)
14
+ stylesheet_link_tag HotwireCombobox.stylesheet_path, *args, **kwargs
15
+ end
16
+
17
+ hw def hw_combobox_tag(*args, async_src: nil, options: [], render_in: {}, **kwargs)
18
+ options = hw_combobox_options options, render_in: render_in
19
+ src = hw_uri_with_params async_src, format: :turbo_stream
20
+ component = HotwireCombobox::Component.new self, *args, options: options, async_src: src, **kwargs
21
+
22
+ render "hotwire_combobox/combobox", component: component
23
+ end
20
24
 
21
- hw_combobox_option(**attrs)
22
- end
25
+ hw def hw_combobox_options(options, render_in: {}, display: :to_combobox_display, **methods)
26
+ if options.first.is_a? HotwireCombobox::Listbox::Option
27
+ options
28
+ else
29
+ content = if render_in.present?
30
+ ->(object) { render(**render_in.merge(object: object)) }
23
31
  else
24
- options.map { |option| hw_combobox_option(**option) }
32
+ methods[:content]
25
33
  end
26
- end
27
34
 
28
- private
29
- def hw_combobox_option(*args, **kwargs, &block)
30
- HotwireCombobox::Option.new(*args, **kwargs, &block)
31
- end
35
+ hw_parse_combobox_options options, **methods.merge(display: display, content: content)
36
+ end
32
37
  end
33
38
 
34
- def hw_combobox_tag(*args, **kwargs, &block)
35
- render "hotwire_combobox/combobox", component: HotwireCombobox::Component.new(*args, **kwargs, &block)
39
+ hw def hw_paginated_combobox_options(options, for_id:, src:, next_page:, render_in: {}, **methods)
40
+ this_page = render("hotwire_combobox/paginated_options", for_id: for_id, options: hw_combobox_options(options, render_in: render_in, **methods), format: :turbo_stream)
41
+ next_page = render("hotwire_combobox/next_page", src: src, next_page: next_page, format: :turbo_stream)
42
+
43
+ safe_join [ this_page, next_page ]
36
44
  end
37
- alias_method :combobox_tag, :hw_combobox_tag unless bypass_convenience_methods?
38
45
 
39
- def hw_combobox_options(*args, **kwargs, &block)
40
- HotwireCombobox::Helper.hw_combobox_options(*args, **kwargs, &block)
46
+ hw def hw_listbox_options_id(id)
47
+ "#{id}-hw-listbox__options"
41
48
  end
42
- alias_method :combobox_options, :hw_combobox_options unless bypass_convenience_methods?
49
+
50
+ private
51
+ def hw_uri_with_params(url_or_path, **params)
52
+ URI.parse(url_or_path).tap do |url_or_path|
53
+ query = URI.decode_www_form(url_or_path.query || "").to_h.merge(params)
54
+ url_or_path.query = URI.encode_www_form query
55
+ end.to_s
56
+ rescue URI::InvalidURIError
57
+ url_or_path
58
+ end
59
+
60
+ def hw_parse_combobox_options(options, **methods)
61
+ options.map do |option|
62
+ attrs = option.is_a?(Hash) ? option : hw_option_attrs_for_obj(option, **methods)
63
+ HotwireCombobox::Listbox::Option.new **attrs
64
+ end
65
+ end
66
+
67
+ def hw_option_attrs_for_obj(option, **methods)
68
+ {}.tap do |attrs|
69
+ attrs[:id] = hw_call_method_or_proc(option, methods[:id]) if methods[:id]
70
+ attrs[:value] = hw_call_method_or_proc(option, methods[:value] || :id)
71
+ attrs[:display] = hw_call_method_or_proc(option, methods[:display]) if methods[:display]
72
+ attrs[:content] = hw_call_method_or_proc(option, methods[:content]) if methods[:content]
73
+ end
74
+ end
75
+
76
+ def hw_call_method_or_proc(object, method_or_proc)
77
+ if method_or_proc.is_a? Proc
78
+ method_or_proc.call object
79
+ else
80
+ object.public_send method_or_proc
81
+ end
82
+ end
83
+
84
+ def hw_combobox_next_page_uri(uri, next_page)
85
+ if next_page
86
+ hw_uri_with_params uri, page: next_page, q: params[:q], format: :turbo_stream
87
+ end
88
+ end
89
+
90
+ def hw_combobox_page_stream_action
91
+ params[:page] ? :append : :update
92
+ end
43
93
  end
44
94
  end
@@ -1,20 +1,23 @@
1
1
  class HotwireCombobox::Component
2
- include ActionView::Helpers::TagHelper
2
+ attr_reader :async_src, :options, :dialog_label
3
3
 
4
- class << self
5
- def options_from(options)
6
- if HotwireCombobox::Option === options.first
7
- options
8
- else
9
- HotwireCombobox::Helper.hw_combobox_options options, display: :to_combobox_display
10
- end
11
- end
12
- end
4
+ def initialize(
5
+ view, name, value = nil,
6
+ autocomplete: :both,
7
+ id: nil,
8
+ form: nil,
9
+ name_when_new: nil,
10
+ open: false,
11
+ small_width: "640px",
12
+ async_src: nil,
13
+ dialog_label: nil,
14
+ options: [], data: {}, input: {}, **rest)
15
+ @combobox_attrs = input.reverse_merge(rest).with_indifferent_access
13
16
 
14
- def initialize(name, value = nil, id: nil, form: nil, name_when_new: nil, open: false, options: [], data: {}, input: {}, **rest)
15
- @combobox_attrs = input.reverse_merge(rest).with_indifferent_access # input: {} allows for specifying e.g. data attributes on the input field
16
- @options = self.class.options_from options
17
- @id, @name, @value, @form, @name_when_new, @open, @data = id, name, value, form, name_when_new, open, data
17
+ @view, @autocomplete, @id, @name, @value, @form, @async_src,
18
+ @name_when_new, @open, @data, @small_width, @options, @dialog_label =
19
+ view, autocomplete, id, name, value, form, async_src,
20
+ name_when_new, open, data, small_width, options, dialog_label
18
21
  end
19
22
 
20
23
  def fieldset_attrs
@@ -24,6 +27,7 @@ class HotwireCombobox::Component
24
27
  }
25
28
  end
26
29
 
30
+
27
31
  def hidden_field_attrs
28
32
  {
29
33
  id: hidden_field_id,
@@ -33,42 +37,113 @@ class HotwireCombobox::Component
33
37
  }
34
38
  end
35
39
 
40
+
36
41
  def input_attrs
37
42
  nested_attrs = %i[ data aria ]
38
43
 
39
44
  {
40
45
  id: input_id,
41
46
  role: :combobox,
47
+ class: "hw-combobox__input",
42
48
  type: input_type,
43
49
  data: input_data,
44
50
  aria: input_aria
45
51
  }.merge combobox_attrs.except(*nested_attrs)
46
52
  end
47
53
 
54
+
55
+ def handle_attrs
56
+ {
57
+ class: "hw-combobox__handle",
58
+ data: handle_data
59
+ }
60
+ end
61
+
62
+
48
63
  def listbox_attrs
49
64
  {
50
65
  id: listbox_id,
51
66
  role: :listbox,
67
+ class: "hw-combobox__listbox",
52
68
  hidden: "",
53
69
  data: listbox_data
54
70
  }
55
71
  end
56
72
 
57
- def listbox_options
58
- options.map { |option| HotwireCombobox::Listbox::Option.new option }
73
+
74
+ def listbox_options_attrs
75
+ { id: listbox_options_id }
76
+ end
77
+
78
+
79
+ def dialog_attrs
80
+ {
81
+ class: "hw-combobox__dialog",
82
+ role: :dialog,
83
+ data: dialog_data
84
+ }
85
+ end
86
+
87
+ def dialog_label_attrs
88
+ {
89
+ class: "hw-combobox__dialog__label",
90
+ for: dialog_input_id
91
+ }
92
+ end
93
+
94
+ def dialog_input_attrs
95
+ {
96
+ id: dialog_input_id,
97
+ role: :combobox,
98
+ class: "hw-combobox__dialog__input",
99
+ autofocus: "",
100
+ type: input_type,
101
+ data: dialog_input_data,
102
+ aria: dialog_input_aria
103
+ }
104
+ end
105
+
106
+ def dialog_listbox_attrs
107
+ {
108
+ id: dialog_listbox_id,
109
+ class: "hw-combobox__dialog__listbox",
110
+ role: :listbox,
111
+ data: dialog_listbox_data
112
+ }
113
+ end
114
+
115
+ def dialog_focus_trap_attrs
116
+ {
117
+ tabindex: "-1",
118
+ data: dialog_focus_trap_data
119
+ }
120
+ end
121
+
122
+
123
+ def paginated?
124
+ async_src.present?
125
+ end
126
+
127
+ def pagination_attrs
128
+ { src: async_src }
59
129
  end
60
130
 
61
131
  private
62
- attr_reader :id, :name, :value, :form, :name_when_new, :open, :options, :data, :combobox_attrs
132
+ attr_reader :view, :autocomplete, :id, :name, :value, :form,
133
+ :name_when_new, :open, :data, :combobox_attrs, :small_width
63
134
 
64
135
  def fieldset_data
65
136
  data.reverse_merge \
66
- controller: token_list("hw-combobox", data[:controller]),
137
+ controller: view.token_list("hw-combobox", data[:controller]),
67
138
  hw_combobox_expanded_value: open,
68
139
  hw_combobox_name_when_new_value: name_when_new,
69
140
  hw_combobox_original_name_value: hidden_field_name,
141
+ hw_combobox_autocomplete_value: autocomplete,
142
+ hw_combobox_small_viewport_max_width_value: small_width,
143
+ hw_combobox_async_src_value: async_src,
70
144
  hw_combobox_filterable_attribute_value: "data-filterable-as",
71
- hw_combobox_autocompletable_attribute_value: "data-autocompletable-as"
145
+ hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
146
+ hw_combobox_selected_class: "hw-combobox__option--selected"
72
147
  end
73
148
 
74
149
 
@@ -113,7 +188,15 @@ class HotwireCombobox::Component
113
188
  controls: listbox_id,
114
189
  owns: listbox_id,
115
190
  haspopup: "listbox",
116
- autocomplete: "both"
191
+ autocomplete: autocomplete
192
+ end
193
+
194
+
195
+ def handle_data
196
+ {
197
+ action: "click->hw-combobox#toggle",
198
+ hw_combobox_target: "handle"
199
+ }
117
200
  end
118
201
 
119
202
 
@@ -124,4 +207,51 @@ class HotwireCombobox::Component
124
207
  def listbox_data
125
208
  { hw_combobox_target: "listbox" }
126
209
  end
210
+
211
+
212
+ def listbox_options_id
213
+ "#{listbox_id}__options"
214
+ end
215
+
216
+
217
+ def dialog_data
218
+ {
219
+ action: "keydown->hw-combobox#navigate",
220
+ hw_combobox_target: "dialog"
221
+ }
222
+ end
223
+
224
+ def dialog_input_id
225
+ "#{hidden_field_id}-hw-dialog-combobox"
226
+ end
227
+
228
+ def dialog_input_data
229
+ {
230
+ action: "
231
+ input->hw-combobox#filter
232
+ keydown->hw-combobox#navigate
233
+ click@window->hw-combobox#closeOnClickOutside".squish,
234
+ hw_combobox_target: "dialogCombobox"
235
+ }
236
+ end
237
+
238
+ def dialog_input_aria
239
+ {
240
+ controls: dialog_listbox_id,
241
+ owns: dialog_listbox_id,
242
+ autocomplete: autocomplete
243
+ }
244
+ end
245
+
246
+ def dialog_listbox_id
247
+ "#{hidden_field_id}-hw-dialog-listbox"
248
+ end
249
+
250
+ def dialog_listbox_data
251
+ { hw_combobox_target: "dialogListbox" }
252
+ end
253
+
254
+ def dialog_focus_trap_data
255
+ { hw_combobox_target: "dialogFocusTrap" }
256
+ end
127
257
  end
@@ -1,6 +1,6 @@
1
1
  class HotwireCombobox::Listbox::Option
2
2
  def initialize(option)
3
- @option = option
3
+ @option = option.is_a?(Hash) ? Data.new(**option) : option
4
4
  end
5
5
 
6
6
  def render_in(view_context)
@@ -8,6 +8,8 @@ class HotwireCombobox::Listbox::Option
8
8
  end
9
9
 
10
10
  private
11
+ Data = Struct.new :id, :value, :display, :content, :filterable_as, :autocompletable_as, keyword_init: true
12
+
11
13
  attr_reader :option
12
14
 
13
15
  def content
@@ -18,7 +20,8 @@ class HotwireCombobox::Listbox::Option
18
20
  {
19
21
  id: id,
20
22
  role: :option,
21
- style: "cursor: pointer;",
23
+ class: "hw-combobox__option",
24
+ tabindex: 0,
22
25
  data: data
23
26
  }
24
27
  end
@@ -1,15 +1,6 @@
1
1
  <%= tag.fieldset **component.fieldset_attrs do %>
2
- <% hidden_field_attrs = component.hidden_field_attrs %>
3
- <%= hidden_field_tag \
4
- hidden_field_attrs.delete(:name),
5
- hidden_field_attrs.delete(:value),
6
- **hidden_field_attrs %>
7
-
8
- <%= tag.input **component.input_attrs %>
9
-
10
- <%= tag.ul **component.listbox_attrs do |ul| %>
11
- <% component.listbox_options.each do |option| %>
12
- <%= render option %>
13
- <% end %>
14
- <% end %>
2
+ <%= render "hotwire_combobox/combobox/hidden_field", component: component %>
3
+ <%= render "hotwire_combobox/combobox/input", component: component %>
4
+ <%= render "hotwire_combobox/combobox/paginated_listbox", component: component %>
5
+ <%= render "hotwire_combobox/combobox/dialog", component: component %>
15
6
  <% end %>
@@ -0,0 +1,5 @@
1
+ <%# locals: (next_page:, src:) -%>
2
+
3
+ <%= turbo_stream.replace :hotwire_combobox_pagination do %>
4
+ <%= render "hotwire_combobox/pagination", src: hw_combobox_next_page_uri(src, next_page) %>
5
+ <% end %>
@@ -0,0 +1,7 @@
1
+ <%# locals: (for_id:, options:) -%>
2
+
3
+ <%= turbo_stream.public_send(hw_combobox_page_stream_action, hw_listbox_options_id(for_id)) do %>
4
+ <% options.each do |option| %>
5
+ <%= render option %>
6
+ <% end %>
7
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <%# locals: (src:) -%>
2
+
3
+ <%= turbo_frame_tag :hotwire_combobox_pagination, src: src, loading: :lazy %>
@@ -0,0 +1,7 @@
1
+ <%= tag.div **component.dialog_focus_trap_attrs %>
2
+
3
+ <%= tag.dialog **component.dialog_attrs do %>
4
+ <%= tag.label component.dialog_label, **component.dialog_label_attrs %>
5
+ <%= tag.input **component.dialog_input_attrs %>
6
+ <%= tag.ul **component.dialog_listbox_attrs %>
7
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <% hidden_field_attrs = component.hidden_field_attrs %>
2
+
3
+ <%= hidden_field_tag hidden_field_attrs.delete(:name),
4
+ hidden_field_attrs.delete(:value), **hidden_field_attrs %>
@@ -0,0 +1,2 @@
1
+ <%= tag.input **component.input_attrs %>
2
+ <%= tag.span **component.handle_attrs %>
@@ -0,0 +1,11 @@
1
+ <%= tag.ul **component.listbox_attrs do |ul| %>
2
+ <%= tag.div **component.listbox_options_attrs do %>
3
+ <% component.options.each do |option| %>
4
+ <%= render option %>
5
+ <% end %>
6
+ <% end %>
7
+
8
+ <% if component.paginated? %>
9
+ <%= render "hotwire_combobox/pagination", **component.pagination_attrs %>
10
+ <% end %>
11
+ <% end %>
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.11"
2
+ VERSION = "0.1.12"
3
3
  end
@@ -2,8 +2,6 @@ require "hotwire_combobox/version"
2
2
  require "hotwire_combobox/engine"
3
3
 
4
4
  module HotwireCombobox
5
- Option = Struct.new(:id, :value, :display, :content, :filterable_as, :autocompletable_as)
6
-
7
5
  mattr_accessor :bypass_convenience_methods
8
6
  @@bypass_convenience_methods = false
9
7
 
@@ -15,5 +13,9 @@ module HotwireCombobox
15
13
  def bypass_convenience_methods?
16
14
  bypass_convenience_methods
17
15
  end
16
+
17
+ def stylesheet_path
18
+ "hotwire_combobox"
19
+ end
18
20
  end
19
21
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_combobox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Farias
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-30 00:00:00.000000000 Z
11
+ date: 2024-01-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -52,6 +52,34 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.2'
55
+ - !ruby/object:Gem::Dependency
56
+ name: turbo-rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '1.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '1.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: requestjs-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 0.0.11
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 0.0.11
55
83
  description: A combobox implementation for Ruby on Rails apps running on Hotwire.
56
84
  email:
57
85
  - jose@farias.mx
@@ -65,11 +93,33 @@ files:
65
93
  - app/assets/javascripts/controllers/application.js
66
94
  - app/assets/javascripts/controllers/hw_combobox_controller.js
67
95
  - app/assets/javascripts/controllers/index.js
96
+ - app/assets/javascripts/helpers.js
68
97
  - app/assets/javascripts/hotwire_combobox_application.js
98
+ - app/assets/javascripts/models/combobox.js
99
+ - app/assets/javascripts/models/combobox/actors.js
100
+ - app/assets/javascripts/models/combobox/async_loading.js
101
+ - app/assets/javascripts/models/combobox/autocomplete.js
102
+ - app/assets/javascripts/models/combobox/base.js
103
+ - app/assets/javascripts/models/combobox/dialog.js
104
+ - app/assets/javascripts/models/combobox/filtering.js
105
+ - app/assets/javascripts/models/combobox/navigation.js
106
+ - app/assets/javascripts/models/combobox/options.js
107
+ - app/assets/javascripts/models/combobox/selection.js
108
+ - app/assets/javascripts/models/combobox/toggle.js
109
+ - app/assets/javascripts/models/combobox/validity.js
110
+ - app/assets/javascripts/vendor/bodyScrollLock.js
111
+ - app/assets/stylesheets/hotwire_combobox.css
69
112
  - app/helpers/hotwire_combobox/helper.rb
70
113
  - app/presenters/hotwire_combobox/component.rb
71
114
  - app/presenters/hotwire_combobox/listbox/option.rb
72
115
  - app/views/hotwire_combobox/_combobox.html.erb
116
+ - app/views/hotwire_combobox/_next_page.turbo_stream.erb
117
+ - app/views/hotwire_combobox/_paginated_options.turbo_stream.erb
118
+ - app/views/hotwire_combobox/_pagination.html.erb
119
+ - app/views/hotwire_combobox/combobox/_dialog.html.erb
120
+ - app/views/hotwire_combobox/combobox/_hidden_field.html.erb
121
+ - app/views/hotwire_combobox/combobox/_input.html.erb
122
+ - app/views/hotwire_combobox/combobox/_paginated_listbox.html.erb
73
123
  - config/importmap.rb
74
124
  - lib/hotwire_combobox.rb
75
125
  - lib/hotwire_combobox/engine.rb