hotwire_combobox 0.1.43 → 0.2.1
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 +2 -1
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +29 -3
- data/app/assets/javascripts/hotwire_combobox.esm.js +442 -104
- data/app/assets/javascripts/hotwire_combobox.umd.js +442 -104
- data/app/assets/javascripts/hw_combobox/helpers.js +16 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/announcements.js +7 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -11
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +20 -12
- data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +74 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +160 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +15 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/options.js +19 -7
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +50 -49
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +33 -16
- data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox.js +3 -0
- data/app/assets/stylesheets/hotwire_combobox.css +90 -19
- data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
- data/app/presenters/hotwire_combobox/component.rb +106 -32
- data/app/presenters/hotwire_combobox/listbox/group.rb +47 -0
- data/app/presenters/hotwire_combobox/listbox/item/collection.rb +14 -0
- data/app/presenters/hotwire_combobox/listbox/item.rb +111 -0
- data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
- data/app/views/hotwire_combobox/_component.html.erb +1 -0
- data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
- data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
- data/lib/hotwire_combobox/helper.rb +112 -91
- data/lib/hotwire_combobox/version.rb +1 -1
- metadata +11 -3
@@ -6,22 +6,40 @@ Combobox.Toggle = Base => class extends Base {
|
|
6
6
|
this.expandedValue = true
|
7
7
|
}
|
8
8
|
|
9
|
-
|
9
|
+
openByFocusing() {
|
10
|
+
this._actingCombobox.focus()
|
11
|
+
}
|
12
|
+
|
13
|
+
close(inputType) {
|
10
14
|
if (this._isOpen) {
|
15
|
+
const shouldReopen = this._isMultiselect &&
|
16
|
+
this._isSync &&
|
17
|
+
!this._isSmallViewport &&
|
18
|
+
inputType != "hw:clickOutside" &&
|
19
|
+
inputType != "hw:focusOutside"
|
20
|
+
|
11
21
|
this._lockInSelection()
|
12
22
|
this._clearInvalidQuery()
|
13
23
|
|
14
24
|
this.expandedValue = false
|
15
25
|
|
16
|
-
this.
|
26
|
+
this._dispatchSelectionEvent()
|
27
|
+
|
28
|
+
if (inputType != "hw:keyHandler:escape") {
|
29
|
+
this._createChip(shouldReopen)
|
30
|
+
}
|
31
|
+
|
32
|
+
if (this._isSingleSelect && this._selectedOptionElement) {
|
33
|
+
this._announceToScreenReader(this._displayForOptionElement(this._selectedOptionElement), "selected")
|
34
|
+
}
|
17
35
|
}
|
18
36
|
}
|
19
37
|
|
20
38
|
toggle() {
|
21
39
|
if (this.expandedValue) {
|
22
|
-
this.
|
40
|
+
this._closeAndBlur("hw:toggle")
|
23
41
|
} else {
|
24
|
-
this.
|
42
|
+
this.openByFocusing()
|
25
43
|
}
|
26
44
|
}
|
27
45
|
|
@@ -32,14 +50,14 @@ Combobox.Toggle = Base => class extends Base {
|
|
32
50
|
if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
|
33
51
|
if (this._withinElementBounds(event)) return
|
34
52
|
|
35
|
-
this.
|
53
|
+
this._closeAndBlur("hw:clickOutside")
|
36
54
|
}
|
37
55
|
|
38
56
|
closeOnFocusOutside({ target }) {
|
39
57
|
if (!this._isOpen) return
|
40
58
|
if (this.element.contains(target)) return
|
41
59
|
|
42
|
-
this.
|
60
|
+
this._closeAndBlur("hw:focusOutside")
|
43
61
|
}
|
44
62
|
|
45
63
|
clearOrToggleOnHandleClick() {
|
@@ -51,6 +69,11 @@ Combobox.Toggle = Base => class extends Base {
|
|
51
69
|
}
|
52
70
|
}
|
53
71
|
|
72
|
+
_closeAndBlur(inputType) {
|
73
|
+
this.close(inputType)
|
74
|
+
this._actingCombobox.blur()
|
75
|
+
}
|
76
|
+
|
54
77
|
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
55
78
|
// Hovering over these elements emits a click event for some reason.
|
56
79
|
// These events don't contain any telling information, so we use `_withinElementBounds`
|
@@ -62,18 +85,16 @@ Combobox.Toggle = Base => class extends Base {
|
|
62
85
|
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
|
63
86
|
}
|
64
87
|
|
65
|
-
_openByFocusing() {
|
66
|
-
this._actingCombobox.focus()
|
67
|
-
}
|
68
|
-
|
69
88
|
_isDialogDismisser(target) {
|
70
89
|
return target.closest("dialog") && target.role != "combobox"
|
71
90
|
}
|
72
91
|
|
73
92
|
_expand() {
|
74
|
-
if (this.
|
93
|
+
if (this._isSync) {
|
94
|
+
this._preselectSingle()
|
95
|
+
}
|
75
96
|
|
76
|
-
if (this._autocompletesList && this.
|
97
|
+
if (this._autocompletesList && this._isSmallViewport) {
|
77
98
|
this._openInDialog()
|
78
99
|
} else {
|
79
100
|
this._openInline()
|
@@ -134,8 +155,4 @@ Combobox.Toggle = Base => class extends Base {
|
|
134
155
|
get _isOpen() {
|
135
156
|
return this.expandedValue
|
136
157
|
}
|
137
|
-
|
138
|
-
get _preselectOnExpansion() {
|
139
|
-
return !this._isAsync // async comboboxes preselect based on callbacks
|
140
|
-
}
|
141
158
|
}
|
@@ -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 &&
|
37
|
+
const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue
|
38
38
|
return isRequiredAndEmpty
|
39
39
|
}
|
40
40
|
}
|
@@ -1,11 +1,14 @@
|
|
1
1
|
import Combobox from "hw_combobox/models/combobox/base"
|
2
2
|
|
3
3
|
import "hw_combobox/models/combobox/actors"
|
4
|
+
import "hw_combobox/models/combobox/announcements"
|
4
5
|
import "hw_combobox/models/combobox/async_loading"
|
5
6
|
import "hw_combobox/models/combobox/autocomplete"
|
6
7
|
import "hw_combobox/models/combobox/dialog"
|
7
8
|
import "hw_combobox/models/combobox/events"
|
8
9
|
import "hw_combobox/models/combobox/filtering"
|
10
|
+
import "hw_combobox/models/combobox/form_field"
|
11
|
+
import "hw_combobox/models/combobox/multiselect"
|
9
12
|
import "hw_combobox/models/combobox/navigation"
|
10
13
|
import "hw_combobox/models/combobox/new_options"
|
11
14
|
import "hw_combobox/models/combobox/options"
|
@@ -1,6 +1,8 @@
|
|
1
1
|
:root {
|
2
2
|
--hw-active-bg-color: #F3F4F6;
|
3
3
|
--hw-border-color: #D1D5DB;
|
4
|
+
--hw-group-color: #57595C;
|
5
|
+
--hw-group-bg-color: #FFFFFF;
|
4
6
|
--hw-invalid-color: #EF4444;
|
5
7
|
--hw-dialog-label-color: #1D1D1D;
|
6
8
|
--hw-focus-color: #2563EB;
|
@@ -10,6 +12,9 @@
|
|
10
12
|
--hw-border-width--slim: 1px;
|
11
13
|
--hw-border-width--thick: 2px;
|
12
14
|
|
15
|
+
--hw-combobox-width: 10rem;
|
16
|
+
--hw-combobox-width--multiple: 30rem;
|
17
|
+
|
13
18
|
--hw-dialog-font-size: 1.25rem;
|
14
19
|
--hw-dialog-input-height: 2.5rem;
|
15
20
|
--hw-dialog-label-alignment: center;
|
@@ -24,20 +29,21 @@
|
|
24
29
|
--hw-handle-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
|
25
30
|
--hw-handle-image--queried: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 18 18 6M6 6l12 12'/%3E%3C/svg%3E");
|
26
31
|
--hw-handle-offset-right: 0.375rem;
|
27
|
-
--hw-handle-width: 1.
|
28
|
-
--hw-handle-width--queried:
|
29
|
-
|
30
|
-
--hw-combobox-width: 10rem;
|
32
|
+
--hw-handle-width: 1.5rem;
|
33
|
+
--hw-handle-width--queried: 1rem;
|
31
34
|
|
32
35
|
--hw-line-height: 1.5rem;
|
36
|
+
--hw-line-height--multiple: 2.125rem;
|
33
37
|
|
34
38
|
--hw-listbox-height: calc(var(--hw-line-height) * 10);
|
35
|
-
--hw-listbox-offset-top: calc(var(--hw-line-height) * 1.625);
|
36
39
|
--hw-listbox-z-index: 10;
|
37
40
|
|
41
|
+
--hw-padding--slimmer: 0.25rem;
|
38
42
|
--hw-padding--slim: 0.375rem;
|
39
43
|
--hw-padding--thick: 0.75rem;
|
40
44
|
|
45
|
+
--hw-selection-chip-font-size: 0.875rem;
|
46
|
+
|
41
47
|
--hw-visual-viewport-height: 100vh;
|
42
48
|
}
|
43
49
|
|
@@ -57,30 +63,38 @@
|
|
57
63
|
}
|
58
64
|
|
59
65
|
.hw-combobox__main__wrapper {
|
66
|
+
border: var(--hw-border-width--slim) solid var(--hw-border-color);
|
67
|
+
border-radius: var(--hw-border-radius);
|
68
|
+
padding: var(--hw-padding--slim) calc(var(--hw-handle-width) + var(--hw-padding--slimmer)) var(--hw-padding--slim) var(--hw-padding--thick);
|
60
69
|
position: relative;
|
61
|
-
width:
|
70
|
+
width: var(--hw-combobox-width);
|
71
|
+
|
72
|
+
&:focus-within {
|
73
|
+
box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
|
74
|
+
}
|
75
|
+
|
76
|
+
&:has(.hw-combobox__input--invalid) {
|
77
|
+
box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-invalid-color);
|
78
|
+
}
|
62
79
|
}
|
63
80
|
|
64
81
|
.hw-combobox__input {
|
65
|
-
border:
|
66
|
-
border-radius: var(--hw-border-radius);
|
82
|
+
border: none;
|
67
83
|
font-size: inherit;
|
68
84
|
line-height: var(--hw-line-height);
|
69
|
-
|
70
|
-
|
71
|
-
width: var(--hw-combobox-width);
|
85
|
+
min-width: 0;
|
86
|
+
padding: 0;
|
72
87
|
text-overflow: ellipsis;
|
88
|
+
width: 100%;
|
73
89
|
}
|
74
90
|
|
75
|
-
.hw-combobox__input:focus
|
76
|
-
|
91
|
+
.hw-combobox__input:focus,
|
92
|
+
.hw-combobox__input:focus-visible,
|
93
|
+
.hw-combobox__input:focus-within {
|
94
|
+
box-shadow: none;
|
77
95
|
outline: none;
|
78
96
|
}
|
79
97
|
|
80
|
-
.hw-combobox__input--invalid {
|
81
|
-
border: var(--hw-border-width--slim) solid var(--hw-invalid-color);
|
82
|
-
}
|
83
|
-
|
84
98
|
.hw-combobox__handle {
|
85
99
|
height: 100%;
|
86
100
|
position: absolute;
|
@@ -105,7 +119,6 @@
|
|
105
119
|
.hw-combobox__input[data-queried] + .hw-combobox__handle::before {
|
106
120
|
background-image: var(--hw-handle-image--queried);
|
107
121
|
background-size: var(--hw-handle-width--queried);
|
108
|
-
cursor: pointer;
|
109
122
|
}
|
110
123
|
|
111
124
|
.hw-combobox__listbox {
|
@@ -121,7 +134,7 @@
|
|
121
134
|
overflow: auto;
|
122
135
|
padding: 0;
|
123
136
|
position: absolute;
|
124
|
-
top:
|
137
|
+
top: calc(100% + 0.2rem);
|
125
138
|
width: 100%;
|
126
139
|
z-index: var(--hw-listbox-z-index);
|
127
140
|
|
@@ -130,6 +143,21 @@
|
|
130
143
|
}
|
131
144
|
}
|
132
145
|
|
146
|
+
.hw-combobox__group {
|
147
|
+
display: none;
|
148
|
+
padding: 0;
|
149
|
+
}
|
150
|
+
|
151
|
+
.hw-combobox__group__label {
|
152
|
+
background-color: var(--hw-group-bg-color);
|
153
|
+
color: var(--hw-group-color);
|
154
|
+
padding: var(--hw-padding--slim);
|
155
|
+
}
|
156
|
+
|
157
|
+
.hw-combobox__group:has(.hw-combobox__option:not([hidden])) {
|
158
|
+
display: block;
|
159
|
+
}
|
160
|
+
|
133
161
|
.hw-combobox__option {
|
134
162
|
background-color: var(--hw-option-bg-color);
|
135
163
|
padding: var(--hw-padding--slim) var(--hw-padding--thick);
|
@@ -144,6 +172,7 @@
|
|
144
172
|
}
|
145
173
|
|
146
174
|
.hw-combobox__option:hover,
|
175
|
+
.hw-combobox__option--navigated,
|
147
176
|
.hw-combobox__option--selected {
|
148
177
|
background-color: var(--hw-active-bg-color);
|
149
178
|
}
|
@@ -217,3 +246,45 @@
|
|
217
246
|
padding: var(--hw-padding--thick);
|
218
247
|
}
|
219
248
|
}
|
249
|
+
|
250
|
+
.hw-combobox__chip {
|
251
|
+
align-items: center;
|
252
|
+
border: var(--hw-border-width--slim) solid var(--hw-border-color);
|
253
|
+
border-radius: var(--hw-border-radius);
|
254
|
+
display: flex;
|
255
|
+
font-size: var(--hw-selection-chip-font-size);
|
256
|
+
line-height: var(--hw-line-height);
|
257
|
+
padding: var(--hw-padding--slimmer);
|
258
|
+
padding-left: var(--hw-padding--slim);
|
259
|
+
}
|
260
|
+
|
261
|
+
.hw-combobox__chip__remover {
|
262
|
+
background-image: var(--hw-handle-image--queried);
|
263
|
+
background-size: var(--hw-handle-width--queried);
|
264
|
+
background-repeat: no-repeat;
|
265
|
+
margin-left: var(--hw-padding--slimmer);
|
266
|
+
min-height: var(--hw-handle-width--queried);
|
267
|
+
min-width: var(--hw-handle-width--queried);
|
268
|
+
}
|
269
|
+
|
270
|
+
.hw-combobox--multiple {
|
271
|
+
.hw-combobox__main__wrapper {
|
272
|
+
align-items: center;
|
273
|
+
display: flex;
|
274
|
+
flex-wrap: wrap;
|
275
|
+
gap: var(--hw-padding--slimmer);
|
276
|
+
width: var(--hw-combobox-width--multiple);
|
277
|
+
|
278
|
+
&:has([data-hw-combobox-chip]) .hw-combobox__input::placeholder {
|
279
|
+
color: transparent;
|
280
|
+
}
|
281
|
+
}
|
282
|
+
|
283
|
+
.hw-combobox__input {
|
284
|
+
min-width: calc(var(--hw-combobox-width) / 2);
|
285
|
+
flex-grow: 1;
|
286
|
+
line-height: var(--hw-line-height--multiple);
|
287
|
+
max-width: 100%;
|
288
|
+
width: 1rem;
|
289
|
+
}
|
290
|
+
}
|
@@ -44,7 +44,15 @@ module HotwireCombobox::Component::Customizable
|
|
44
44
|
|
45
45
|
def apply_customizations_to(element, base: {})
|
46
46
|
custom = custom_attrs[element]
|
47
|
-
|
47
|
+
|
48
|
+
coalesce = ->(key, value) do
|
49
|
+
if value.is_a?(String) || value.is_a?(Array)
|
50
|
+
view.token_list(value, custom.delete(key))
|
51
|
+
else
|
52
|
+
value
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
48
56
|
default = base.map { |k, v| [ k, coalesce.(k, v) ] }.to_h
|
49
57
|
|
50
58
|
custom.deep_merge default
|
@@ -2,33 +2,39 @@ require "securerandom"
|
|
2
2
|
|
3
3
|
class HotwireCombobox::Component
|
4
4
|
include Customizable
|
5
|
+
include ActiveModel::Validations
|
5
6
|
|
6
7
|
attr_reader :options, :label
|
7
8
|
|
9
|
+
validate :name_when_new_on_multiselect_must_match_original_name
|
10
|
+
|
8
11
|
def initialize \
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
12
|
+
view, name,
|
13
|
+
association_name: nil,
|
14
|
+
async_src: nil,
|
15
|
+
autocomplete: :both,
|
16
|
+
data: {},
|
17
|
+
dialog_label: nil,
|
18
|
+
form: nil,
|
19
|
+
id: nil,
|
20
|
+
input: {},
|
21
|
+
label: nil,
|
22
|
+
mobile_at: "640px",
|
23
|
+
multiselect_chip_src: nil,
|
24
|
+
name_when_new: nil,
|
25
|
+
open: false,
|
26
|
+
options: [],
|
27
|
+
value: nil,
|
28
|
+
**rest
|
25
29
|
@view, @autocomplete, @id, @name, @value, @form, @async_src, @label,
|
26
|
-
@name_when_new, @open, @data, @mobile_at, @options, @dialog_label =
|
30
|
+
@name_when_new, @open, @data, @mobile_at, @multiselect_chip_src, @options, @dialog_label =
|
27
31
|
view, autocomplete, id, name.to_s, value, form, async_src, label,
|
28
|
-
name_when_new, open, data, mobile_at, options, dialog_label
|
32
|
+
name_when_new, open, data, mobile_at, multiselect_chip_src, options, dialog_label
|
29
33
|
|
30
34
|
@combobox_attrs = input.reverse_merge(rest).deep_symbolize_keys
|
31
35
|
@association_name = association_name || infer_association_name
|
36
|
+
|
37
|
+
validate!
|
32
38
|
end
|
33
39
|
|
34
40
|
def render_in(view_context, &block)
|
@@ -39,7 +45,7 @@ class HotwireCombobox::Component
|
|
39
45
|
|
40
46
|
def fieldset_attrs
|
41
47
|
apply_customizations_to :fieldset, base: {
|
42
|
-
class: "hw-combobox",
|
48
|
+
class: [ "hw-combobox", { "hw-combobox--multiple": multiselect? } ],
|
43
49
|
data: fieldset_data
|
44
50
|
}
|
45
51
|
end
|
@@ -72,6 +78,23 @@ class HotwireCombobox::Component
|
|
72
78
|
end
|
73
79
|
|
74
80
|
|
81
|
+
def announcer_attrs
|
82
|
+
{
|
83
|
+
style: "
|
84
|
+
position: absolute;
|
85
|
+
width: 1px;
|
86
|
+
height: 1px;
|
87
|
+
margin: -1px;
|
88
|
+
padding: 0;
|
89
|
+
overflow: hidden;
|
90
|
+
clip: rect(0, 0, 0, 0);
|
91
|
+
border: 0;".squish,
|
92
|
+
aria: announcer_aria,
|
93
|
+
data: announcer_data
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
|
75
98
|
def input_attrs
|
76
99
|
nested_attrs = %i[ data aria ]
|
77
100
|
|
@@ -103,7 +126,8 @@ class HotwireCombobox::Component
|
|
103
126
|
role: :listbox,
|
104
127
|
class: "hw-combobox__listbox",
|
105
128
|
hidden: "",
|
106
|
-
data: listbox_data
|
129
|
+
data: listbox_data,
|
130
|
+
aria: listbox_aria
|
107
131
|
}
|
108
132
|
end
|
109
133
|
|
@@ -150,7 +174,8 @@ class HotwireCombobox::Component
|
|
150
174
|
id: dialog_listbox_id,
|
151
175
|
class: "hw-combobox__dialog__listbox",
|
152
176
|
role: :listbox,
|
153
|
-
data: dialog_listbox_data
|
177
|
+
data: dialog_listbox_data,
|
178
|
+
aria: dialog_listbox_aria
|
154
179
|
}
|
155
180
|
end
|
156
181
|
|
@@ -173,10 +198,23 @@ class HotwireCombobox::Component
|
|
173
198
|
private
|
174
199
|
attr_reader :view, :autocomplete, :id, :name, :value, :form,
|
175
200
|
:name_when_new, :open, :data, :combobox_attrs, :mobile_at,
|
176
|
-
:association_name
|
201
|
+
:association_name, :multiselect_chip_src
|
202
|
+
|
203
|
+
def name_when_new_on_multiselect_must_match_original_name
|
204
|
+
return unless multiselect? && name_when_new.present?
|
205
|
+
|
206
|
+
unless name_when_new.to_s == name
|
207
|
+
errors.add :name_when_new, :must_match_original_name,
|
208
|
+
message: "must match the regular name ('#{name}', in this case) on multiselect comboboxes."
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
def multiselect?
|
213
|
+
multiselect_chip_src.present?
|
214
|
+
end
|
177
215
|
|
178
216
|
def infer_association_name
|
179
|
-
if name.
|
217
|
+
if name.end_with?("_id")
|
180
218
|
name.sub(/_id\z/, "")
|
181
219
|
end
|
182
220
|
end
|
@@ -192,6 +230,7 @@ class HotwireCombobox::Component
|
|
192
230
|
hw_combobox_small_viewport_max_width_value: mobile_at,
|
193
231
|
hw_combobox_async_src_value: async_src,
|
194
232
|
hw_combobox_prefilled_display_value: prefilled_display,
|
233
|
+
hw_combobox_selection_chip_src_value: multiselect_chip_src,
|
195
234
|
hw_combobox_filterable_attribute_value: "data-filterable-as",
|
196
235
|
hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
|
197
236
|
hw_combobox_selected_class: "hw-combobox__option--selected",
|
@@ -199,21 +238,29 @@ class HotwireCombobox::Component
|
|
199
238
|
end
|
200
239
|
|
201
240
|
def prefilled_display
|
241
|
+
return if multiselect? || !hidden_field_value
|
242
|
+
|
202
243
|
if async_src && associated_object
|
203
244
|
associated_object.to_combobox_display
|
204
|
-
elsif
|
205
|
-
|
245
|
+
elsif async_src && form_object&.respond_to?(name)
|
246
|
+
form_object.public_send name
|
247
|
+
else
|
248
|
+
options.find_by_value(hidden_field_value)&.autocompletable_as
|
206
249
|
end
|
207
250
|
end
|
208
251
|
|
209
252
|
def associated_object
|
210
253
|
@associated_object ||= if association_exists?
|
211
|
-
|
254
|
+
form_object&.public_send association_name
|
212
255
|
end
|
213
256
|
end
|
214
257
|
|
215
258
|
def association_exists?
|
216
|
-
|
259
|
+
form_object&.class&.reflect_on_association(association_name).present?
|
260
|
+
end
|
261
|
+
|
262
|
+
def form_object
|
263
|
+
form&.object
|
217
264
|
end
|
218
265
|
|
219
266
|
def async_src
|
@@ -227,7 +274,22 @@ class HotwireCombobox::Component
|
|
227
274
|
|
228
275
|
|
229
276
|
def main_wrapper_data
|
230
|
-
{
|
277
|
+
{
|
278
|
+
action: ("click->hw-combobox#openByFocusing:self" if multiselect?),
|
279
|
+
hw_combobox_target: "mainWrapper"
|
280
|
+
}
|
281
|
+
end
|
282
|
+
|
283
|
+
|
284
|
+
def announcer_aria
|
285
|
+
{
|
286
|
+
live: :polite,
|
287
|
+
atomic: true
|
288
|
+
}
|
289
|
+
end
|
290
|
+
|
291
|
+
def announcer_data
|
292
|
+
{ hw_combobox_target: "announcer" }
|
231
293
|
end
|
232
294
|
|
233
295
|
|
@@ -246,10 +308,12 @@ class HotwireCombobox::Component
|
|
246
308
|
def hidden_field_value
|
247
309
|
return value if value
|
248
310
|
|
249
|
-
if
|
250
|
-
|
311
|
+
if form_object&.defined_enums&.try :[], name
|
312
|
+
form_object.public_send "#{name}_before_type_cast"
|
251
313
|
else
|
252
|
-
|
314
|
+
form_object&.try(name).then do |value|
|
315
|
+
value.respond_to?(:map) ? value.join(",") : value
|
316
|
+
end
|
253
317
|
end
|
254
318
|
end
|
255
319
|
|
@@ -270,7 +334,9 @@ class HotwireCombobox::Component
|
|
270
334
|
keydown->hw-combobox#navigate
|
271
335
|
click@window->hw-combobox#closeOnClickOutside
|
272
336
|
focusin@window->hw-combobox#closeOnFocusOutside
|
273
|
-
turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog
|
337
|
+
turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog
|
338
|
+
turbo:before-cache@document->hw-combobox#hideChipsForCache
|
339
|
+
turbo:morph-element->hw-combobox#idempotentConnect".squish,
|
274
340
|
hw_combobox_target: "combobox",
|
275
341
|
async_id: canonical_id
|
276
342
|
end
|
@@ -301,6 +367,10 @@ class HotwireCombobox::Component
|
|
301
367
|
{ hw_combobox_target: "listbox" }
|
302
368
|
end
|
303
369
|
|
370
|
+
def listbox_aria
|
371
|
+
{ multiselectable: multiselect? }
|
372
|
+
end
|
373
|
+
|
304
374
|
|
305
375
|
def dialog_data
|
306
376
|
{
|
@@ -340,6 +410,10 @@ class HotwireCombobox::Component
|
|
340
410
|
{ hw_combobox_target: "dialogListbox" }
|
341
411
|
end
|
342
412
|
|
413
|
+
def dialog_listbox_aria
|
414
|
+
{ multiselectable: multiselect? }
|
415
|
+
end
|
416
|
+
|
343
417
|
def dialog_focus_trap_data
|
344
418
|
{ hw_combobox_target: "dialogFocusTrap" }
|
345
419
|
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require "securerandom"
|
2
|
+
|
3
|
+
class HotwireCombobox::Listbox::Group
|
4
|
+
attr_reader :options
|
5
|
+
|
6
|
+
def initialize(name, options:)
|
7
|
+
@name = name
|
8
|
+
@options = options
|
9
|
+
end
|
10
|
+
|
11
|
+
def render_in(view)
|
12
|
+
view.tag.ul **group_attrs do
|
13
|
+
view.concat view.tag.li(name, **label_attrs)
|
14
|
+
|
15
|
+
options.map do |option|
|
16
|
+
view.concat view.render(option)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
attr_reader :name
|
23
|
+
|
24
|
+
def id
|
25
|
+
@id ||= SecureRandom.uuid
|
26
|
+
end
|
27
|
+
|
28
|
+
def group_attrs
|
29
|
+
{
|
30
|
+
class: "hw-combobox__group",
|
31
|
+
role: :group,
|
32
|
+
aria: group_aria
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
def group_aria
|
37
|
+
{ labelledby: id }
|
38
|
+
end
|
39
|
+
|
40
|
+
def label_attrs
|
41
|
+
{
|
42
|
+
id: id,
|
43
|
+
class: "hw-combobox__group__label",
|
44
|
+
role: :presentation
|
45
|
+
}
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
class HotwireCombobox::Listbox::Item::Collection < Array
|
2
|
+
def find_by_value(value)
|
3
|
+
if grouped?
|
4
|
+
flat_map { |item| item.options }.find { |option| option.value == value }
|
5
|
+
else
|
6
|
+
find { |option| option.value == value }
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
def grouped?
|
12
|
+
first.is_a? HotwireCombobox::Listbox::Group
|
13
|
+
end
|
14
|
+
end
|