hotwire_combobox 0.1.13 → 0.1.14
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +7 -1
- data/app/assets/javascripts/models/combobox/dialog.js +7 -5
- data/app/assets/javascripts/models/combobox/options.js +2 -2
- data/app/assets/javascripts/models/combobox/selection.js +26 -12
- data/app/assets/javascripts/models/combobox/toggle.js +22 -2
- data/app/helpers/hotwire_combobox/helper.rb +21 -10
- data/app/presenters/hotwire_combobox/component.rb +50 -16
- data/app/presenters/hotwire_combobox/listbox/option.rb +10 -10
- data/app/views/hotwire_combobox/_next_page.turbo_stream.erb +3 -3
- data/app/views/hotwire_combobox/_pagination.html.erb +3 -2
- data/lib/hotwire_combobox/engine.rb +2 -2
- data/lib/hotwire_combobox/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f1edf53e6f495466fb562c9e5a5fa6f4484fcb3f5729d561dc3e59ab622daad5
|
4
|
+
data.tar.gz: ade7a12ec97430ec9fb824525725706d0f4628f67a12019b5392c2f2b5ce46a2
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4b8a4ed1d34fa641f546fc59838e052feaa9bd1f89a339b31ef218a5f61354cefb5cc4ced5c5b02d29a1416d125054a7a93087bc8a94b54855cba2fe0f4cb4da
|
7
|
+
data.tar.gz: 771ddf2a2f621331bc0199dcb114ef9362fdb090459a80dc1e56589d5c2221dd5eec45516a84acdc279fd349f86494fd800838c5203c6412bfa40f11e36a4724
|
@@ -30,7 +30,8 @@ export default class HwComboboxController extends Concerns(...concerns) {
|
|
30
30
|
"dialogListbox",
|
31
31
|
"handle",
|
32
32
|
"hiddenField",
|
33
|
-
"listbox"
|
33
|
+
"listbox",
|
34
|
+
"paginationFrame"
|
34
35
|
]
|
35
36
|
|
36
37
|
static values = {
|
@@ -41,6 +42,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
|
|
41
42
|
filterableAttribute: String,
|
42
43
|
nameWhenNew: String,
|
43
44
|
originalName: String,
|
45
|
+
prefilledDisplay: String,
|
44
46
|
smallViewportMaxWidth: String
|
45
47
|
}
|
46
48
|
|
@@ -66,4 +68,8 @@ export default class HwComboboxController extends Concerns(...concerns) {
|
|
66
68
|
this._collapse()
|
67
69
|
}
|
68
70
|
}
|
71
|
+
|
72
|
+
paginationFrameTargetConnected() {
|
73
|
+
this._preselectOption()
|
74
|
+
}
|
69
75
|
}
|
@@ -14,7 +14,7 @@ Combobox.Dialog = Base => class extends Base {
|
|
14
14
|
}
|
15
15
|
|
16
16
|
_moveArtifactsToDialog() {
|
17
|
-
this.dialogComboboxTarget.value = this.
|
17
|
+
this.dialogComboboxTarget.value = this._actingCombobox.value
|
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.
|
26
|
+
this.comboboxTarget.value = this._actingCombobox.value
|
27
27
|
|
28
28
|
this._actingCombobox = this.comboboxTarget
|
29
29
|
this._actingListbox = this.listboxTarget
|
@@ -32,9 +32,11 @@ Combobox.Dialog = Base => class extends Base {
|
|
32
32
|
}
|
33
33
|
|
34
34
|
_resizeDialog = () => {
|
35
|
-
|
36
|
-
|
37
|
-
|
35
|
+
if (window.visualViewport) {
|
36
|
+
const fullHeight = window.innerHeight
|
37
|
+
const viewportHeight = window.visualViewport.height
|
38
|
+
this.dialogTarget.style.setProperty("--hw-dialog-bottom-padding", `${fullHeight - viewportHeight}px`)
|
39
|
+
}
|
38
40
|
}
|
39
41
|
|
40
42
|
// After closing a dialog, focus returns to the last focused element.
|
@@ -10,9 +10,9 @@ Combobox.Options = Base => class extends Base {
|
|
10
10
|
_isValidNewOption(query, { ignoreAutocomplete = false } = {}) {
|
11
11
|
const typedValue = this._actingCombobox.value
|
12
12
|
const autocompletedValue = this._visibleOptionElements[0]?.getAttribute(this.autocompletableAttributeValue)
|
13
|
-
const
|
13
|
+
const insufficientAutocomplete = !autocompletedValue || !startsWith(autocompletedValue, typedValue)
|
14
14
|
|
15
|
-
return query.length > 0 && this._allowNew && (ignoreAutocomplete ||
|
15
|
+
return query.length > 0 && this._allowNew && (ignoreAutocomplete || insufficientAutocomplete)
|
16
16
|
}
|
17
17
|
|
18
18
|
get _allowNew() {
|
@@ -8,8 +8,8 @@ Combobox.Selection = Base => class extends Base {
|
|
8
8
|
}
|
9
9
|
|
10
10
|
_connectSelection() {
|
11
|
-
if (this.
|
12
|
-
this.
|
11
|
+
if (this.hasPrefilledDisplayValue) {
|
12
|
+
this._actingCombobox.value = this.prefilledDisplayValue
|
13
13
|
}
|
14
14
|
}
|
15
15
|
|
@@ -17,8 +17,6 @@ Combobox.Selection = Base => class extends Base {
|
|
17
17
|
this._resetOptions()
|
18
18
|
|
19
19
|
if (option) {
|
20
|
-
if (this.hasSelectedClass) option.classList.add(this.selectedClass)
|
21
|
-
|
22
20
|
this._markValid()
|
23
21
|
this._maybeAutocompleteWith(option, { force })
|
24
22
|
this._commitSelection(option, { selected: true })
|
@@ -28,21 +26,27 @@ Combobox.Selection = Base => class extends Base {
|
|
28
26
|
}
|
29
27
|
|
30
28
|
_commitSelection(option, { selected }) {
|
31
|
-
option
|
32
|
-
option?.scrollIntoView({ block: "nearest" })
|
29
|
+
this._markSelected(option, { selected })
|
33
30
|
|
34
31
|
if (selected) {
|
35
|
-
this.hiddenFieldTarget.value = option
|
32
|
+
this.hiddenFieldTarget.value = option.dataset.value
|
33
|
+
option.scrollIntoView({ block: "nearest" })
|
36
34
|
} else {
|
37
35
|
this.hiddenFieldTarget.value = null
|
38
36
|
}
|
39
37
|
}
|
40
38
|
|
39
|
+
_markSelected(option, { selected }) {
|
40
|
+
if (this.hasSelectedClass) {
|
41
|
+
option.classList.toggle(this.selectedClass, selected)
|
42
|
+
}
|
43
|
+
|
44
|
+
option.setAttribute("aria-selected", selected)
|
45
|
+
}
|
46
|
+
|
41
47
|
_deselect() {
|
42
48
|
const option = this._selectedOptionElement
|
43
|
-
|
44
|
-
if (this.hasSelectedClass) option?.classList.remove(this.selectedClass)
|
45
|
-
this._commitSelection(option, { selected: false })
|
49
|
+
if (option) this._commitSelection(option, { selected: false })
|
46
50
|
}
|
47
51
|
|
48
52
|
_selectNew(query) {
|
@@ -56,7 +60,17 @@ Combobox.Selection = Base => class extends Base {
|
|
56
60
|
this._select(option, { force: true })
|
57
61
|
}
|
58
62
|
|
59
|
-
|
60
|
-
this.
|
63
|
+
_preselectOption() {
|
64
|
+
if (this._hasValueButNoSelection && this._allOptions.length < 100) {
|
65
|
+
const option = this._allOptions.find(option => {
|
66
|
+
return option.dataset.value === this.hiddenFieldTarget.value
|
67
|
+
})
|
68
|
+
|
69
|
+
if (option) this._markSelected(option, { selected: true })
|
70
|
+
}
|
71
|
+
}
|
72
|
+
|
73
|
+
get _hasValueButNoSelection() {
|
74
|
+
return this.hiddenFieldTarget.value && !this._selectedOptionElement
|
61
75
|
}
|
62
76
|
}
|
@@ -21,8 +21,11 @@ Combobox.Toggle = Base => class extends Base {
|
|
21
21
|
}
|
22
22
|
}
|
23
23
|
|
24
|
-
closeOnClickOutside(
|
24
|
+
closeOnClickOutside(event) {
|
25
|
+
const target = event.target
|
26
|
+
|
25
27
|
if (this.element.contains(target) && !this._isDialogDismisser(target)) return
|
28
|
+
if (this._withinElementBounds(event)) return
|
26
29
|
|
27
30
|
this.close()
|
28
31
|
}
|
@@ -35,6 +38,17 @@ Combobox.Toggle = Base => class extends Base {
|
|
35
38
|
this.close()
|
36
39
|
}
|
37
40
|
|
41
|
+
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
42
|
+
// Hovering over these elements emits a click event for some reason.
|
43
|
+
// These events don't contain any telling information, so we use `_withinElementBounds`
|
44
|
+
// as an alternative to check whether the click is legitimate.
|
45
|
+
_withinElementBounds(event) {
|
46
|
+
const { left, right, top, bottom } = this.element.getBoundingClientRect()
|
47
|
+
const { clientX, clientY } = event
|
48
|
+
|
49
|
+
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
|
50
|
+
}
|
51
|
+
|
38
52
|
_ensureSelection() {
|
39
53
|
if (!this._isValidNewOption(this._actingCombobox.value, { ignoreAutocomplete: true })) {
|
40
54
|
this._select(this._selectedOptionElement, { force: true })
|
@@ -49,7 +63,9 @@ Combobox.Toggle = Base => class extends Base {
|
|
49
63
|
return target.closest("dialog") && target.role != "combobox"
|
50
64
|
}
|
51
65
|
|
52
|
-
|
66
|
+
_expand() {
|
67
|
+
if (this._preselectOnExpansion) this._preselectOption()
|
68
|
+
|
53
69
|
if (this._autocompletesList && this._smallViewport) {
|
54
70
|
this._openInDialog()
|
55
71
|
} else {
|
@@ -101,4 +117,8 @@ Combobox.Toggle = Base => class extends Base {
|
|
101
117
|
get _isOpen() {
|
102
118
|
return this.expandedValue
|
103
119
|
}
|
120
|
+
|
121
|
+
get _preselectOnExpansion() {
|
122
|
+
return !this._isAsync // async comboboxes preselect based on callbacks
|
123
|
+
}
|
104
124
|
}
|
@@ -15,10 +15,9 @@ module HotwireCombobox
|
|
15
15
|
end
|
16
16
|
hw_alias :hw_combobox_style_tag
|
17
17
|
|
18
|
-
def hw_combobox_tag(
|
19
|
-
options =
|
20
|
-
|
21
|
-
component = HotwireCombobox::Component.new self, *args, options: options, async_src: src, **kwargs
|
18
|
+
def hw_combobox_tag(name, options_or_src = [], render_in: {}, **kwargs)
|
19
|
+
options, src = hw_extract_options_and_src(options_or_src, render_in)
|
20
|
+
component = HotwireCombobox::Component.new self, name, options: options, async_src: src, **kwargs
|
22
21
|
|
23
22
|
render "hotwire_combobox/combobox", component: component
|
24
23
|
end
|
@@ -39,20 +38,32 @@ module HotwireCombobox
|
|
39
38
|
end
|
40
39
|
hw_alias :hw_combobox_options
|
41
40
|
|
42
|
-
def hw_paginated_combobox_options(options, for_id:, src
|
41
|
+
def hw_paginated_combobox_options(options, for_id:, src: request.path, next_page: nil, render_in: {}, **methods)
|
43
42
|
this_page = render("hotwire_combobox/paginated_options", for_id: for_id, options: hw_combobox_options(options, render_in: render_in, **methods), format: :turbo_stream)
|
44
|
-
next_page = render("hotwire_combobox/next_page", src: src, next_page: next_page, format: :turbo_stream)
|
43
|
+
next_page = render("hotwire_combobox/next_page", for_id: for_id, src: src, next_page: next_page, format: :turbo_stream)
|
45
44
|
|
46
45
|
safe_join [ this_page, next_page ]
|
47
46
|
end
|
48
47
|
hw_alias :hw_paginated_combobox_options
|
49
48
|
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
49
|
+
protected # library use only
|
50
|
+
def hw_listbox_options_id(id)
|
51
|
+
"#{id}-hw-listbox__options"
|
52
|
+
end
|
53
|
+
|
54
|
+
def hw_pagination_frame_id(id)
|
55
|
+
"#{id}__hw_combobox_pagination"
|
56
|
+
end
|
54
57
|
|
55
58
|
private
|
59
|
+
def hw_extract_options_and_src(options_or_src, render_in)
|
60
|
+
if options_or_src.is_a? String
|
61
|
+
[ [], hw_uri_with_params(options_or_src, format: :turbo_stream) ]
|
62
|
+
else
|
63
|
+
[ hw_combobox_options(options_or_src, render_in: render_in), nil ]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
56
67
|
def hw_uri_with_params(url_or_path, **params)
|
57
68
|
URI.parse(url_or_path).tap do |url_or_path|
|
58
69
|
query = URI.decode_www_form(url_or_path.query || "").to_h.merge(params)
|
@@ -1,23 +1,29 @@
|
|
1
1
|
class HotwireCombobox::Component
|
2
2
|
attr_reader :async_src, :options, :dialog_label
|
3
3
|
|
4
|
-
def initialize
|
5
|
-
view, name,
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
4
|
+
def initialize \
|
5
|
+
view, name,
|
6
|
+
association_name: nil,
|
7
|
+
async_src: nil,
|
8
|
+
autocomplete: :both,
|
9
|
+
data: {},
|
10
|
+
dialog_label: nil,
|
11
|
+
form: nil,
|
12
|
+
id: nil,
|
13
|
+
input: {},
|
14
|
+
name_when_new: nil,
|
15
|
+
open: false,
|
16
|
+
options: [],
|
17
|
+
small_width: "640px",
|
18
|
+
value: nil,
|
19
|
+
**rest
|
17
20
|
@view, @autocomplete, @id, @name, @value, @form, @async_src,
|
18
21
|
@name_when_new, @open, @data, @small_width, @options, @dialog_label =
|
19
22
|
view, autocomplete, id, name, value, form, async_src,
|
20
23
|
name_when_new, open, data, small_width, options, dialog_label
|
24
|
+
|
25
|
+
@combobox_attrs = input.reverse_merge(rest).with_indifferent_access
|
26
|
+
@association_name = association_name || infer_association_name
|
21
27
|
end
|
22
28
|
|
23
29
|
def fieldset_attrs
|
@@ -125,15 +131,23 @@ class HotwireCombobox::Component
|
|
125
131
|
end
|
126
132
|
|
127
133
|
def pagination_attrs
|
128
|
-
{ src: async_src }
|
134
|
+
{ for_id: hidden_field_id, src: async_src }
|
129
135
|
end
|
130
136
|
|
131
137
|
private
|
132
138
|
attr_reader :view, :autocomplete, :id, :name, :value, :form,
|
133
|
-
:name_when_new, :open, :data, :combobox_attrs, :small_width
|
139
|
+
:name_when_new, :open, :data, :combobox_attrs, :small_width,
|
140
|
+
:association_name
|
141
|
+
|
142
|
+
def infer_association_name
|
143
|
+
if name.to_s.include?("_id")
|
144
|
+
name.to_s.sub(/_id\z/, "")
|
145
|
+
end
|
146
|
+
end
|
134
147
|
|
135
148
|
def fieldset_data
|
136
149
|
data.reverse_merge \
|
150
|
+
pagination_id: hidden_field_id,
|
137
151
|
controller: view.token_list("hw-combobox", data[:controller]),
|
138
152
|
hw_combobox_expanded_value: open,
|
139
153
|
hw_combobox_name_when_new_value: name_when_new,
|
@@ -141,11 +155,30 @@ class HotwireCombobox::Component
|
|
141
155
|
hw_combobox_autocomplete_value: autocomplete,
|
142
156
|
hw_combobox_small_viewport_max_width_value: small_width,
|
143
157
|
hw_combobox_async_src_value: async_src,
|
158
|
+
hw_combobox_prefilled_display_value: prefilled_display,
|
144
159
|
hw_combobox_filterable_attribute_value: "data-filterable-as",
|
145
160
|
hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
|
146
161
|
hw_combobox_selected_class: "hw-combobox__option--selected"
|
147
162
|
end
|
148
163
|
|
164
|
+
def prefilled_display
|
165
|
+
if async_src && associated_object
|
166
|
+
associated_object.to_combobox_display
|
167
|
+
elsif value
|
168
|
+
options.find { |option| option.value == value }&.content
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
def associated_object
|
173
|
+
@associated_object ||= if association_exists?
|
174
|
+
form.object.public_send association_name
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def association_exists?
|
179
|
+
form&.object&.class&.reflect_on_association(association_name).present?
|
180
|
+
end
|
181
|
+
|
149
182
|
|
150
183
|
def hidden_field_id
|
151
184
|
id || form&.field_id(name)
|
@@ -180,7 +213,8 @@ class HotwireCombobox::Component
|
|
180
213
|
keydown->hw-combobox#navigate
|
181
214
|
click@window->hw-combobox#closeOnClickOutside
|
182
215
|
focusin@window->hw-combobox#closeOnFocusOutside".squish,
|
183
|
-
hw_combobox_target: "combobox"
|
216
|
+
hw_combobox_target: "combobox",
|
217
|
+
pagination_id: hidden_field_id
|
184
218
|
end
|
185
219
|
|
186
220
|
def input_aria
|
@@ -3,8 +3,16 @@ class HotwireCombobox::Listbox::Option
|
|
3
3
|
@option = option.is_a?(Hash) ? Data.new(**option) : option
|
4
4
|
end
|
5
5
|
|
6
|
-
def render_in(
|
7
|
-
|
6
|
+
def render_in(view)
|
7
|
+
view.tag.li content, **options
|
8
|
+
end
|
9
|
+
|
10
|
+
def value
|
11
|
+
option.try(:value) || option.id
|
12
|
+
end
|
13
|
+
|
14
|
+
def content
|
15
|
+
option.try(:content) || option.try(:display)
|
8
16
|
end
|
9
17
|
|
10
18
|
private
|
@@ -12,10 +20,6 @@ class HotwireCombobox::Listbox::Option
|
|
12
20
|
|
13
21
|
attr_reader :option
|
14
22
|
|
15
|
-
def content
|
16
|
-
option.try(:content) || option.try(:display)
|
17
|
-
end
|
18
|
-
|
19
23
|
def options
|
20
24
|
{
|
21
25
|
id: id,
|
@@ -45,8 +49,4 @@ class HotwireCombobox::Listbox::Option
|
|
45
49
|
def autocompletable_as
|
46
50
|
option.try(:autocompletable_as) || option.try(:display)
|
47
51
|
end
|
48
|
-
|
49
|
-
def value
|
50
|
-
option.try(:value) || option.id
|
51
|
-
end
|
52
52
|
end
|
@@ -1,5 +1,5 @@
|
|
1
|
-
<%# locals: (next_page:, src:) -%>
|
1
|
+
<%# locals: (for_id:, next_page:, src:) -%>
|
2
2
|
|
3
|
-
<%= turbo_stream.replace
|
4
|
-
<%= render "hotwire_combobox/pagination", src: hw_combobox_next_page_uri(src, next_page) %>
|
3
|
+
<%= turbo_stream.replace hw_pagination_frame_id(for_id) do %>
|
4
|
+
<%= render "hotwire_combobox/pagination", for_id: for_id, src: hw_combobox_next_page_uri(src, next_page) %>
|
5
5
|
<% end %>
|
@@ -1,3 +1,4 @@
|
|
1
|
-
<%# locals: (src:) -%>
|
1
|
+
<%# locals: (for_id:, src:) -%>
|
2
2
|
|
3
|
-
<%= turbo_frame_tag
|
3
|
+
<%= turbo_frame_tag hw_pagination_frame_id(for_id), src: src, loading: :lazy,
|
4
|
+
data: { hw_combobox_target: "paginationFrame" } %>
|
@@ -8,8 +8,8 @@ module HotwireCombobox
|
|
8
8
|
|
9
9
|
unless HotwireCombobox.bypass_convenience_methods?
|
10
10
|
module FormBuilderExtensions
|
11
|
-
def combobox(*args, **kwargs
|
12
|
-
@template.hw_combobox_tag *args, **kwargs.merge(form: self)
|
11
|
+
def combobox(*args, **kwargs)
|
12
|
+
@template.hw_combobox_tag *args, **kwargs.merge(form: self)
|
13
13
|
end
|
14
14
|
end
|
15
15
|
|
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.14
|
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-
|
11
|
+
date: 2024-02-02 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|