hotwire_combobox 0.1.12 → 0.1.14

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f21dd202e0a59116a7d505c6b79df8ba287db06dfa66fd6c9159cbaad15f119d
4
- data.tar.gz: 9eb4d23df658d14948200e88ee39f9dad471a61782cde5f4132767a6c81763e1
3
+ metadata.gz: f1edf53e6f495466fb562c9e5a5fa6f4484fcb3f5729d561dc3e59ab622daad5
4
+ data.tar.gz: ade7a12ec97430ec9fb824525725706d0f4628f67a12019b5392c2f2b5ce46a2
5
5
  SHA512:
6
- metadata.gz: 4013316a7bcbaf7080f25987c32f0f2dc4b2a38f11e8cfb62292155d626ab2bbc7d46f5116125b351eacabeb40856e50fb03335f69c95c7e09a919a0a0ed26ca
7
- data.tar.gz: 6ca62cf9479f2d6951823affeb07320e572ff02dbb34681cf3ef33dabb579103410f7ad6123ee02cd21252c2cdcbb0e06c2efb564913c52b289beaae57e1fb36
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
  }
@@ -3,26 +3,27 @@ module HotwireCombobox
3
3
  class << self
4
4
  delegate :bypass_convenience_methods?, to: :HotwireCombobox
5
5
 
6
- def hw(method_name)
6
+ def hw_alias(method_name)
7
7
  unless bypass_convenience_methods?
8
8
  alias_method method_name.to_s.sub(/^hw_/, ""), method_name
9
9
  end
10
10
  end
11
11
  end
12
12
 
13
- hw def hw_combobox_style_tag(*args, **kwargs)
13
+ def hw_combobox_style_tag(*args, **kwargs)
14
14
  stylesheet_link_tag HotwireCombobox.stylesheet_path, *args, **kwargs
15
15
  end
16
+ hw_alias :hw_combobox_style_tag
16
17
 
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
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
21
21
 
22
22
  render "hotwire_combobox/combobox", component: component
23
23
  end
24
+ hw_alias :hw_combobox_tag
24
25
 
25
- hw def hw_combobox_options(options, render_in: {}, display: :to_combobox_display, **methods)
26
+ def hw_combobox_options(options, render_in: {}, display: :to_combobox_display, **methods)
26
27
  if options.first.is_a? HotwireCombobox::Listbox::Option
27
28
  options
28
29
  else
@@ -35,19 +36,34 @@ module HotwireCombobox
35
36
  hw_parse_combobox_options options, **methods.merge(display: display, content: content)
36
37
  end
37
38
  end
39
+ hw_alias :hw_combobox_options
38
40
 
39
- hw 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)
40
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)
41
- 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)
42
44
 
43
45
  safe_join [ this_page, next_page ]
44
46
  end
47
+ hw_alias :hw_paginated_combobox_options
45
48
 
46
- hw def hw_listbox_options_id(id)
47
- "#{id}-hw-listbox__options"
48
- end
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
49
57
 
50
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
+
51
67
  def hw_uri_with_params(url_or_path, **params)
52
68
  URI.parse(url_or_path).tap do |url_or_path|
53
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,16 +20,11 @@ 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,
22
26
  role: :option,
23
27
  class: "hw-combobox__option",
24
- tabindex: 0,
25
28
  data: data
26
29
  }
27
30
  end
@@ -46,8 +49,4 @@ class HotwireCombobox::Listbox::Option
46
49
  def autocompletable_as
47
50
  option.try(:autocompletable_as) || option.try(:display)
48
51
  end
49
-
50
- def value
51
- option.try(:value) || option.id
52
- end
53
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.12"
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.12
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