hotwire_combobox 0.1.41 → 0.1.43

Sign up to get free protection for your applications and to get access to all the features.
@@ -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