hotwire_combobox 0.1.10 → 0.1.12

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