hotwire_combobox 0.1.13 → 0.1.14
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/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
|