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.
- 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
|