hotwire_combobox 0.1.36 → 0.1.38

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.
@@ -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