hotwire_combobox 0.1.41 → 0.1.43

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.
@@ -1,10 +1,9 @@
1
1
  import Combobox from "hw_combobox/models/combobox/base"
2
- import { wrapAroundAccess } from "hw_combobox/helpers"
2
+ import { wrapAroundAccess, isDeleteEvent } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Selection = Base => class extends Base {
5
5
  selectOptionOnClick(event) {
6
- this.filter(event)
7
- this._select(event.currentTarget, { forceAutocomplete: true })
6
+ this._forceSelectionAndFilter(event.currentTarget, event)
8
7
  this.close()
9
8
  }
10
9
 
@@ -14,91 +13,143 @@ Combobox.Selection = Base => class extends Base {
14
13
  }
15
14
  }
16
15
 
17
- _select(option, { forceAutocomplete = false } = {}) {
18
- this._resetOptions()
19
-
20
- if (option) {
21
- this._autocompleteWith(option, { force: forceAutocomplete })
22
- this._commitSelection(option, { selected: true })
23
- this._markValid()
24
- } else {
16
+ _selectBasedOnQuery(event) {
17
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
18
+ this._selectNew()
19
+ } else if (isDeleteEvent(event)) {
20
+ this._deselect()
21
+ } else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
22
+ this._selectAndAutocompleteMissingPortion(this._ensurableOption)
23
+ } else if (this._isOpen && this._visibleOptionElements[0]) {
24
+ this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0])
25
+ } else if (this._isOpen) {
26
+ this._resetOptionsAndNotify()
25
27
  this._markInvalid()
28
+ } else {
29
+ // When selecting from an async dialog listbox: selection is forced, the listbox is filtered,
30
+ // and the dialog is closed. Filtering ends with an `endOfOptionsStream` target connected
31
+ // to the now invisible combobox, which is now closed because Turbo waits for "nextRepaint"
32
+ // before rendering turbo streams. This ultimately calls +_selectBasedOnQuery+. We do want
33
+ // to call +_selectBasedOnQuery+ in this case to account for e.g. selection of
34
+ // new options. But we will noop here if it's none of the cases checked above.
26
35
  }
27
36
  }
28
37
 
29
- _commitSelection(option, { selected }) {
30
- this._markSelected(option, { selected })
38
+ _select(option, autocompleteStrategy) {
39
+ const previousValue = this._value
31
40
 
32
- if (selected) {
33
- this.hiddenFieldTarget.value = option.dataset.value
34
- option.scrollIntoView({ block: "nearest" })
35
- }
41
+ this._resetOptionsSilently()
36
42
 
37
- this._dispatchSelectionEvent({ isNew: false })
43
+ autocompleteStrategy(option)
44
+
45
+ this._setValue(option.dataset.value)
46
+ this._markSelected(option)
47
+ this._markValid()
48
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
49
+
50
+ option.scrollIntoView({ block: "nearest" })
38
51
  }
39
52
 
40
- _markSelected(option, { selected }) {
41
- if (this.hasSelectedClass) {
42
- option.classList.toggle(this.selectedClass, selected)
43
- }
53
+ _selectNew() {
54
+ const previousValue = this._value
44
55
 
45
- option.setAttribute("aria-selected", selected)
46
- this._setActiveDescendant(selected ? option.id : "")
56
+ this._resetOptionsSilently()
57
+ this._setValue(this._fullQuery)
58
+ this._setName(this.nameWhenNewValue)
59
+ this._markValid()
60
+ this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue })
47
61
  }
48
62
 
49
63
  _deselect() {
50
- const option = this._selectedOptionElement
64
+ const previousValue = this._value
51
65
 
52
- if (option) this._commitSelection(option, { selected: false })
66
+ if (this._selectedOptionElement) {
67
+ this._markNotSelected(this._selectedOptionElement)
68
+ }
53
69
 
54
- this.hiddenFieldTarget.value = null
70
+ this._setValue(null)
55
71
  this._setActiveDescendant("")
56
72
 
57
- if (!option) this._dispatchSelectionEvent({ isNew: false })
73
+ return previousValue
58
74
  }
59
75
 
60
- _selectNew() {
61
- this._resetOptions()
62
- this.hiddenFieldTarget.value = this._fullQuery
63
- this.hiddenFieldTarget.name = this.nameWhenNewValue
64
- this._markValid()
76
+ _deselectAndNotify() {
77
+ const previousValue = this._deselect()
78
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
79
+ }
80
+
81
+ _forceSelectionAndFilter(option, event) {
82
+ this._forceSelectionWithoutFiltering(option)
83
+ this._filter(event)
84
+ }
65
85
 
66
- this._dispatchSelectionEvent({ isNew: true })
86
+ _forceSelectionWithoutFiltering(option) {
87
+ this._selectAndReplaceFullQuery(option)
67
88
  }
68
89
 
69
90
  _selectIndex(index) {
70
91
  const option = wrapAroundAccess(this._visibleOptionElements, index)
71
- this._select(option, { forceAutocomplete: true })
92
+ this._forceSelectionWithoutFiltering(option)
72
93
  }
73
94
 
74
95
  _preselectOption() {
75
96
  if (this._hasValueButNoSelection && this._allOptions.length < 100) {
76
97
  const option = this._allOptions.find(option => {
77
- return option.dataset.value === this.hiddenFieldTarget.value
98
+ return option.dataset.value === this._value
78
99
  })
79
100
 
80
- if (option) this._markSelected(option, { selected: true })
101
+ if (option) this._markSelected(option)
81
102
  }
82
103
  }
83
104
 
105
+ _selectAndReplaceFullQuery(option) {
106
+ this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this))
107
+ }
108
+
109
+ _selectAndAutocompleteMissingPortion(option) {
110
+ this._select(option, this._autocompleteMissingPortion.bind(this))
111
+ }
112
+
84
113
  _lockInSelection() {
85
114
  if (this._shouldLockInSelection) {
86
- this._select(this._ensurableOption, { forceAutocomplete: true })
87
- this.filter({ inputType: "hw:lockInSelection" })
115
+ this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" })
88
116
  }
117
+ }
89
118
 
90
- if (this._isUnjustifiablyBlank) {
91
- this._deselect()
92
- this._clearQuery()
93
- }
119
+ _markSelected(option) {
120
+ if (this.hasSelectedClass) option.classList.add(this.selectedClass)
121
+ option.setAttribute("aria-selected", true)
122
+ this._setActiveDescendant(option.id)
123
+ }
124
+
125
+ _markNotSelected(option) {
126
+ if (this.hasSelectedClass) option.classList.remove(this.selectedClass)
127
+ option.removeAttribute("aria-selected")
128
+ this._removeActiveDescendant()
94
129
  }
95
130
 
96
131
  _setActiveDescendant(id) {
97
132
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id))
98
133
  }
99
134
 
135
+ _removeActiveDescendant() {
136
+ this._setActiveDescendant("")
137
+ }
138
+
139
+ _setValue(value) {
140
+ this.hiddenFieldTarget.value = value
141
+ }
142
+
143
+ _setName(value) {
144
+ this.hiddenFieldTarget.name = value
145
+ }
146
+
147
+ get _value() {
148
+ return this.hiddenFieldTarget.value
149
+ }
150
+
100
151
  get _hasValueButNoSelection() {
101
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
152
+ return this._value && !this._selectedOptionElement
102
153
  }
103
154
 
104
155
  get _shouldLockInSelection() {
@@ -9,7 +9,11 @@ Combobox.Toggle = Base => class extends Base {
9
9
  close() {
10
10
  if (this._isOpen) {
11
11
  this._lockInSelection()
12
+ this._clearInvalidQuery()
13
+
12
14
  this.expandedValue = false
15
+
16
+ this._dispatchClosedEvent()
13
17
  }
14
18
  }
15
19
 
@@ -78,6 +82,8 @@ Combobox.Toggle = Base => class extends Base {
78
82
  this._actingCombobox.setAttribute("aria-expanded", true) // needs to happen after setting acting combobox
79
83
  }
80
84
 
85
+ // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
86
+ // it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
81
87
  _collapse() {
82
88
  this._actingCombobox.setAttribute("aria-expanded", false) // needs to happen before resetting acting combobox
83
89
 
@@ -118,6 +124,13 @@ Combobox.Toggle = Base => class extends Base {
118
124
  enableBodyScroll(this.dialogListboxTarget)
119
125
  }
120
126
 
127
+ _clearInvalidQuery() {
128
+ if (this._isUnjustifiablyBlank) {
129
+ this._deselect()
130
+ this._clearQuery()
131
+ }
132
+ }
133
+
121
134
  get _isOpen() {
122
135
  return this.expandedValue
123
136
  }
@@ -34,7 +34,7 @@ Combobox.Validity = Base => class extends Base {
34
34
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
35
35
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
36
36
  get _valueIsInvalid() {
37
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value
37
+ const isRequiredAndEmpty = this.comboboxTarget.required && !this._value
38
38
  return isRequiredAndEmpty
39
39
  }
40
40
  }
@@ -266,7 +266,7 @@ class HotwireCombobox::Component
266
266
  combobox_attrs.fetch(:data, {}).merge \
267
267
  action: "
268
268
  focus->hw-combobox#open
269
- input->hw-combobox#filter
269
+ input->hw-combobox#filterAndSelect
270
270
  keydown->hw-combobox#navigate
271
271
  click@window->hw-combobox#closeOnClickOutside
272
272
  focusin@window->hw-combobox#closeOnFocusOutside
@@ -316,7 +316,7 @@ class HotwireCombobox::Component
316
316
  def dialog_input_data
317
317
  {
318
318
  action: "
319
- input->hw-combobox#filter
319
+ input->hw-combobox#filterAndSelect
320
320
  keydown->hw-combobox#navigate
321
321
  click@window->hw-combobox#closeOnClickOutside".squish,
322
322
  hw_combobox_target: "dialogCombobox"
@@ -9,8 +9,8 @@ module HotwireCombobox
9
9
 
10
10
  unless HotwireCombobox.bypass_convenience_methods?
11
11
  module FormBuilderExtensions
12
- def combobox(*args, **kwargs)
13
- @template.hw_combobox_tag(*args, **kwargs.merge(form: self))
12
+ def combobox(*args, **kwargs, &block)
13
+ @template.hw_combobox_tag(*args, **kwargs.merge(form: self), &block)
14
14
  end
15
15
  end
16
16
 
@@ -26,11 +26,9 @@ module HotwireCombobox
26
26
  if options.first.is_a? HotwireCombobox::Listbox::Option
27
27
  options
28
28
  else
29
- render_in_proc = hw_render_in_proc(render_in) if render_in.present?
30
-
31
- hw_parse_combobox_options(options, render_in: render_in_proc, **methods.merge(display: display)).tap do |options|
32
- options.unshift(hw_blank_option(include_blank)) if include_blank.present?
33
- end
29
+ opts = hw_parse_combobox_options options, render_in_proc: hw_render_in_proc(render_in), **methods.merge(display: display)
30
+ opts.unshift(hw_blank_option(include_blank)) if include_blank.present?
31
+ opts
34
32
  end
35
33
  end
36
34
  hw_alias :hw_combobox_options
@@ -85,7 +83,7 @@ module HotwireCombobox
85
83
  if include_blank.is_a? Hash
86
84
  text = include_blank.delete(:text)
87
85
 
88
- [ text, hw_render_in_proc(include_blank).(text) ]
86
+ [ text, hw_call_render_in_proc(hw_render_in_proc(include_blank), text, display: text, value: "") ]
89
87
  else
90
88
  [ include_blank, include_blank ]
91
89
  end
@@ -102,7 +100,9 @@ module HotwireCombobox
102
100
 
103
101
  private
104
102
  def hw_render_in_proc(render_in)
105
- ->(object) { render(**render_in.reverse_merge(object: object)) }
103
+ if render_in.present?
104
+ ->(object, locals) { render(**render_in.reverse_merge(object: object, locals: locals)) }
105
+ end
106
106
  end
107
107
 
108
108
  def hw_extract_options_and_src(options_or_src, render_in, include_blank)
@@ -113,40 +113,50 @@ module HotwireCombobox
113
113
  end
114
114
  end
115
115
 
116
- def hw_parse_combobox_options(options, render_in: nil, **methods)
116
+ def hw_parse_combobox_options(options, render_in_proc: nil, **methods)
117
117
  options.map do |option|
118
118
  HotwireCombobox::Listbox::Option.new \
119
- **hw_option_attrs_for(option, render_in: render_in, **methods)
119
+ **hw_option_attrs_for(option, render_in_proc: render_in_proc, **methods)
120
120
  end
121
121
  end
122
122
 
123
- def hw_option_attrs_for(option, render_in: nil, **methods)
123
+ def hw_option_attrs_for(option, render_in_proc: nil, **methods)
124
124
  case option
125
125
  when Hash
126
- option
126
+ option.tap do |attrs|
127
+ attrs[:content] = hw_call_render_in_proc(render_in_proc, attrs[:display], attrs) if render_in_proc
128
+ end
127
129
  when String
128
130
  {}.tap do |attrs|
129
131
  attrs[:display] = option
130
132
  attrs[:value] = option
131
- attrs[:content] = render_in.(option) if render_in
133
+ attrs[:content] = hw_call_render_in_proc(render_in_proc, attrs[:display], attrs) if render_in_proc
132
134
  end
133
135
  when Array
134
136
  {}.tap do |attrs|
135
137
  attrs[:display] = option.first
136
138
  attrs[:value] = option.last
137
- attrs[:content] = render_in.(option.first) if render_in
139
+ attrs[:content] = hw_call_render_in_proc(render_in_proc, attrs[:display], attrs) if render_in_proc
138
140
  end
139
141
  else
140
142
  {}.tap do |attrs|
141
- attrs[:value] = hw_call_method_or_proc(option, methods[:value] || :id)
142
-
143
143
  attrs[:id] = hw_call_method_or_proc(option, methods[:id]) if methods[:id]
144
144
  attrs[:display] = hw_call_method_or_proc(option, methods[:display]) if methods[:display]
145
- attrs[:content] = hw_call_method_or_proc(option, render_in || methods[:content]) if render_in || methods[:content]
145
+ attrs[:value] = hw_call_method_or_proc(option, methods[:value] || :id)
146
+
147
+ if render_in_proc
148
+ attrs[:content] = hw_call_render_in_proc(render_in_proc, option, attrs)
149
+ elsif methods[:content]
150
+ attrs[:content] = hw_call_method_or_proc(option, methods[:content])
151
+ end
146
152
  end
147
153
  end
148
154
  end
149
155
 
156
+ def hw_call_render_in_proc(render_in_proc, object, attrs)
157
+ render_in_proc.(object, combobox_display: attrs[:display], combobox_value: attrs[:value])
158
+ end
159
+
150
160
  def hw_call_method_or_proc(object, method_or_proc)
151
161
  if method_or_proc.is_a? Proc
152
162
  method_or_proc.call object
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.41"
2
+ VERSION = "0.1.43"
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.41
4
+ version: 0.1.43
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-12 00:00:00.000000000 Z
11
+ date: 2024-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails