hotwire_combobox 0.1.35 → 0.1.37

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: 4b9f936869948e6767d3cdf4f02f226bfe4c3954d57172b37388239884b69ae6
4
- data.tar.gz: b5495f56d66183c5ebb6c54a6a8e5278391df921f304b36b9da9ffb3bf752576
3
+ metadata.gz: 85fbe2e5c1b8d503be62eceae58650266582ac3428be6558c0af8bb76ec5f03c
4
+ data.tar.gz: f13eb27444b1e530391a8058dc6f00a6d46d9df4af75eb2de2cd28a568851f0b
5
5
  SHA512:
6
- metadata.gz: 7bb2f124171055bc2b46c7584f31b3ae6426979dd9485645e8364bef559aa86cfa905f62f0db47e79d48db98f42d422b9c8fd9f342729e7da4704385d8544e3b
7
- data.tar.gz: 27e49fa1cfd828d23f7dcf82b75a2ba7d7edc4adc8fcff88d35cab8cc41e666f39dffb82209c68740e8b4d9ec9236b1db3513e7c2952cf80cf6f96dc7608a39e
6
+ metadata.gz: 207509d422415ad6684e08b09690b2244f6fde41e3a9f92a6e90ddf7808f1d0a43d1966f48847c52e133e48e8d81d8139dd2883053dd7b44ed5739d7ab2d62fc
7
+ data.tar.gz: 184048c9ec147b4d28abf8a3c0d189fa6b73e72a9ef47c6db4c9cf3a595b880287d4e0ddc985d785a38fbce1b9a58279825c46099f800492368ae2cafe349a30
@@ -1,7 +1,9 @@
1
1
  import Combobox from "hw_combobox/models/combobox"
2
- import { Concerns } from "hw_combobox/helpers"
2
+ import { Concerns, sleep } from "hw_combobox/helpers"
3
3
  import { Controller } from "@hotwired/stimulus"
4
4
 
5
+ window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0 // ms, for testing purposes
6
+
5
7
  const concerns = [
6
8
  Controller,
7
9
  Combobox.Actors,
@@ -70,10 +72,12 @@ export default class HwComboboxController extends Concerns(...concerns) {
70
72
  }
71
73
  }
72
74
 
73
- endOfOptionsStreamTargetConnected(element) {
75
+ async endOfOptionsStreamTargetConnected(element) {
74
76
  const inputType = element.dataset.inputType
77
+ const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY
75
78
 
76
- if (inputType && inputType !== "hw:ensureSelection") {
79
+ if (inputType && inputType !== "hw:lockInSelection") {
80
+ if (delay) await sleep(delay)
77
81
  this._commitFilter({ inputType })
78
82
  } else {
79
83
  this._preselectOption()
@@ -37,7 +37,7 @@ export function startsWith(string, substring) {
37
37
  return string.toLowerCase().startsWith(substring.toLowerCase())
38
38
  }
39
39
 
40
- export function debounce(fn, delay = 300) {
40
+ export function debounce(fn, delay = 150) {
41
41
  let timeoutId = null
42
42
 
43
43
  return (...args) => {
@@ -50,3 +50,15 @@ export function debounce(fn, delay = 300) {
50
50
  export function isDeleteEvent(event) {
51
51
  return event.inputType === "deleteContentBackward" || event.inputType === "deleteWordBackward"
52
52
  }
53
+
54
+ export function sleep(ms) {
55
+ return new Promise(resolve => setTimeout(resolve, ms))
56
+ }
57
+
58
+ export function unselectedPortion(element) {
59
+ if (element.selectionStart === element.selectionEnd) {
60
+ return element.value
61
+ } else {
62
+ return element.value.substring(0, element.selectionStart)
63
+ }
64
+ }
@@ -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
  }
@@ -11,14 +11,14 @@ Combobox.Autocomplete = Base => class extends Base {
11
11
  _autocompleteWith(option, { force }) {
12
12
  if (!this._autocompletesInline && !force) return
13
13
 
14
- const typedValue = this._query
14
+ const typedValue = this._typedQuery
15
15
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
16
16
 
17
17
  if (force) {
18
- this._query = autocompletedValue
18
+ this._fullQuery = autocompletedValue
19
19
  this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
20
20
  } else if (startsWith(autocompletedValue, typedValue)) {
21
- this._query = autocompletedValue
21
+ this._fullQuery = autocompletedValue
22
22
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length)
23
23
  }
24
24
  }
@@ -30,14 +30,14 @@ Combobox.Autocomplete = Base => class extends Base {
30
30
  }
31
31
 
32
32
  get _isExactAutocompleteMatch() {
33
- return this._immediatelyAutocompletableValue === this._query
33
+ return this._immediatelyAutocompletableValue === this._fullQuery
34
34
  }
35
35
 
36
36
  // All `_isExactAutocompleteMatch` matches are `_isPartialAutocompleteMatch` matches
37
37
  // but not all `_isPartialAutocompleteMatch` matches are `_isExactAutocompleteMatch` matches.
38
38
  get _isPartialAutocompleteMatch() {
39
39
  return !!this._immediatelyAutocompletableValue &&
40
- startsWith(this._immediatelyAutocompletableValue, this._query)
40
+ startsWith(this._immediatelyAutocompletableValue, this._fullQuery)
41
41
  }
42
42
 
43
43
  get _autocompletesList() {
@@ -49,6 +49,6 @@ Combobox.Autocomplete = Base => class extends Base {
49
49
  }
50
50
 
51
51
  get _immediatelyAutocompletableValue() {
52
- return this._visibleOptionElements[0]?.getAttribute(this.autocompletableAttributeValue)
52
+ return this._ensurableOption?.getAttribute(this.autocompletableAttributeValue)
53
53
  }
54
54
  }
@@ -14,7 +14,7 @@ Combobox.Dialog = Base => class extends Base {
14
14
  }
15
15
 
16
16
  _moveArtifactsToDialog() {
17
- this.dialogComboboxTarget.value = this._query
17
+ this.dialogComboboxTarget.value = this._fullQuery
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._query
26
+ this.comboboxTarget.value = this._fullQuery
27
27
 
28
28
  this._actingCombobox = this.comboboxTarget
29
29
  this._actingListbox = this.listboxTarget
@@ -1,6 +1,6 @@
1
1
 
2
2
  import Combobox from "hw_combobox/models/combobox/base"
3
- import { applyFilter, debounce, isDeleteEvent } from "hw_combobox/helpers"
3
+ import { applyFilter, debounce, isDeleteEvent, unselectedPortion } from "hw_combobox/helpers"
4
4
  import { get } from "hw_combobox/vendor/requestjs"
5
5
 
6
6
  Combobox.Filtering = Base => class extends Base {
@@ -21,13 +21,13 @@ Combobox.Filtering = Base => class extends Base {
21
21
  }
22
22
 
23
23
  async _filterAsync(event) {
24
- const query = { q: this._query, input_type: event.inputType }
24
+ const query = { q: this._fullQuery, input_type: event.inputType }
25
25
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query })
26
26
  }
27
27
 
28
28
  _filterSync(event) {
29
29
  this.open()
30
- this._allOptionElements.forEach(applyFilter(this._query, { matching: this.filterableAttributeValue }))
30
+ this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }))
31
31
  this._commitFilter(event)
32
32
  }
33
33
 
@@ -42,16 +42,18 @@ Combobox.Filtering = Base => class extends Base {
42
42
  }
43
43
 
44
44
  get _isQueried() {
45
- return this._query.length > 0
45
+ return this._fullQuery.length > 0
46
46
  }
47
47
 
48
- // Consider +_query+ will contain the full autocompleted value
49
- // after a certain point in the call chain.
50
- get _query() {
48
+ get _fullQuery() {
51
49
  return this._actingCombobox.value
52
50
  }
53
51
 
54
- set _query(value) {
52
+ set _fullQuery(value) {
55
53
  this._actingCombobox.value = value
56
54
  }
55
+
56
+ get _typedQuery() {
57
+ return unselectedPortion(this._actingCombobox)
58
+ }
57
59
  }
@@ -10,16 +10,16 @@ Combobox.Selection = Base => class extends Base {
10
10
 
11
11
  _connectSelection() {
12
12
  if (this.hasPrefilledDisplayValue) {
13
- this._query = this.prefilledDisplayValue
13
+ this._fullQuery = this.prefilledDisplayValue
14
14
  }
15
15
  }
16
16
 
17
- _select(option, { force = false } = {}) {
17
+ _select(option, { forceAutocomplete = false } = {}) {
18
18
  this._resetOptions()
19
19
 
20
20
  if (option) {
21
21
  this._markValid()
22
- this._autocompleteWith(option, { force })
22
+ this._autocompleteWith(option, { force: forceAutocomplete })
23
23
  this._commitSelection(option, { selected: true })
24
24
  } else {
25
25
  this._markInvalid()
@@ -41,23 +41,25 @@ 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() {
53
55
  this._resetOptions()
54
- this.hiddenFieldTarget.value = this._query
56
+ this.hiddenFieldTarget.value = this._fullQuery
55
57
  this.hiddenFieldTarget.name = this.nameWhenNewValue
56
58
  }
57
59
 
58
60
  _selectIndex(index) {
59
61
  const option = wrapAroundAccess(this._visibleOptionElements, index)
60
- this._select(option, { force: true })
62
+ this._select(option, { forceAutocomplete: true })
61
63
  }
62
64
 
63
65
  _preselectOption() {
@@ -70,18 +72,22 @@ Combobox.Selection = Base => class extends Base {
70
72
  }
71
73
  }
72
74
 
73
- _ensureSelection() {
74
- if (this._shouldEnsureSelection) {
75
- this._select(this._ensurableOption, { force: true })
76
- this.filter({ inputType: "hw:ensureSelection" })
75
+ _lockInSelection() {
76
+ if (this._shouldLockInSelection) {
77
+ this._select(this._ensurableOption, { forceAutocomplete: true })
78
+ this.filter({ inputType: "hw:lockInSelection" })
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
  }
83
89
 
84
- get _shouldEnsureSelection() {
90
+ get _shouldLockInSelection() {
85
91
  return this._isQueried && !!this._ensurableOption && !this._isNewOptionWithPotentialMatches
86
92
  }
87
93
 
@@ -8,7 +8,7 @@ Combobox.Toggle = Base => class extends Base {
8
8
 
9
9
  close() {
10
10
  if (this._isOpen) {
11
- this._ensureSelection()
11
+ this._lockInSelection()
12
12
  this.expandedValue = false
13
13
  }
14
14
  }
@@ -34,7 +34,6 @@ Combobox.Toggle = Base => class extends Base {
34
34
  closeOnFocusOutside({ target }) {
35
35
  if (!this._isOpen) return
36
36
  if (this.element.contains(target)) return
37
- if (target.matches("main")) return
38
37
 
39
38
  this.close()
40
39
  }
@@ -53,8 +53,9 @@ class HotwireCombobox::Component
53
53
  class: "hw-combobox__input",
54
54
  type: input_type,
55
55
  data: input_data,
56
- aria: input_aria
57
- }.merge combobox_attrs.except(*nested_attrs)
56
+ aria: input_aria,
57
+ autocomplete: :off
58
+ }.with_indifferent_access.merge combobox_attrs.except(*nested_attrs)
58
59
  end
59
60
 
60
61
 
@@ -77,11 +78,6 @@ class HotwireCombobox::Component
77
78
  end
78
79
 
79
80
 
80
- def listbox_options_attrs
81
- { id: listbox_options_id }
82
- end
83
-
84
-
85
81
  def dialog_wrapper_attrs
86
82
  {
87
83
  class: "hw-combobox__dialog__wrapper"
@@ -239,7 +235,8 @@ class HotwireCombobox::Component
239
235
  controls: listbox_id,
240
236
  owns: listbox_id,
241
237
  haspopup: "listbox",
242
- autocomplete: autocomplete
238
+ autocomplete: autocomplete,
239
+ activedescendant: ""
243
240
  end
244
241
 
245
242
 
@@ -260,11 +257,6 @@ class HotwireCombobox::Component
260
257
  end
261
258
 
262
259
 
263
- def listbox_options_id
264
- "#{listbox_id}__options"
265
- end
266
-
267
-
268
260
  def dialog_data
269
261
  {
270
262
  action: "keydown->hw-combobox#navigate",
@@ -290,7 +282,8 @@ class HotwireCombobox::Component
290
282
  {
291
283
  controls: dialog_listbox_id,
292
284
  owns: dialog_listbox_id,
293
- autocomplete: autocomplete
285
+ autocomplete: autocomplete,
286
+ activedescendant: ""
294
287
  }
295
288
  end
296
289
 
@@ -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
@@ -30,7 +32,7 @@ class HotwireCombobox::Listbox::Option
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
@@ -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 %>
3
+ <%= turbo_stream.remove hw_pagination_frame_wrapper_id(for_id) %>
4
+ <%= turbo_stream.append hw_listbox_id(for_id) do %>
4
5
  <%= render "hotwire_combobox/pagination", for_id: for_id, src: hw_combobox_next_page_uri(src, next_page) %>
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? %>
@@ -45,8 +45,12 @@ module HotwireCombobox
45
45
  hw_alias :hw_async_combobox_options
46
46
 
47
47
  protected # library use only
48
- def hw_listbox_options_id(id)
49
- "#{id}-hw-listbox__options"
48
+ def hw_listbox_id(id)
49
+ "#{id}-hw-listbox"
50
+ end
51
+
52
+ def hw_pagination_frame_wrapper_id(id)
53
+ "#{id}__hw_combobox_pagination__wrapper"
50
54
  end
51
55
 
52
56
  def hw_pagination_frame_id(id)
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.35"
2
+ VERSION = "0.1.37"
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.35
4
+ version: 0.1.37
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-02-29 00:00:00.000000000 Z
11
+ date: 2024-03-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -52,7 +52,8 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.2'
55
- description: A combobox implementation for Ruby on Rails apps using Hotwire.
55
+ description: An autocomplete combobox implementation for Ruby on Rails apps using
56
+ Hotwire.
56
57
  email:
57
58
  - jose@farias.mx
58
59
  executables: []