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.
- checksums.yaml +4 -4
- data/README.md +16 -0
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +1 -1
- data/app/assets/javascripts/hotwire_combobox.esm.js +161 -85
- data/app/assets/javascripts/hotwire_combobox.umd.js +161 -85
- data/app/assets/javascripts/hw_combobox/models/combobox/async_loading.js +4 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +8 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/events.js +17 -7
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +19 -22
- data/app/assets/javascripts/hw_combobox/models/combobox/options.js +12 -4
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +95 -44
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +13 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
- data/app/presenters/hotwire_combobox/component.rb +2 -2
- data/lib/hotwire_combobox/engine.rb +2 -2
- data/lib/hotwire_combobox/helper.rb +26 -16
- data/lib/hotwire_combobox/version.rb +1 -1
- metadata +2 -2
@@ -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.
|
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
|
-
|
18
|
-
this.
|
19
|
-
|
20
|
-
if (
|
21
|
-
this.
|
22
|
-
|
23
|
-
this.
|
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
|
-
|
30
|
-
|
38
|
+
_select(option, autocompleteStrategy) {
|
39
|
+
const previousValue = this._value
|
31
40
|
|
32
|
-
|
33
|
-
this.hiddenFieldTarget.value = option.dataset.value
|
34
|
-
option.scrollIntoView({ block: "nearest" })
|
35
|
-
}
|
41
|
+
this._resetOptionsSilently()
|
36
42
|
|
37
|
-
|
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
|
-
|
41
|
-
|
42
|
-
option.classList.toggle(this.selectedClass, selected)
|
43
|
-
}
|
53
|
+
_selectNew() {
|
54
|
+
const previousValue = this._value
|
44
55
|
|
45
|
-
|
46
|
-
this.
|
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
|
64
|
+
const previousValue = this._value
|
51
65
|
|
52
|
-
if (
|
66
|
+
if (this._selectedOptionElement) {
|
67
|
+
this._markNotSelected(this._selectedOptionElement)
|
68
|
+
}
|
53
69
|
|
54
|
-
this.
|
70
|
+
this._setValue(null)
|
55
71
|
this._setActiveDescendant("")
|
56
72
|
|
57
|
-
|
73
|
+
return previousValue
|
58
74
|
}
|
59
75
|
|
60
|
-
|
61
|
-
this.
|
62
|
-
this.
|
63
|
-
|
64
|
-
|
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
|
-
|
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.
|
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.
|
98
|
+
return option.dataset.value === this._value
|
78
99
|
})
|
79
100
|
|
80
|
-
if (option) this._markSelected(option
|
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.
|
87
|
-
this.filter({ inputType: "hw:lockInSelection" })
|
115
|
+
this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" })
|
88
116
|
}
|
117
|
+
}
|
89
118
|
|
90
|
-
|
91
|
-
|
92
|
-
|
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.
|
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.
|
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#
|
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#
|
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
|
-
|
30
|
-
|
31
|
-
|
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)
|
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
|
-
|
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,
|
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,
|
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,
|
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] =
|
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] =
|
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[:
|
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
|
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.
|
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-
|
11
|
+
date: 2024-03-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|