hotwire_combobox 0.1.43 → 0.2.0
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 +1 -0
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +25 -3
- data/app/assets/javascripts/hotwire_combobox.esm.js +438 -104
- data/app/assets/javascripts/hotwire_combobox.umd.js +438 -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 +84 -18
- data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
- data/app/presenters/hotwire_combobox/component.rb +93 -26
- data/app/presenters/hotwire_combobox/listbox/group.rb +45 -0
- data/app/presenters/hotwire_combobox/listbox/item.rb +104 -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 +111 -86
- data/lib/hotwire_combobox/version.rb +1 -1
- metadata +9 -2
@@ -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,7 @@
|
|
1
1
|
:root {
|
2
2
|
--hw-active-bg-color: #F3F4F6;
|
3
3
|
--hw-border-color: #D1D5DB;
|
4
|
+
--hw-group-color: #57595C;
|
4
5
|
--hw-invalid-color: #EF4444;
|
5
6
|
--hw-dialog-label-color: #1D1D1D;
|
6
7
|
--hw-focus-color: #2563EB;
|
@@ -10,6 +11,9 @@
|
|
10
11
|
--hw-border-width--slim: 1px;
|
11
12
|
--hw-border-width--thick: 2px;
|
12
13
|
|
14
|
+
--hw-combobox-width: 10rem;
|
15
|
+
--hw-combobox-width--multiple: 30rem;
|
16
|
+
|
13
17
|
--hw-dialog-font-size: 1.25rem;
|
14
18
|
--hw-dialog-input-height: 2.5rem;
|
15
19
|
--hw-dialog-label-alignment: center;
|
@@ -24,20 +28,21 @@
|
|
24
28
|
--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
29
|
--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
30
|
--hw-handle-offset-right: 0.375rem;
|
27
|
-
--hw-handle-width: 1.
|
28
|
-
--hw-handle-width--queried:
|
29
|
-
|
30
|
-
--hw-combobox-width: 10rem;
|
31
|
+
--hw-handle-width: 1.5rem;
|
32
|
+
--hw-handle-width--queried: 1rem;
|
31
33
|
|
32
34
|
--hw-line-height: 1.5rem;
|
35
|
+
--hw-line-height--multiple: 2.125rem;
|
33
36
|
|
34
37
|
--hw-listbox-height: calc(var(--hw-line-height) * 10);
|
35
|
-
--hw-listbox-offset-top: calc(var(--hw-line-height) * 1.625);
|
36
38
|
--hw-listbox-z-index: 10;
|
37
39
|
|
40
|
+
--hw-padding--slimmer: 0.25rem;
|
38
41
|
--hw-padding--slim: 0.375rem;
|
39
42
|
--hw-padding--thick: 0.75rem;
|
40
43
|
|
44
|
+
--hw-selection-chip-font-size: 0.875rem;
|
45
|
+
|
41
46
|
--hw-visual-viewport-height: 100vh;
|
42
47
|
}
|
43
48
|
|
@@ -57,30 +62,35 @@
|
|
57
62
|
}
|
58
63
|
|
59
64
|
.hw-combobox__main__wrapper {
|
65
|
+
border: var(--hw-border-width--slim) solid var(--hw-border-color);
|
66
|
+
border-radius: var(--hw-border-radius);
|
67
|
+
padding: var(--hw-padding--slim) calc(var(--hw-handle-width) + var(--hw-padding--slimmer)) var(--hw-padding--slim) var(--hw-padding--thick);
|
60
68
|
position: relative;
|
61
|
-
width:
|
69
|
+
width: var(--hw-combobox-width);
|
70
|
+
|
71
|
+
&:focus-within {
|
72
|
+
box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
|
73
|
+
}
|
74
|
+
|
75
|
+
&:has(.hw-combobox__input--invalid) {
|
76
|
+
box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-invalid-color);
|
77
|
+
}
|
62
78
|
}
|
63
79
|
|
64
80
|
.hw-combobox__input {
|
65
|
-
border:
|
66
|
-
border-radius: var(--hw-border-radius);
|
81
|
+
border: 0;
|
67
82
|
font-size: inherit;
|
68
83
|
line-height: var(--hw-line-height);
|
69
|
-
|
70
|
-
|
71
|
-
width: var(--hw-combobox-width);
|
84
|
+
min-width: 0;
|
85
|
+
padding: 0;
|
72
86
|
text-overflow: ellipsis;
|
87
|
+
width: 100%;
|
73
88
|
}
|
74
89
|
|
75
90
|
.hw-combobox__input:focus-visible {
|
76
|
-
box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
|
77
91
|
outline: none;
|
78
92
|
}
|
79
93
|
|
80
|
-
.hw-combobox__input--invalid {
|
81
|
-
border: var(--hw-border-width--slim) solid var(--hw-invalid-color);
|
82
|
-
}
|
83
|
-
|
84
94
|
.hw-combobox__handle {
|
85
95
|
height: 100%;
|
86
96
|
position: absolute;
|
@@ -105,7 +115,6 @@
|
|
105
115
|
.hw-combobox__input[data-queried] + .hw-combobox__handle::before {
|
106
116
|
background-image: var(--hw-handle-image--queried);
|
107
117
|
background-size: var(--hw-handle-width--queried);
|
108
|
-
cursor: pointer;
|
109
118
|
}
|
110
119
|
|
111
120
|
.hw-combobox__listbox {
|
@@ -121,7 +130,7 @@
|
|
121
130
|
overflow: auto;
|
122
131
|
padding: 0;
|
123
132
|
position: absolute;
|
124
|
-
top:
|
133
|
+
top: calc(100% + 0.2rem);
|
125
134
|
width: 100%;
|
126
135
|
z-index: var(--hw-listbox-z-index);
|
127
136
|
|
@@ -130,6 +139,20 @@
|
|
130
139
|
}
|
131
140
|
}
|
132
141
|
|
142
|
+
.hw-combobox__group {
|
143
|
+
display: none;
|
144
|
+
padding: 0;
|
145
|
+
}
|
146
|
+
|
147
|
+
.hw-combobox__group__label {
|
148
|
+
color: var(--hw-group-color);
|
149
|
+
padding: var(--hw-padding--slim);
|
150
|
+
}
|
151
|
+
|
152
|
+
.hw-combobox__group:has(.hw-combobox__option:not([hidden])) {
|
153
|
+
display: block;
|
154
|
+
}
|
155
|
+
|
133
156
|
.hw-combobox__option {
|
134
157
|
background-color: var(--hw-option-bg-color);
|
135
158
|
padding: var(--hw-padding--slim) var(--hw-padding--thick);
|
@@ -144,6 +167,7 @@
|
|
144
167
|
}
|
145
168
|
|
146
169
|
.hw-combobox__option:hover,
|
170
|
+
.hw-combobox__option--navigated,
|
147
171
|
.hw-combobox__option--selected {
|
148
172
|
background-color: var(--hw-active-bg-color);
|
149
173
|
}
|
@@ -217,3 +241,45 @@
|
|
217
241
|
padding: var(--hw-padding--thick);
|
218
242
|
}
|
219
243
|
}
|
244
|
+
|
245
|
+
.hw-combobox__chip {
|
246
|
+
align-items: center;
|
247
|
+
border: var(--hw-border-width--slim) solid var(--hw-border-color);
|
248
|
+
border-radius: var(--hw-border-radius);
|
249
|
+
display: flex;
|
250
|
+
font-size: var(--hw-selection-chip-font-size);
|
251
|
+
line-height: var(--hw-line-height);
|
252
|
+
padding: var(--hw-padding--slimmer);
|
253
|
+
padding-left: var(--hw-padding--slim);
|
254
|
+
}
|
255
|
+
|
256
|
+
.hw-combobox__chip__remover {
|
257
|
+
background-image: var(--hw-handle-image--queried);
|
258
|
+
background-size: var(--hw-handle-width--queried);
|
259
|
+
background-repeat: no-repeat;
|
260
|
+
margin-left: var(--hw-padding--slimmer);
|
261
|
+
min-height: var(--hw-handle-width--queried);
|
262
|
+
min-width: var(--hw-handle-width--queried);
|
263
|
+
}
|
264
|
+
|
265
|
+
.hw-combobox--multiple {
|
266
|
+
.hw-combobox__main__wrapper {
|
267
|
+
align-items: center;
|
268
|
+
display: flex;
|
269
|
+
flex-wrap: wrap;
|
270
|
+
gap: var(--hw-padding--slimmer);
|
271
|
+
width: var(--hw-combobox-width--multiple);
|
272
|
+
|
273
|
+
&:has([data-hw-combobox-chip]) .hw-combobox__input::placeholder {
|
274
|
+
color: transparent;
|
275
|
+
}
|
276
|
+
}
|
277
|
+
|
278
|
+
.hw-combobox__input {
|
279
|
+
min-width: calc(var(--hw-combobox-width) / 2);
|
280
|
+
flex-grow: 1;
|
281
|
+
line-height: var(--hw-line-height--multiple);
|
282
|
+
max-width: 100%;
|
283
|
+
width: 1rem;
|
284
|
+
}
|
285
|
+
}
|
@@ -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,6 +238,8 @@ class HotwireCombobox::Component
|
|
199
238
|
end
|
200
239
|
|
201
240
|
def prefilled_display
|
241
|
+
return if multiselect?
|
242
|
+
|
202
243
|
if async_src && associated_object
|
203
244
|
associated_object.to_combobox_display
|
204
245
|
elsif hidden_field_value
|
@@ -227,7 +268,22 @@ class HotwireCombobox::Component
|
|
227
268
|
|
228
269
|
|
229
270
|
def main_wrapper_data
|
230
|
-
{
|
271
|
+
{
|
272
|
+
action: ("click->hw-combobox#openByFocusing:self" if multiselect?),
|
273
|
+
hw_combobox_target: "mainWrapper"
|
274
|
+
}
|
275
|
+
end
|
276
|
+
|
277
|
+
|
278
|
+
def announcer_aria
|
279
|
+
{
|
280
|
+
live: :polite,
|
281
|
+
atomic: true
|
282
|
+
}
|
283
|
+
end
|
284
|
+
|
285
|
+
def announcer_data
|
286
|
+
{ hw_combobox_target: "announcer" }
|
231
287
|
end
|
232
288
|
|
233
289
|
|
@@ -249,7 +305,9 @@ class HotwireCombobox::Component
|
|
249
305
|
if form&.object&.defined_enums&.try :[], name
|
250
306
|
form.object.public_send "#{name}_before_type_cast"
|
251
307
|
else
|
252
|
-
form&.object&.try
|
308
|
+
form&.object&.try(name).then do |value|
|
309
|
+
value.respond_to?(:map) ? value.join(",") : value
|
310
|
+
end
|
253
311
|
end
|
254
312
|
end
|
255
313
|
|
@@ -270,7 +328,8 @@ class HotwireCombobox::Component
|
|
270
328
|
keydown->hw-combobox#navigate
|
271
329
|
click@window->hw-combobox#closeOnClickOutside
|
272
330
|
focusin@window->hw-combobox#closeOnFocusOutside
|
273
|
-
turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog
|
331
|
+
turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog
|
332
|
+
turbo:before-cache@document->hw-combobox#hideChipsForCache".squish,
|
274
333
|
hw_combobox_target: "combobox",
|
275
334
|
async_id: canonical_id
|
276
335
|
end
|
@@ -301,6 +360,10 @@ class HotwireCombobox::Component
|
|
301
360
|
{ hw_combobox_target: "listbox" }
|
302
361
|
end
|
303
362
|
|
363
|
+
def listbox_aria
|
364
|
+
{ multiselectable: multiselect? }
|
365
|
+
end
|
366
|
+
|
304
367
|
|
305
368
|
def dialog_data
|
306
369
|
{
|
@@ -340,6 +403,10 @@ class HotwireCombobox::Component
|
|
340
403
|
{ hw_combobox_target: "dialogListbox" }
|
341
404
|
end
|
342
405
|
|
406
|
+
def dialog_listbox_aria
|
407
|
+
{ multiselectable: multiselect? }
|
408
|
+
end
|
409
|
+
|
343
410
|
def dialog_focus_trap_data
|
344
411
|
{ hw_combobox_target: "dialogFocusTrap" }
|
345
412
|
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require "securerandom"
|
2
|
+
|
3
|
+
class HotwireCombobox::Listbox::Group
|
4
|
+
def initialize(name, options:)
|
5
|
+
@name = name
|
6
|
+
@options = options
|
7
|
+
end
|
8
|
+
|
9
|
+
def render_in(view)
|
10
|
+
view.tag.ul **group_attrs do
|
11
|
+
view.concat view.tag.li(name, **label_attrs)
|
12
|
+
|
13
|
+
options.map do |option|
|
14
|
+
view.concat view.render(option)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
attr_reader :name, :options
|
21
|
+
|
22
|
+
def id
|
23
|
+
@id ||= SecureRandom.uuid
|
24
|
+
end
|
25
|
+
|
26
|
+
def group_attrs
|
27
|
+
{
|
28
|
+
class: "hw-combobox__group",
|
29
|
+
role: :group,
|
30
|
+
aria: group_aria
|
31
|
+
}
|
32
|
+
end
|
33
|
+
|
34
|
+
def group_aria
|
35
|
+
{ labelledby: id }
|
36
|
+
end
|
37
|
+
|
38
|
+
def label_attrs
|
39
|
+
{
|
40
|
+
id: id,
|
41
|
+
class: "hw-combobox__group__label",
|
42
|
+
role: :presentation
|
43
|
+
}
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
class HotwireCombobox::Listbox::Item
|
2
|
+
class << self
|
3
|
+
def collection_for(view, options, render_in:, include_blank:, **custom_methods)
|
4
|
+
new(view, options, render_in: render_in, include_blank: include_blank, **custom_methods).items
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
def initialize(view, options, render_in:, include_blank:, **custom_methods)
|
9
|
+
@view = view
|
10
|
+
@options = options
|
11
|
+
@render_in = render_in
|
12
|
+
@include_blank = include_blank
|
13
|
+
@custom_methods = custom_methods
|
14
|
+
end
|
15
|
+
|
16
|
+
def items
|
17
|
+
items = groups_or_options
|
18
|
+
items.unshift(blank_option) if include_blank.present?
|
19
|
+
items
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
attr_reader :view, :options, :render_in, :include_blank, :custom_methods
|
24
|
+
|
25
|
+
def groups_or_options
|
26
|
+
if grouped?
|
27
|
+
create_listbox_group options
|
28
|
+
else
|
29
|
+
create_listbox_options options
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def grouped?
|
34
|
+
key, value = options.to_a.first
|
35
|
+
value.is_a? Array
|
36
|
+
end
|
37
|
+
|
38
|
+
def create_listbox_group(options)
|
39
|
+
options.map do |group_name, group_options|
|
40
|
+
HotwireCombobox::Listbox::Group.new group_name,
|
41
|
+
options: create_listbox_options(group_options)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def create_listbox_options(options)
|
46
|
+
options.map do |option|
|
47
|
+
HotwireCombobox::Listbox::Option.new **option_attrs(option)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def option_attrs(option)
|
52
|
+
case option
|
53
|
+
when Hash
|
54
|
+
option.tap do |attrs|
|
55
|
+
attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
|
56
|
+
end
|
57
|
+
when String
|
58
|
+
{}.tap do |attrs|
|
59
|
+
attrs[:display] = option
|
60
|
+
attrs[:value] = option
|
61
|
+
attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
|
62
|
+
end
|
63
|
+
when Array
|
64
|
+
{}.tap do |attrs|
|
65
|
+
attrs[:display] = option.first
|
66
|
+
attrs[:value] = option.last
|
67
|
+
attrs[:content] = render_content(object: attrs[:display], attrs: attrs) if render_in.present?
|
68
|
+
end
|
69
|
+
else
|
70
|
+
{}.tap do |attrs|
|
71
|
+
attrs[:id] = view.hw_call_method_or_proc(option, custom_methods[:id]) if custom_methods[:id]
|
72
|
+
attrs[:display] = view.hw_call_method_or_proc(option, custom_methods[:display]) if custom_methods[:display]
|
73
|
+
attrs[:value] = view.hw_call_method_or_proc(option, custom_methods[:value] || :id)
|
74
|
+
|
75
|
+
if render_in.present?
|
76
|
+
attrs[:content] = render_content(object: option, attrs: attrs)
|
77
|
+
elsif custom_methods[:content]
|
78
|
+
attrs[:content] = view.hw_call_method_or_proc(option, custom_methods[:content])
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def render_content(render_opts: render_in, object:, attrs:)
|
85
|
+
view.render **render_opts.reverse_merge(
|
86
|
+
object: object,
|
87
|
+
locals: { combobox_display: attrs[:display], combobox_value: attrs[:value] })
|
88
|
+
end
|
89
|
+
|
90
|
+
def blank_option
|
91
|
+
display, content = extract_blank_display_and_content
|
92
|
+
HotwireCombobox::Listbox::Option.new display: display, content: content, value: "", blank: true
|
93
|
+
end
|
94
|
+
|
95
|
+
def extract_blank_display_and_content
|
96
|
+
if include_blank.is_a? Hash
|
97
|
+
text = include_blank.delete(:text)
|
98
|
+
|
99
|
+
[ text, render_content(render_opts: include_blank, object: text, attrs: { display: text, value: "" }) ]
|
100
|
+
else
|
101
|
+
[ include_blank, include_blank ]
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|