hotwire_combobox 0.1.36 → 0.1.38

Sign up to get free protection for your applications and to get access to all the features.
@@ -6,6 +6,10 @@ Combobox.Actors = Base => class extends Base {
6
6
  this._actingCombobox = this.comboboxTarget
7
7
  }
8
8
 
9
+ _forAllComboboxes(callback) {
10
+ this._allComboboxes.forEach(callback)
11
+ }
12
+
9
13
  get _actingListbox() {
10
14
  return this.actingListbox
11
15
  }
@@ -21,4 +25,8 @@ Combobox.Actors = Base => class extends Base {
21
25
  set _actingCombobox(combobox) {
22
26
  this.actingCombobox = combobox
23
27
  }
28
+
29
+ get _allComboboxes() {
30
+ return [ this.comboboxTarget, this.dialogComboboxTarget ]
31
+ }
24
32
  }
@@ -21,7 +21,12 @@ Combobox.Filtering = Base => class extends Base {
21
21
  }
22
22
 
23
23
  async _filterAsync(event) {
24
- const query = { q: this._fullQuery, input_type: event.inputType }
24
+ const query = {
25
+ q: this._fullQuery,
26
+ input_type: event.inputType,
27
+ for_id: this.element.dataset.asyncId
28
+ }
29
+
25
30
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query })
26
31
  }
27
32
 
@@ -41,12 +41,14 @@ Combobox.Selection = Base => class extends Base {
41
41
  }
42
42
 
43
43
  option.setAttribute("aria-selected", selected)
44
+ this._setActiveDescendant(selected ? option.id : "")
44
45
  }
45
46
 
46
47
  _deselect() {
47
48
  const option = this._selectedOptionElement
48
49
  if (option) this._commitSelection(option, { selected: false })
49
50
  this.hiddenFieldTarget.value = null
51
+ this._setActiveDescendant("")
50
52
  }
51
53
 
52
54
  _selectNew() {
@@ -77,6 +79,10 @@ Combobox.Selection = Base => class extends Base {
77
79
  }
78
80
  }
79
81
 
82
+ _setActiveDescendant(id) {
83
+ this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id))
84
+ }
85
+
80
86
  get _hasValueButNoSelection() {
81
87
  return this.hiddenFieldTarget.value && !this._selectedOptionElement
82
88
  }
@@ -121,6 +121,10 @@
121
121
  text-overflow: ellipsis;
122
122
  }
123
123
 
124
+ .hw-combobox__option--blank {
125
+ border-bottom: var(--hw-border-width--slim) solid var(--hw-border-color);
126
+ }
127
+
124
128
  .hw-combobox__option:hover,
125
129
  .hw-combobox__option--selected {
126
130
  background-color: var(--hw-active-bg-color);
@@ -1,5 +1,7 @@
1
+ require "securerandom"
2
+
1
3
  class HotwireCombobox::Component
2
- attr_reader :async_src, :options, :dialog_label
4
+ attr_reader :options, :dialog_label
3
5
 
4
6
  def initialize \
5
7
  view, name,
@@ -53,8 +55,9 @@ class HotwireCombobox::Component
53
55
  class: "hw-combobox__input",
54
56
  type: input_type,
55
57
  data: input_data,
56
- aria: input_aria
57
- }.merge combobox_attrs.except(*nested_attrs)
58
+ aria: input_aria,
59
+ autocomplete: :off
60
+ }.with_indifferent_access.merge combobox_attrs.except(*nested_attrs)
58
61
  end
59
62
 
60
63
 
@@ -77,11 +80,6 @@ class HotwireCombobox::Component
77
80
  end
78
81
 
79
82
 
80
- def listbox_options_attrs
81
- { id: listbox_options_id }
82
- end
83
-
84
-
85
83
  def dialog_wrapper_attrs
86
84
  {
87
85
  class: "hw-combobox__dialog__wrapper"
@@ -185,9 +183,13 @@ class HotwireCombobox::Component
185
183
  form&.object&.class&.reflect_on_association(association_name).present?
186
184
  end
187
185
 
186
+ def async_src
187
+ view.hw_uri_with_params @async_src, for_id: canonical_id, format: :turbo_stream
188
+ end
189
+
188
190
 
189
191
  def canonical_id
190
- id || form&.field_id(name)
192
+ id || form&.field_id(name) || SecureRandom.uuid
191
193
  end
192
194
 
193
195
 
@@ -239,7 +241,8 @@ class HotwireCombobox::Component
239
241
  controls: listbox_id,
240
242
  owns: listbox_id,
241
243
  haspopup: "listbox",
242
- autocomplete: autocomplete
244
+ autocomplete: autocomplete,
245
+ activedescendant: ""
243
246
  end
244
247
 
245
248
 
@@ -260,11 +263,6 @@ class HotwireCombobox::Component
260
263
  end
261
264
 
262
265
 
263
- def listbox_options_id
264
- "#{listbox_id}__options"
265
- end
266
-
267
-
268
266
  def dialog_data
269
267
  {
270
268
  action: "keydown->hw-combobox#navigate",
@@ -290,7 +288,8 @@ class HotwireCombobox::Component
290
288
  {
291
289
  controls: dialog_listbox_id,
292
290
  owns: dialog_listbox_id,
293
- autocomplete: autocomplete
291
+ autocomplete: autocomplete,
292
+ activedescendant: ""
294
293
  }
295
294
  end
296
295
 
@@ -1,3 +1,5 @@
1
+ require "securerandom"
2
+
1
3
  class HotwireCombobox::Listbox::Option
2
4
  def initialize(option)
3
5
  @option = option.is_a?(Hash) ? Data.new(**option) : option
@@ -16,7 +18,7 @@ class HotwireCombobox::Listbox::Option
16
18
  end
17
19
 
18
20
  private
19
- Data = Struct.new :id, :value, :display, :content, :filterable_as, :autocompletable_as, keyword_init: true
21
+ Data = Struct.new :id, :value, :display, :content, :blank, :filterable_as, :autocompletable_as, keyword_init: true
20
22
 
21
23
  attr_reader :option
22
24
 
@@ -24,13 +26,13 @@ class HotwireCombobox::Listbox::Option
24
26
  {
25
27
  id: id,
26
28
  role: :option,
27
- class: "hw-combobox__option",
29
+ class: [ "hw-combobox__option", { "hw-combobox__option--blank": blank? } ],
28
30
  data: data
29
31
  }
30
32
  end
31
33
 
32
34
  def id
33
- option.try(:id)
35
+ option.try(:id) || SecureRandom.uuid
34
36
  end
35
37
 
36
38
  def data
@@ -49,4 +51,8 @@ class HotwireCombobox::Listbox::Option
49
51
  def filterable_as
50
52
  option.try(:filterable_as) || option.try(:display)
51
53
  end
54
+
55
+ def blank?
56
+ option.try(:blank).present?
57
+ end
52
58
  end
@@ -1,5 +1,6 @@
1
1
  <%# locals: (for_id:, next_page:, src:) -%>
2
2
 
3
- <%= turbo_stream.replace hw_pagination_frame_id(for_id) do %>
4
- <%= render "hotwire_combobox/pagination", for_id: for_id, src: hw_combobox_next_page_uri(src, next_page) %>
3
+ <%= turbo_stream.remove hw_pagination_frame_wrapper_id(for_id) %>
4
+ <%= turbo_stream.append hw_listbox_id(for_id) do %>
5
+ <%= render "hotwire_combobox/pagination", for_id: for_id, src: hw_combobox_next_page_uri(src, next_page, for_id) %>
5
6
  <% end %>
@@ -1,6 +1,6 @@
1
1
  <%# locals: (for_id:, options:) -%>
2
2
 
3
- <%= turbo_stream.public_send(hw_combobox_page_stream_action, hw_listbox_options_id(for_id)) do %>
3
+ <%= turbo_stream.public_send(hw_combobox_page_stream_action, hw_listbox_id(for_id)) do %>
4
4
  <% options.each do |option| %>
5
5
  <%= render option %>
6
6
  <% end %>
@@ -1,4 +1,7 @@
1
1
  <%# locals: (for_id:, src:) -%>
2
2
 
3
- <%= turbo_frame_tag hw_pagination_frame_id(for_id), src: src, loading: :lazy,
4
- data: { hw_combobox_target: "endOfOptionsStream", input_type: params[:input_type] } %>
3
+ <%= tag.li id: hw_pagination_frame_wrapper_id(for_id), data: {
4
+ hw_combobox_target: "endOfOptionsStream", input_type: params[:input_type] },
5
+ aria: { hidden: true } do %>
6
+ <%= turbo_frame_tag hw_pagination_frame_id(for_id), src: src, loading: :lazy %>
7
+ <% end %>
@@ -1,8 +1,6 @@
1
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 %>
2
+ <% component.options.each do |option| %>
3
+ <%= render option %>
6
4
  <% end %>
7
5
 
8
6
  <% if component.paginated? %>
@@ -10,7 +10,7 @@ module HotwireCombobox
10
10
  unless HotwireCombobox.bypass_convenience_methods?
11
11
  module FormBuilderExtensions
12
12
  def combobox(*args, **kwargs)
13
- @template.hw_combobox_tag *args, **kwargs.merge(form: self)
13
+ @template.hw_combobox_tag(*args, **kwargs.merge(form: self))
14
14
  end
15
15
  end
16
16
 
@@ -15,27 +15,32 @@ module HotwireCombobox
15
15
  end
16
16
  hw_alias :hw_combobox_style_tag
17
17
 
18
- def hw_combobox_tag(name, options_or_src = [], render_in: {}, **kwargs)
19
- options, src = hw_extract_options_and_src(options_or_src, render_in)
18
+ def hw_combobox_tag(name, options_or_src = [], render_in: {}, include_blank: nil, **kwargs)
19
+ options, src = hw_extract_options_and_src(options_or_src, render_in, include_blank)
20
20
  component = HotwireCombobox::Component.new self, name, options: options, async_src: src, **kwargs
21
21
 
22
22
  render "hotwire_combobox/combobox", component: component
23
23
  end
24
24
  hw_alias :hw_combobox_tag
25
25
 
26
- def hw_combobox_options(options, render_in: {}, display: :to_combobox_display, **methods)
26
+ def hw_combobox_options(options, render_in: {}, include_blank: nil, display: :to_combobox_display, **methods)
27
27
  if options.first.is_a? HotwireCombobox::Listbox::Option
28
28
  options
29
29
  else
30
- render_in_proc = ->(object) { render(**render_in.merge(object: object)) } if render_in.present?
31
- hw_parse_combobox_options options, render_in: render_in_proc, **methods.merge(display: display)
30
+ render_in_proc = hw_render_in_proc(render_in) if render_in.present?
31
+
32
+ hw_parse_combobox_options(options, render_in: render_in_proc, **methods.merge(display: display)).tap do |options|
33
+ options.unshift(hw_blank_option(include_blank)) if include_blank.present?
34
+ end
32
35
  end
33
36
  end
34
37
  hw_alias :hw_combobox_options
35
38
 
36
- def hw_paginated_combobox_options(options, for_id:, src: request.path, next_page: nil, render_in: {}, **methods)
37
- this_page = render("hotwire_combobox/paginated_options", for_id: for_id, options: hw_combobox_options(options, render_in: render_in, **methods))
38
- next_page = render("hotwire_combobox/next_page", for_id: for_id, src: src, next_page: next_page)
39
+ def hw_paginated_combobox_options(options, for_id: params[:for_id], src: request.path, next_page: nil, render_in: {}, include_blank: {}, **methods)
40
+ include_blank = params[:page] ? nil : include_blank
41
+ options = hw_combobox_options options, render_in: render_in, include_blank: include_blank, **methods
42
+ this_page = render "hotwire_combobox/paginated_options", for_id: for_id, options: options
43
+ next_page = render "hotwire_combobox/next_page", for_id: for_id, src: src, next_page: next_page
39
44
 
40
45
  safe_join [ this_page, next_page ]
41
46
  end
@@ -44,18 +49,26 @@ module HotwireCombobox
44
49
  alias_method :hw_async_combobox_options, :hw_paginated_combobox_options
45
50
  hw_alias :hw_async_combobox_options
46
51
 
47
- protected # library use only
48
- def hw_listbox_options_id(id)
49
- "#{id}-hw-listbox__options"
52
+ # private library use only
53
+ def hw_listbox_id(id)
54
+ "#{id}-hw-listbox"
55
+ end
56
+
57
+ def hw_pagination_frame_wrapper_id(id)
58
+ "#{id}__hw_combobox_pagination__wrapper"
50
59
  end
51
60
 
52
61
  def hw_pagination_frame_id(id)
53
62
  "#{id}__hw_combobox_pagination"
54
63
  end
55
64
 
56
- def hw_combobox_next_page_uri(uri, next_page)
65
+ def hw_combobox_next_page_uri(uri, next_page, for_id)
57
66
  if next_page
58
- hw_uri_with_params uri, page: next_page, q: params[:q], format: :turbo_stream
67
+ hw_uri_with_params uri,
68
+ page: next_page,
69
+ q: params[:q],
70
+ for_id: for_id,
71
+ format: :turbo_stream
59
72
  end
60
73
  end
61
74
 
@@ -63,12 +76,19 @@ module HotwireCombobox
63
76
  params[:page] ? :append : :update
64
77
  end
65
78
 
66
- private
67
- def hw_extract_options_and_src(options_or_src, render_in)
68
- if options_or_src.is_a? String
69
- [ [], hw_uri_with_params(options_or_src, format: :turbo_stream) ]
79
+ def hw_blank_option(include_blank)
80
+ display, content = hw_extract_blank_display_and_content include_blank
81
+
82
+ HotwireCombobox::Listbox::Option.new display: display, content: content, value: "", blank: true
83
+ end
84
+
85
+ def hw_extract_blank_display_and_content(include_blank)
86
+ if include_blank.is_a? Hash
87
+ text = include_blank.delete(:text)
88
+
89
+ [ text, hw_render_in_proc(include_blank).(text) ]
70
90
  else
71
- [ hw_combobox_options(options_or_src, render_in: render_in), nil ]
91
+ [ include_blank, include_blank ]
72
92
  end
73
93
  end
74
94
 
@@ -81,9 +101,23 @@ module HotwireCombobox
81
101
  url_or_path
82
102
  end
83
103
 
104
+ private
105
+ def hw_render_in_proc(render_in)
106
+ ->(object) { render(**render_in.reverse_merge(object: object)) }
107
+ end
108
+
109
+ def hw_extract_options_and_src(options_or_src, render_in, include_blank)
110
+ if options_or_src.is_a? String
111
+ [ [], options_or_src ]
112
+ else
113
+ [ hw_combobox_options(options_or_src, render_in: render_in, include_blank: include_blank), nil ]
114
+ end
115
+ end
116
+
84
117
  def hw_parse_combobox_options(options, render_in: nil, **methods)
85
118
  options.map do |option|
86
- HotwireCombobox::Listbox::Option.new **hw_option_attrs_for(option, render_in: render_in, **methods)
119
+ HotwireCombobox::Listbox::Option.new \
120
+ **hw_option_attrs_for(option, render_in: render_in, **methods)
87
121
  end
88
122
  end
89
123
 
@@ -118,8 +152,43 @@ module HotwireCombobox
118
152
  if method_or_proc.is_a? Proc
119
153
  method_or_proc.call object
120
154
  else
121
- object.public_send method_or_proc
155
+ hw_call_method object, method_or_proc
156
+ end
157
+ end
158
+
159
+ def hw_call_method(object, method)
160
+ if object.respond_to? method
161
+ object.public_send method
162
+ else
163
+ hw_raise_no_public_method_error object, method
164
+ end
165
+ end
166
+
167
+ def hw_raise_no_public_method_error(object, method)
168
+ if object.respond_to? method, true
169
+ header = "`#{object.class}` responds to `##{method}` but the method is not public."
170
+ else
171
+ header = "`#{object.class}` does not respond to `##{method}`."
172
+ end
173
+
174
+ if method.to_s == "to_combobox_display"
175
+ header << "\n\nThis method is used to determine how this option should appear in the combobox options list."
122
176
  end
177
+
178
+ raise NoMethodError, <<~MSG
179
+ [ACTION NEEDED] – Message from HotwireCombobox:
180
+
181
+ #{header}
182
+
183
+ Please add this as a public method and return a string.
184
+
185
+ Example:
186
+ class #{object.class} < ApplicationRecord
187
+ def #{method}
188
+ name # or `title`, `to_s`, etc.
189
+ end
190
+ end
191
+ MSG
123
192
  end
124
193
  end
125
194
  end
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.36"
2
+ VERSION = "0.1.38"
3
3
  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.36
4
+ version: 0.1.38
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Farias
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-02 00:00:00.000000000 Z
11
+ date: 2024-03-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -65,6 +65,8 @@ files:
65
65
  - Rakefile
66
66
  - app/assets/config/hw_combobox_manifest.js
67
67
  - app/assets/javascripts/controllers/hw_combobox_controller.js
68
+ - app/assets/javascripts/hotwire_combobox.esm.js
69
+ - app/assets/javascripts/hotwire_combobox.umd.js
68
70
  - app/assets/javascripts/hw_combobox/helpers.js
69
71
  - app/assets/javascripts/hw_combobox/models/combobox.js
70
72
  - app/assets/javascripts/hw_combobox/models/combobox/actors.js