hotwire_combobox 0.1.11 → 0.1.12

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