hotwire_combobox 0.1.13 → 0.1.14

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f868770ad8adc907437ea9d7c0f2a9550b848a1ab0ca3dddb3592aacd4ce75a
4
- data.tar.gz: 6d1932f507fd650da412d2bacc1d6dc01faed6b8d3f2230fe9df1251b66baaf9
3
+ metadata.gz: f1edf53e6f495466fb562c9e5a5fa6f4484fcb3f5729d561dc3e59ab622daad5
4
+ data.tar.gz: ade7a12ec97430ec9fb824525725706d0f4628f67a12019b5392c2f2b5ce46a2
5
5
  SHA512:
6
- metadata.gz: 31127e0fa02f231b604b10b25f5ff514b33065814a3283ae4d6c338ab59835ef8696e19496f867bec8ddc564066a1b5af2c6c7fa1a88e88507c1943bb741f6ce
7
- data.tar.gz: 77070143486b82fa1b0d3179e1fb74fe43300988a6a6c2c3654dc883a06d1b4b3a9e069e10d8e71f543ee72ee6a242cba42ef7e595e6ba377a530e73954a1aca
6
+ metadata.gz: 4b8a4ed1d34fa641f546fc59838e052feaa9bd1f89a339b31ef218a5f61354cefb5cc4ced5c5b02d29a1416d125054a7a93087bc8a94b54855cba2fe0f4cb4da
7
+ data.tar.gz: 771ddf2a2f621331bc0199dcb114ef9362fdb090459a80dc1e56589d5c2221dd5eec45516a84acdc279fd349f86494fd800838c5203c6412bfa40f11e36a4724
@@ -30,7 +30,8 @@ export default class HwComboboxController extends Concerns(...concerns) {
30
30
  "dialogListbox",
31
31
  "handle",
32
32
  "hiddenField",
33
- "listbox"
33
+ "listbox",
34
+ "paginationFrame"
34
35
  ]
35
36
 
36
37
  static values = {
@@ -41,6 +42,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
41
42
  filterableAttribute: String,
42
43
  nameWhenNew: String,
43
44
  originalName: String,
45
+ prefilledDisplay: String,
44
46
  smallViewportMaxWidth: String
45
47
  }
46
48
 
@@ -66,4 +68,8 @@ export default class HwComboboxController extends Concerns(...concerns) {
66
68
  this._collapse()
67
69
  }
68
70
  }
71
+
72
+ paginationFrameTargetConnected() {
73
+ this._preselectOption()
74
+ }
69
75
  }
@@ -14,7 +14,7 @@ Combobox.Dialog = Base => class extends Base {
14
14
  }
15
15
 
16
16
  _moveArtifactsToDialog() {
17
- this.dialogComboboxTarget.value = this.actingCombobox.value
17
+ this.dialogComboboxTarget.value = this._actingCombobox.value
18
18
 
19
19
  this._actingCombobox = this.dialogComboboxTarget
20
20
  this._actingListbox = this.dialogListboxTarget
@@ -23,7 +23,7 @@ Combobox.Dialog = Base => class extends Base {
23
23
  }
24
24
 
25
25
  _moveArtifactsInline() {
26
- this.comboboxTarget.value = this.actingCombobox.value
26
+ this.comboboxTarget.value = this._actingCombobox.value
27
27
 
28
28
  this._actingCombobox = this.comboboxTarget
29
29
  this._actingListbox = this.listboxTarget
@@ -32,9 +32,11 @@ Combobox.Dialog = Base => class extends Base {
32
32
  }
33
33
 
34
34
  _resizeDialog = () => {
35
- const fullHeight = window.innerHeight
36
- const viewportHeight = window.visualViewport.height
37
- this.dialogTarget.style.setProperty("--hw-dialog-bottom-padding", `${fullHeight - viewportHeight}px`)
35
+ if (window.visualViewport) {
36
+ const fullHeight = window.innerHeight
37
+ const viewportHeight = window.visualViewport.height
38
+ this.dialogTarget.style.setProperty("--hw-dialog-bottom-padding", `${fullHeight - viewportHeight}px`)
39
+ }
38
40
  }
39
41
 
40
42
  // After closing a dialog, focus returns to the last focused element.
@@ -10,9 +10,9 @@ Combobox.Options = Base => class extends Base {
10
10
  _isValidNewOption(query, { ignoreAutocomplete = false } = {}) {
11
11
  const typedValue = this._actingCombobox.value
12
12
  const autocompletedValue = this._visibleOptionElements[0]?.getAttribute(this.autocompletableAttributeValue)
13
- const insufficentAutocomplete = !autocompletedValue || !startsWith(autocompletedValue, typedValue)
13
+ const insufficientAutocomplete = !autocompletedValue || !startsWith(autocompletedValue, typedValue)
14
14
 
15
- return query.length > 0 && this._allowNew && (ignoreAutocomplete || insufficentAutocomplete)
15
+ return query.length > 0 && this._allowNew && (ignoreAutocomplete || insufficientAutocomplete)
16
16
  }
17
17
 
18
18
  get _allowNew() {
@@ -8,8 +8,8 @@ Combobox.Selection = Base => class extends Base {
8
8
  }
9
9
 
10
10
  _connectSelection() {
11
- if (this.hiddenFieldTarget.value) {
12
- this._selectOptionByValue(this.hiddenFieldTarget.value)
11
+ if (this.hasPrefilledDisplayValue) {
12
+ this._actingCombobox.value = this.prefilledDisplayValue
13
13
  }
14
14
  }
15
15
 
@@ -17,8 +17,6 @@ Combobox.Selection = Base => class extends Base {
17
17
  this._resetOptions()
18
18
 
19
19
  if (option) {
20
- if (this.hasSelectedClass) option.classList.add(this.selectedClass)
21
-
22
20
  this._markValid()
23
21
  this._maybeAutocompleteWith(option, { force })
24
22
  this._commitSelection(option, { selected: true })
@@ -28,21 +26,27 @@ Combobox.Selection = Base => class extends Base {
28
26
  }
29
27
 
30
28
  _commitSelection(option, { selected }) {
31
- option?.setAttribute("aria-selected", selected)
32
- option?.scrollIntoView({ block: "nearest" })
29
+ this._markSelected(option, { selected })
33
30
 
34
31
  if (selected) {
35
- this.hiddenFieldTarget.value = option?.dataset.value
32
+ this.hiddenFieldTarget.value = option.dataset.value
33
+ option.scrollIntoView({ block: "nearest" })
36
34
  } else {
37
35
  this.hiddenFieldTarget.value = null
38
36
  }
39
37
  }
40
38
 
39
+ _markSelected(option, { selected }) {
40
+ if (this.hasSelectedClass) {
41
+ option.classList.toggle(this.selectedClass, selected)
42
+ }
43
+
44
+ option.setAttribute("aria-selected", selected)
45
+ }
46
+
41
47
  _deselect() {
42
48
  const option = this._selectedOptionElement
43
-
44
- if (this.hasSelectedClass) option?.classList.remove(this.selectedClass)
45
- this._commitSelection(option, { selected: false })
49
+ if (option) this._commitSelection(option, { selected: false })
46
50
  }
47
51
 
48
52
  _selectNew(query) {
@@ -56,7 +60,17 @@ Combobox.Selection = Base => class extends Base {
56
60
  this._select(option, { force: true })
57
61
  }
58
62
 
59
- _selectOptionByValue(value) {
60
- this._allOptions.find(option => option.dataset.value === value)?.click()
63
+ _preselectOption() {
64
+ if (this._hasValueButNoSelection && this._allOptions.length < 100) {
65
+ const option = this._allOptions.find(option => {
66
+ return option.dataset.value === this.hiddenFieldTarget.value
67
+ })
68
+
69
+ if (option) this._markSelected(option, { selected: true })
70
+ }
71
+ }
72
+
73
+ get _hasValueButNoSelection() {
74
+ return this.hiddenFieldTarget.value && !this._selectedOptionElement
61
75
  }
62
76
  }
@@ -21,8 +21,11 @@ Combobox.Toggle = Base => class extends Base {
21
21
  }
22
22
  }
23
23
 
24
- closeOnClickOutside({ target }) {
24
+ closeOnClickOutside(event) {
25
+ const target = event.target
26
+
25
27
  if (this.element.contains(target) && !this._isDialogDismisser(target)) return
28
+ if (this._withinElementBounds(event)) return
26
29
 
27
30
  this.close()
28
31
  }
@@ -35,6 +38,17 @@ Combobox.Toggle = Base => class extends Base {
35
38
  this.close()
36
39
  }
37
40
 
41
+ // Some browser extensions like 1Password overlay elements on top of the combobox.
42
+ // Hovering over these elements emits a click event for some reason.
43
+ // These events don't contain any telling information, so we use `_withinElementBounds`
44
+ // as an alternative to check whether the click is legitimate.
45
+ _withinElementBounds(event) {
46
+ const { left, right, top, bottom } = this.element.getBoundingClientRect()
47
+ const { clientX, clientY } = event
48
+
49
+ return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
50
+ }
51
+
38
52
  _ensureSelection() {
39
53
  if (!this._isValidNewOption(this._actingCombobox.value, { ignoreAutocomplete: true })) {
40
54
  this._select(this._selectedOptionElement, { force: true })
@@ -49,7 +63,9 @@ Combobox.Toggle = Base => class extends Base {
49
63
  return target.closest("dialog") && target.role != "combobox"
50
64
  }
51
65
 
52
- async _expand() {
66
+ _expand() {
67
+ if (this._preselectOnExpansion) this._preselectOption()
68
+
53
69
  if (this._autocompletesList && this._smallViewport) {
54
70
  this._openInDialog()
55
71
  } else {
@@ -101,4 +117,8 @@ Combobox.Toggle = Base => class extends Base {
101
117
  get _isOpen() {
102
118
  return this.expandedValue
103
119
  }
120
+
121
+ get _preselectOnExpansion() {
122
+ return !this._isAsync // async comboboxes preselect based on callbacks
123
+ }
104
124
  }
@@ -15,10 +15,9 @@ module HotwireCombobox
15
15
  end
16
16
  hw_alias :hw_combobox_style_tag
17
17
 
18
- def hw_combobox_tag(*args, async_src: nil, options: [], render_in: {}, **kwargs)
19
- options = hw_combobox_options options, render_in: render_in
20
- src = hw_uri_with_params async_src, format: :turbo_stream
21
- component = HotwireCombobox::Component.new self, *args, options: options, async_src: src, **kwargs
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)
20
+ component = HotwireCombobox::Component.new self, name, options: options, async_src: src, **kwargs
22
21
 
23
22
  render "hotwire_combobox/combobox", component: component
24
23
  end
@@ -39,20 +38,32 @@ module HotwireCombobox
39
38
  end
40
39
  hw_alias :hw_combobox_options
41
40
 
42
- def hw_paginated_combobox_options(options, for_id:, src:, next_page:, render_in: {}, **methods)
41
+ def hw_paginated_combobox_options(options, for_id:, src: request.path, next_page: nil, render_in: {}, **methods)
43
42
  this_page = render("hotwire_combobox/paginated_options", for_id: for_id, options: hw_combobox_options(options, render_in: render_in, **methods), format: :turbo_stream)
44
- next_page = render("hotwire_combobox/next_page", src: src, next_page: next_page, format: :turbo_stream)
43
+ next_page = render("hotwire_combobox/next_page", for_id: for_id, src: src, next_page: next_page, format: :turbo_stream)
45
44
 
46
45
  safe_join [ this_page, next_page ]
47
46
  end
48
47
  hw_alias :hw_paginated_combobox_options
49
48
 
50
- def hw_listbox_options_id(id)
51
- "#{id}-hw-listbox__options"
52
- end
53
- hw_alias :hw_listbox_options_id
49
+ protected # library use only
50
+ def hw_listbox_options_id(id)
51
+ "#{id}-hw-listbox__options"
52
+ end
53
+
54
+ def hw_pagination_frame_id(id)
55
+ "#{id}__hw_combobox_pagination"
56
+ end
54
57
 
55
58
  private
59
+ def hw_extract_options_and_src(options_or_src, render_in)
60
+ if options_or_src.is_a? String
61
+ [ [], hw_uri_with_params(options_or_src, format: :turbo_stream) ]
62
+ else
63
+ [ hw_combobox_options(options_or_src, render_in: render_in), nil ]
64
+ end
65
+ end
66
+
56
67
  def hw_uri_with_params(url_or_path, **params)
57
68
  URI.parse(url_or_path).tap do |url_or_path|
58
69
  query = URI.decode_www_form(url_or_path.query || "").to_h.merge(params)
@@ -1,23 +1,29 @@
1
1
  class HotwireCombobox::Component
2
2
  attr_reader :async_src, :options, :dialog_label
3
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
-
4
+ def initialize \
5
+ view, name,
6
+ association_name: nil,
7
+ async_src: nil,
8
+ autocomplete: :both,
9
+ data: {},
10
+ dialog_label: nil,
11
+ form: nil,
12
+ id: nil,
13
+ input: {},
14
+ name_when_new: nil,
15
+ open: false,
16
+ options: [],
17
+ small_width: "640px",
18
+ value: nil,
19
+ **rest
17
20
  @view, @autocomplete, @id, @name, @value, @form, @async_src,
18
21
  @name_when_new, @open, @data, @small_width, @options, @dialog_label =
19
22
  view, autocomplete, id, name, value, form, async_src,
20
23
  name_when_new, open, data, small_width, options, dialog_label
24
+
25
+ @combobox_attrs = input.reverse_merge(rest).with_indifferent_access
26
+ @association_name = association_name || infer_association_name
21
27
  end
22
28
 
23
29
  def fieldset_attrs
@@ -125,15 +131,23 @@ class HotwireCombobox::Component
125
131
  end
126
132
 
127
133
  def pagination_attrs
128
- { src: async_src }
134
+ { for_id: hidden_field_id, src: async_src }
129
135
  end
130
136
 
131
137
  private
132
138
  attr_reader :view, :autocomplete, :id, :name, :value, :form,
133
- :name_when_new, :open, :data, :combobox_attrs, :small_width
139
+ :name_when_new, :open, :data, :combobox_attrs, :small_width,
140
+ :association_name
141
+
142
+ def infer_association_name
143
+ if name.to_s.include?("_id")
144
+ name.to_s.sub(/_id\z/, "")
145
+ end
146
+ end
134
147
 
135
148
  def fieldset_data
136
149
  data.reverse_merge \
150
+ pagination_id: hidden_field_id,
137
151
  controller: view.token_list("hw-combobox", data[:controller]),
138
152
  hw_combobox_expanded_value: open,
139
153
  hw_combobox_name_when_new_value: name_when_new,
@@ -141,11 +155,30 @@ class HotwireCombobox::Component
141
155
  hw_combobox_autocomplete_value: autocomplete,
142
156
  hw_combobox_small_viewport_max_width_value: small_width,
143
157
  hw_combobox_async_src_value: async_src,
158
+ hw_combobox_prefilled_display_value: prefilled_display,
144
159
  hw_combobox_filterable_attribute_value: "data-filterable-as",
145
160
  hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
146
161
  hw_combobox_selected_class: "hw-combobox__option--selected"
147
162
  end
148
163
 
164
+ def prefilled_display
165
+ if async_src && associated_object
166
+ associated_object.to_combobox_display
167
+ elsif value
168
+ options.find { |option| option.value == value }&.content
169
+ end
170
+ end
171
+
172
+ def associated_object
173
+ @associated_object ||= if association_exists?
174
+ form.object.public_send association_name
175
+ end
176
+ end
177
+
178
+ def association_exists?
179
+ form&.object&.class&.reflect_on_association(association_name).present?
180
+ end
181
+
149
182
 
150
183
  def hidden_field_id
151
184
  id || form&.field_id(name)
@@ -180,7 +213,8 @@ class HotwireCombobox::Component
180
213
  keydown->hw-combobox#navigate
181
214
  click@window->hw-combobox#closeOnClickOutside
182
215
  focusin@window->hw-combobox#closeOnFocusOutside".squish,
183
- hw_combobox_target: "combobox"
216
+ hw_combobox_target: "combobox",
217
+ pagination_id: hidden_field_id
184
218
  end
185
219
 
186
220
  def input_aria
@@ -3,8 +3,16 @@ class HotwireCombobox::Listbox::Option
3
3
  @option = option.is_a?(Hash) ? Data.new(**option) : option
4
4
  end
5
5
 
6
- def render_in(view_context)
7
- view_context.tag.li content, **options
6
+ def render_in(view)
7
+ view.tag.li content, **options
8
+ end
9
+
10
+ def value
11
+ option.try(:value) || option.id
12
+ end
13
+
14
+ def content
15
+ option.try(:content) || option.try(:display)
8
16
  end
9
17
 
10
18
  private
@@ -12,10 +20,6 @@ class HotwireCombobox::Listbox::Option
12
20
 
13
21
  attr_reader :option
14
22
 
15
- def content
16
- option.try(:content) || option.try(:display)
17
- end
18
-
19
23
  def options
20
24
  {
21
25
  id: id,
@@ -45,8 +49,4 @@ class HotwireCombobox::Listbox::Option
45
49
  def autocompletable_as
46
50
  option.try(:autocompletable_as) || option.try(:display)
47
51
  end
48
-
49
- def value
50
- option.try(:value) || option.id
51
- end
52
52
  end
@@ -1,5 +1,5 @@
1
- <%# locals: (next_page:, src:) -%>
1
+ <%# locals: (for_id:, next_page:, src:) -%>
2
2
 
3
- <%= turbo_stream.replace :hotwire_combobox_pagination do %>
4
- <%= render "hotwire_combobox/pagination", src: hw_combobox_next_page_uri(src, next_page) %>
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) %>
5
5
  <% end %>
@@ -1,3 +1,4 @@
1
- <%# locals: (src:) -%>
1
+ <%# locals: (for_id:, src:) -%>
2
2
 
3
- <%= turbo_frame_tag :hotwire_combobox_pagination, src: src, loading: :lazy %>
3
+ <%= turbo_frame_tag hw_pagination_frame_id(for_id), src: src, loading: :lazy,
4
+ data: { hw_combobox_target: "paginationFrame" } %>
@@ -8,8 +8,8 @@ module HotwireCombobox
8
8
 
9
9
  unless HotwireCombobox.bypass_convenience_methods?
10
10
  module FormBuilderExtensions
11
- def combobox(*args, **kwargs, &block)
12
- @template.hw_combobox_tag *args, **kwargs.merge(form: self), &block
11
+ def combobox(*args, **kwargs)
12
+ @template.hw_combobox_tag *args, **kwargs.merge(form: self)
13
13
  end
14
14
  end
15
15
 
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.13"
2
+ VERSION = "0.1.14"
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.13
4
+ version: 0.1.14
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-01-31 00:00:00.000000000 Z
11
+ date: 2024-02-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails