hotwire_combobox 0.1.10 → 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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/README.md +1 -4
  4. data/app/assets/javascripts/controllers/hw_combobox_controller.js +55 -211
  5. data/app/assets/javascripts/helpers.js +52 -0
  6. data/app/assets/javascripts/models/combobox/actors.js +24 -0
  7. data/app/assets/javascripts/models/combobox/async_loading.js +7 -0
  8. data/app/assets/javascripts/models/combobox/autocomplete.js +39 -0
  9. data/app/assets/javascripts/models/combobox/base.js +3 -0
  10. data/app/assets/javascripts/models/combobox/dialog.js +50 -0
  11. data/app/assets/javascripts/models/combobox/filtering.js +57 -0
  12. data/app/assets/javascripts/models/combobox/navigation.js +39 -0
  13. data/app/assets/javascripts/models/combobox/options.js +41 -0
  14. data/app/assets/javascripts/models/combobox/selection.js +62 -0
  15. data/app/assets/javascripts/models/combobox/toggle.js +104 -0
  16. data/app/assets/javascripts/models/combobox/validity.js +34 -0
  17. data/app/assets/javascripts/models/combobox.js +14 -0
  18. data/app/assets/javascripts/vendor/bodyScrollLock.js +299 -0
  19. data/app/assets/stylesheets/hotwire_combobox.css +181 -0
  20. data/app/helpers/hotwire_combobox/helper.rb +62 -73
  21. data/app/presenters/hotwire_combobox/component.rb +257 -0
  22. data/app/presenters/hotwire_combobox/listbox/option.rb +53 -0
  23. data/app/views/hotwire_combobox/_combobox.html.erb +5 -20
  24. data/app/views/hotwire_combobox/_next_page.turbo_stream.erb +5 -0
  25. data/app/views/hotwire_combobox/_paginated_options.turbo_stream.erb +7 -0
  26. data/app/views/hotwire_combobox/_pagination.html.erb +3 -0
  27. data/app/views/hotwire_combobox/combobox/_dialog.html.erb +7 -0
  28. data/app/views/hotwire_combobox/combobox/_hidden_field.html.erb +4 -0
  29. data/app/views/hotwire_combobox/combobox/_input.html.erb +2 -0
  30. data/app/views/hotwire_combobox/combobox/_paginated_listbox.html.erb +11 -0
  31. data/lib/hotwire_combobox/engine.rb +12 -2
  32. data/lib/hotwire_combobox/version.rb +1 -1
  33. data/lib/hotwire_combobox.rb +4 -2
  34. metadata +54 -2
@@ -2,104 +2,93 @@ module HotwireCombobox
2
2
  module Helper
3
3
  class << self
4
4
  delegate :bypass_convenience_methods?, to: :HotwireCombobox
5
- end
6
-
7
- def hw_combobox_options(options)
8
- options.map { |option| hw_combobox_option(**option) }
9
- end
10
- alias_method :combobox_options, :hw_combobox_options unless bypass_convenience_methods?
11
5
 
12
- def hw_combobox_tag(name, value = nil, form: nil, options: [], data: {}, input: {}, **attrs)
13
- value_field_attrs = {}.tap do |h|
14
- h[:id] = default_hw_combobox_value_field_id attrs, form, name
15
- h[:name] = default_hw_combobox_value_field_name form, name
16
- h[:data] = default_hw_combobox_value_field_data
17
- h[:value] = form&.object&.public_send(name) || value
6
+ def hw(method_name)
7
+ unless bypass_convenience_methods?
8
+ alias_method method_name.to_s.sub(/^hw_/, ""), method_name
9
+ end
18
10
  end
19
-
20
- attrs[:type] ||= :text
21
- attrs[:role] = :combobox
22
- attrs[:id] = hw_combobox_id value_field_attrs[:id]
23
- attrs[:data] = default_hw_combobox_data input.fetch(:data, {})
24
- attrs[:aria] = default_hw_combobox_aria value_field_attrs, input.fetch(:aria, {})
25
-
26
- render "hotwire_combobox/combobox", options: options,
27
- attrs: attrs, value_field_attrs: value_field_attrs,
28
- listbox_id: hw_combobox_listbox_id(value_field_attrs[:id]),
29
- parent_data: default_hw_combobox_parent_data(attrs, data)
30
11
  end
31
- alias_method :combobox_tag, :hw_combobox_tag unless bypass_convenience_methods?
32
12
 
33
- def hw_listbox_option_id(option)
34
- option.try(:id)
13
+ hw def hw_combobox_style_tag(*args, **kwargs)
14
+ stylesheet_link_tag HotwireCombobox.stylesheet_path, *args, **kwargs
35
15
  end
36
16
 
37
- def hw_listbox_option_value(option)
38
- option.try(:value) || option.id
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
39
23
  end
40
24
 
41
- def hw_listbox_option_content(option)
42
- option.try(:content) || option.try(:display)
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)) }
31
+ else
32
+ methods[:content]
33
+ end
34
+
35
+ hw_parse_combobox_options options, **methods.merge(display: display, content: content)
36
+ end
43
37
  end
44
38
 
45
- def hw_listbox_option_filterable_as(option)
46
- option.try(:filterable_as) || option.try(:display)
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 ]
47
44
  end
48
45
 
49
- def hw_listbox_option_autocompletable_as(option)
50
- option.try(:autocompletable_as) || option.try(:display)
46
+ hw def hw_listbox_options_id(id)
47
+ "#{id}-hw-listbox__options"
51
48
  end
52
49
 
53
50
  private
54
- def hw_combobox_option(...)
55
- HotwireCombobox::Option.new(...)
56
- end
57
-
58
- def default_hw_combobox_value_field_id(attrs, form, name)
59
- attrs.delete(:id) || form&.field_id(name)
60
- end
61
-
62
- def default_hw_combobox_value_field_name(form, name)
63
- form&.field_name(name) || name
64
- end
65
-
66
- def default_hw_combobox_value_field_data
67
- { "hw-combobox-target": "valueField" }
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
68
58
  end
69
59
 
70
- def default_hw_combobox_data(data)
71
- data.reverse_merge! \
72
- "action": "
73
- focus->hw-combobox#open
74
- input->hw-combobox#filter
75
- keydown->hw-combobox#navigate
76
- click@window->hw-combobox#closeOnClickOutside
77
- focusin@window->hw-combobox#closeOnFocusOutside".squish,
78
- "hw-combobox-target": "combobox"
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
79
65
  end
80
66
 
81
- def default_hw_combobox_aria(attrs, aria)
82
- aria.reverse_merge! \
83
- "controls": hw_combobox_listbox_id(attrs[:id]),
84
- "owns": hw_combobox_listbox_id(attrs[:id]),
85
- "haspopup": "listbox",
86
- "autocomplete": "both"
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
87
74
  end
88
75
 
89
- def default_hw_combobox_parent_data(attrs, data)
90
- data.reverse_merge! \
91
- "controller": token_list("hw-combobox", data.delete(:controller)),
92
- "hw-combobox-expanded-value": attrs.delete(:open),
93
- "hw-combobox-filterable-attribute-value": "data-filterable-as",
94
- "hw-combobox-autocompletable-attribute-value": "data-autocompletable-as"
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
95
82
  end
96
83
 
97
- def hw_combobox_id(id)
98
- "#{id}-hw-combobox"
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
99
88
  end
100
89
 
101
- def hw_combobox_listbox_id(id)
102
- "#{id}-hw-listbox"
90
+ def hw_combobox_page_stream_action
91
+ params[:page] ? :append : :update
103
92
  end
104
93
  end
105
94
  end
@@ -0,0 +1,257 @@
1
+ class HotwireCombobox::Component
2
+ attr_reader :async_src, :options, :dialog_label
3
+
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
16
+
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
21
+ end
22
+
23
+ def fieldset_attrs
24
+ {
25
+ class: "hw-combobox",
26
+ data: fieldset_data
27
+ }
28
+ end
29
+
30
+
31
+ def hidden_field_attrs
32
+ {
33
+ id: hidden_field_id,
34
+ name: hidden_field_name,
35
+ data: hidden_field_data,
36
+ value: hidden_field_value
37
+ }
38
+ end
39
+
40
+
41
+ def input_attrs
42
+ nested_attrs = %i[ data aria ]
43
+
44
+ {
45
+ id: input_id,
46
+ role: :combobox,
47
+ class: "hw-combobox__input",
48
+ type: input_type,
49
+ data: input_data,
50
+ aria: input_aria
51
+ }.merge combobox_attrs.except(*nested_attrs)
52
+ end
53
+
54
+
55
+ def handle_attrs
56
+ {
57
+ class: "hw-combobox__handle",
58
+ data: handle_data
59
+ }
60
+ end
61
+
62
+
63
+ def listbox_attrs
64
+ {
65
+ id: listbox_id,
66
+ role: :listbox,
67
+ class: "hw-combobox__listbox",
68
+ hidden: "",
69
+ data: listbox_data
70
+ }
71
+ end
72
+
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 }
129
+ end
130
+
131
+ private
132
+ attr_reader :view, :autocomplete, :id, :name, :value, :form,
133
+ :name_when_new, :open, :data, :combobox_attrs, :small_width
134
+
135
+ def fieldset_data
136
+ data.reverse_merge \
137
+ controller: view.token_list("hw-combobox", data[:controller]),
138
+ hw_combobox_expanded_value: open,
139
+ hw_combobox_name_when_new_value: name_when_new,
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,
144
+ hw_combobox_filterable_attribute_value: "data-filterable-as",
145
+ hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
146
+ hw_combobox_selected_class: "hw-combobox__option--selected"
147
+ end
148
+
149
+
150
+ def hidden_field_id
151
+ id || form&.field_id(name)
152
+ end
153
+
154
+ def hidden_field_name
155
+ form&.field_name(name) || name
156
+ end
157
+
158
+ def hidden_field_data
159
+ { hw_combobox_target: "hiddenField" }
160
+ end
161
+
162
+ def hidden_field_value
163
+ form&.object&.public_send(name) || value
164
+ end
165
+
166
+
167
+ def input_id
168
+ "#{hidden_field_id}-hw-combobox"
169
+ end
170
+
171
+ def input_type
172
+ combobox_attrs[:type].to_s.presence_in(%w[ text search ]) || "text"
173
+ end
174
+
175
+ def input_data
176
+ combobox_attrs.fetch(:data, {}).reverse_merge! \
177
+ action: "
178
+ focus->hw-combobox#open
179
+ input->hw-combobox#filter
180
+ keydown->hw-combobox#navigate
181
+ click@window->hw-combobox#closeOnClickOutside
182
+ focusin@window->hw-combobox#closeOnFocusOutside".squish,
183
+ hw_combobox_target: "combobox"
184
+ end
185
+
186
+ def input_aria
187
+ combobox_attrs.fetch(:aria, {}).reverse_merge! \
188
+ controls: listbox_id,
189
+ owns: listbox_id,
190
+ haspopup: "listbox",
191
+ autocomplete: autocomplete
192
+ end
193
+
194
+
195
+ def handle_data
196
+ {
197
+ action: "click->hw-combobox#toggle",
198
+ hw_combobox_target: "handle"
199
+ }
200
+ end
201
+
202
+
203
+ def listbox_id
204
+ "#{hidden_field_id}-hw-listbox"
205
+ end
206
+
207
+ def listbox_data
208
+ { hw_combobox_target: "listbox" }
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
257
+ end
@@ -0,0 +1,53 @@
1
+ class HotwireCombobox::Listbox::Option
2
+ def initialize(option)
3
+ @option = option.is_a?(Hash) ? Data.new(**option) : option
4
+ end
5
+
6
+ def render_in(view_context)
7
+ view_context.tag.li content, **options
8
+ end
9
+
10
+ private
11
+ Data = Struct.new :id, :value, :display, :content, :filterable_as, :autocompletable_as, keyword_init: true
12
+
13
+ attr_reader :option
14
+
15
+ def content
16
+ option.try(:content) || option.try(:display)
17
+ end
18
+
19
+ def options
20
+ {
21
+ id: id,
22
+ role: :option,
23
+ class: "hw-combobox__option",
24
+ tabindex: 0,
25
+ data: data
26
+ }
27
+ end
28
+
29
+ def id
30
+ option.try(:id)
31
+ end
32
+
33
+ def data
34
+ {
35
+ action: "click->hw-combobox#selectOption",
36
+ filterable_as: filterable_as,
37
+ autocompletable_as: autocompletable_as,
38
+ value: value
39
+ }
40
+ end
41
+
42
+ def filterable_as
43
+ option.try(:filterable_as) || option.try(:display)
44
+ end
45
+
46
+ def autocompletable_as
47
+ option.try(:autocompletable_as) || option.try(:display)
48
+ end
49
+
50
+ def value
51
+ option.try(:value) || option.id
52
+ end
53
+ end
@@ -1,21 +1,6 @@
1
- <%= tag.fieldset class: "hw-combobox", data: parent_data do %>
2
- <%= hidden_field_tag value_field_attrs.delete(:name),
3
- value_field_attrs.delete(:value), **value_field_attrs %>
4
-
5
- <%= tag.input **attrs %>
6
-
7
- <%= tag.ul id: listbox_id, hidden: "", role: :listbox,
8
- data: { "hw-combobox-target": "listbox" } do |ul| %>
9
- <% options.each do |option| %>
10
- <%= tag.li hw_listbox_option_content(option),
11
- id: hw_listbox_option_id(option),
12
- role: :option,
13
- style: "cursor: pointer;",
14
- data: {
15
- "action": "click->hw-combobox#selectOption",
16
- "filterable-as": hw_listbox_option_filterable_as(option),
17
- "autocompletable-as": hw_listbox_option_autocompletable_as(option),
18
- "value": hw_listbox_option_value(option) } %>
19
- <% end %>
20
- <% end %>
1
+ <%= tag.fieldset **component.fieldset_attrs do %>
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 %>
21
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 %>
@@ -3,8 +3,18 @@ module HotwireCombobox
3
3
  isolate_namespace HotwireCombobox
4
4
 
5
5
  initializer "hotwire_combobox.view_helpers" do
6
- ActiveSupport.on_load :action_controller do
7
- helper HotwireCombobox::Helper
6
+ ActiveSupport.on_load :action_view do
7
+ include HotwireCombobox::Helper
8
+
9
+ unless HotwireCombobox.bypass_convenience_methods?
10
+ module FormBuilderExtensions
11
+ def combobox(*args, **kwargs, &block)
12
+ @template.hw_combobox_tag *args, **kwargs.merge(form: self), &block
13
+ end
14
+ end
15
+
16
+ ActionView::Helpers::FormBuilder.include FormBuilderExtensions
17
+ end
8
18
  end
9
19
  end
10
20
 
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.10"
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