hotwire_combobox 0.1.35 → 0.1.37

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: 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: []