hotwire_combobox 0.1.40 → 0.1.42
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 +2 -1
- data/app/assets/javascripts/hotwire_combobox.esm.js +30 -10
- data/app/assets/javascripts/hotwire_combobox.umd.js +30 -10
- data/app/assets/javascripts/hw_combobox/models/combobox/events.js +10 -5
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +4 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +12 -2
- data/app/assets/stylesheets/hotwire_combobox.css +28 -10
- data/app/presenters/hotwire_combobox/component/customizable.rb +52 -0
- data/app/presenters/hotwire_combobox/component.rb +53 -16
- data/app/views/hotwire_combobox/_component.html.erb +11 -0
- data/lib/hotwire_combobox/engine.rb +2 -2
- data/lib/hotwire_combobox/helper.rb +28 -19
- data/lib/hotwire_combobox/version.rb +1 -1
- metadata +4 -3
- data/app/views/hotwire_combobox/_combobox.html.erb +0 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1fc3750ecb7bd58bd28e13d0cc2d543ae4c614535c927c9483e504b643171a8d
|
4
|
+
data.tar.gz: dc6482de8a4344a6d801797d497f6adec8afcc0357e821e1dff8188ecf4c2f68
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8884e9e8295b73d37d15762f08c1a69452a7117f99af09dba4369aedea9af2116bc9683127f6f29535813fa5182b21969eb35e4d077edee1f94ce1a6808aadfe
|
7
|
+
data.tar.gz: b58dd5870a0508912c910b387f7970ffc4a08a16e40612b448e7c35ae244d0f8e02f9dd9356e5cdd8ae7f0c45c1bd65513ece94fcbeba38818c39d39c9ecd870
|
data/README.md
CHANGED
@@ -80,6 +80,9 @@ import HwComboboxController from "@josefarias/hotwire_combobox"
|
|
80
80
|
application.register("hw-combobox", HwComboboxController)
|
81
81
|
```
|
82
82
|
|
83
|
+
> [!WARNING]
|
84
|
+
> Keep in mind you need to update both the npm package and the gem every time there's a new version of HotwireCombobox. You should always run the same version number on both sides.
|
85
|
+
|
83
86
|
### Configuring CSS
|
84
87
|
|
85
88
|
This library comes with optional default styles. Follow the instructions below to include them in your app.
|
@@ -109,6 +112,19 @@ Require the styles in `app/assets/stylesheets/application.css`:
|
|
109
112
|
Visit [the docs site](https://hotwirecombobox.com/) for a demo and detailed documentation.
|
110
113
|
If the site is down, you can run the docs locally by cloning [the docs repo](https://github.com/josefarias/hotwire_combobox_docs).
|
111
114
|
|
115
|
+
## Notes about accessibility
|
116
|
+
|
117
|
+
This gem follows the [APG combobox pattern guidelines](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) with some exceptions we feel increase the usefulness of the component without much detriment to the overall accessible experience.
|
118
|
+
|
119
|
+
These are the exceptions:
|
120
|
+
|
121
|
+
1. Users cannot manipulate the combobox while it's closed. As long as the combobox is focused, the listbox is shown.
|
122
|
+
2. The escape key closes the listbox and blurs the combobox. It does not clear the combobox.
|
123
|
+
3. The listbox has wrap-around selection. That is, pressing `Up Arrow` when the user is on the first option will select the last option. And pressing `Down Arrow` when on the last option will select the first option. In paginated comboboxes, the first and last options refer to the currently available options. More options may be loaded after navigating to the last currently available option.
|
124
|
+
4. It is possible to have an unlabled combobox, as that responsibility is delegated to the implementing user.
|
125
|
+
|
126
|
+
It should be noted none of the maintainers use assistive technologies in their daily lives. If you do, and you feel these exceptions are detrimental to your ability to use the component, or if you find an undocumented exception, please [open a GitHub issue](https://github.com/josefarias/hotwire_combobox/issues). We'll get it sorted.
|
127
|
+
|
112
128
|
## Contributing
|
113
129
|
|
114
130
|
Please read [CONTRIBUTING.md](./CONTRIBUTING.md).
|
@@ -237,16 +237,21 @@ Combobox.Dialog = Base => class extends Base {
|
|
237
237
|
|
238
238
|
Combobox.Events = Base => class extends Base {
|
239
239
|
_dispatchSelectionEvent({ isNew }) {
|
240
|
-
|
240
|
+
dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } });
|
241
|
+
}
|
242
|
+
|
243
|
+
_dispatchClosedEvent() {
|
244
|
+
dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
|
245
|
+
}
|
246
|
+
|
247
|
+
get _eventableDetails() {
|
248
|
+
return {
|
241
249
|
value: this.hiddenFieldTarget.value,
|
242
250
|
display: this._fullQuery,
|
243
251
|
query: this._typedQuery,
|
244
252
|
fieldName: this.hiddenFieldTarget.name,
|
245
|
-
isValid: this._valueIsValid
|
246
|
-
|
247
|
-
};
|
248
|
-
|
249
|
-
dispatch("hw-combobox:selection", { target: this.element, detail });
|
253
|
+
isValid: this._valueIsValid
|
254
|
+
}
|
250
255
|
}
|
251
256
|
};
|
252
257
|
|
@@ -534,6 +539,8 @@ Combobox.Filtering = Base => class extends Base {
|
|
534
539
|
} else {
|
535
540
|
this._filterSync(event);
|
536
541
|
}
|
542
|
+
|
543
|
+
this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
|
537
544
|
}
|
538
545
|
|
539
546
|
_initializeFiltering() {
|
@@ -565,6 +572,8 @@ Combobox.Filtering = Base => class extends Base {
|
|
565
572
|
this._selectNew();
|
566
573
|
} else if (isDeleteEvent(event)) {
|
567
574
|
this._deselect();
|
575
|
+
} else if (event.inputType === "hw:lockInSelection") {
|
576
|
+
this._select(this._ensurableOption);
|
568
577
|
} else if (this._isOpen) {
|
569
578
|
this._select(this._visibleOptionElements[0]);
|
570
579
|
}
|
@@ -706,7 +715,7 @@ Combobox.Options = Base => class extends Base {
|
|
706
715
|
Combobox.Selection = Base => class extends Base {
|
707
716
|
selectOptionOnClick(event) {
|
708
717
|
this.filter(event);
|
709
|
-
this._select(event.currentTarget);
|
718
|
+
this._select(event.currentTarget, { forceAutocomplete: true });
|
710
719
|
this.close();
|
711
720
|
}
|
712
721
|
|
@@ -1095,6 +1104,7 @@ Combobox.Toggle = Base => class extends Base {
|
|
1095
1104
|
if (this._isOpen) {
|
1096
1105
|
this._lockInSelection();
|
1097
1106
|
this.expandedValue = false;
|
1107
|
+
this._dispatchClosedEvent();
|
1098
1108
|
}
|
1099
1109
|
}
|
1100
1110
|
|
@@ -1110,7 +1120,7 @@ Combobox.Toggle = Base => class extends Base {
|
|
1110
1120
|
const target = event.target;
|
1111
1121
|
|
1112
1122
|
if (!this._isOpen) return
|
1113
|
-
if (this.
|
1123
|
+
if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
|
1114
1124
|
if (this._withinElementBounds(event)) return
|
1115
1125
|
|
1116
1126
|
this.close();
|
@@ -1123,12 +1133,21 @@ Combobox.Toggle = Base => class extends Base {
|
|
1123
1133
|
this.close();
|
1124
1134
|
}
|
1125
1135
|
|
1136
|
+
clearOrToggleOnHandleClick() {
|
1137
|
+
if (this._isQueried) {
|
1138
|
+
this._clearQuery();
|
1139
|
+
this._actingCombobox.focus();
|
1140
|
+
} else {
|
1141
|
+
this.toggle();
|
1142
|
+
}
|
1143
|
+
}
|
1144
|
+
|
1126
1145
|
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
1127
1146
|
// Hovering over these elements emits a click event for some reason.
|
1128
1147
|
// These events don't contain any telling information, so we use `_withinElementBounds`
|
1129
1148
|
// as an alternative to check whether the click is legitimate.
|
1130
1149
|
_withinElementBounds(event) {
|
1131
|
-
const { left, right, top, bottom } = this.
|
1150
|
+
const { left, right, top, bottom } = this.mainWrapperTarget.getBoundingClientRect();
|
1132
1151
|
const { clientX, clientY } = event;
|
1133
1152
|
|
1134
1153
|
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
|
@@ -1275,7 +1294,8 @@ class HwComboboxController extends Concerns(...concerns) {
|
|
1275
1294
|
"endOfOptionsStream",
|
1276
1295
|
"handle",
|
1277
1296
|
"hiddenField",
|
1278
|
-
"listbox"
|
1297
|
+
"listbox",
|
1298
|
+
"mainWrapper"
|
1279
1299
|
]
|
1280
1300
|
|
1281
1301
|
static values = {
|
@@ -241,16 +241,21 @@
|
|
241
241
|
|
242
242
|
Combobox.Events = Base => class extends Base {
|
243
243
|
_dispatchSelectionEvent({ isNew }) {
|
244
|
-
|
244
|
+
dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } });
|
245
|
+
}
|
246
|
+
|
247
|
+
_dispatchClosedEvent() {
|
248
|
+
dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
|
249
|
+
}
|
250
|
+
|
251
|
+
get _eventableDetails() {
|
252
|
+
return {
|
245
253
|
value: this.hiddenFieldTarget.value,
|
246
254
|
display: this._fullQuery,
|
247
255
|
query: this._typedQuery,
|
248
256
|
fieldName: this.hiddenFieldTarget.name,
|
249
|
-
isValid: this._valueIsValid
|
250
|
-
|
251
|
-
};
|
252
|
-
|
253
|
-
dispatch("hw-combobox:selection", { target: this.element, detail });
|
257
|
+
isValid: this._valueIsValid
|
258
|
+
}
|
254
259
|
}
|
255
260
|
};
|
256
261
|
|
@@ -538,6 +543,8 @@
|
|
538
543
|
} else {
|
539
544
|
this._filterSync(event);
|
540
545
|
}
|
546
|
+
|
547
|
+
this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
|
541
548
|
}
|
542
549
|
|
543
550
|
_initializeFiltering() {
|
@@ -569,6 +576,8 @@
|
|
569
576
|
this._selectNew();
|
570
577
|
} else if (isDeleteEvent(event)) {
|
571
578
|
this._deselect();
|
579
|
+
} else if (event.inputType === "hw:lockInSelection") {
|
580
|
+
this._select(this._ensurableOption);
|
572
581
|
} else if (this._isOpen) {
|
573
582
|
this._select(this._visibleOptionElements[0]);
|
574
583
|
}
|
@@ -710,7 +719,7 @@
|
|
710
719
|
Combobox.Selection = Base => class extends Base {
|
711
720
|
selectOptionOnClick(event) {
|
712
721
|
this.filter(event);
|
713
|
-
this._select(event.currentTarget);
|
722
|
+
this._select(event.currentTarget, { forceAutocomplete: true });
|
714
723
|
this.close();
|
715
724
|
}
|
716
725
|
|
@@ -1099,6 +1108,7 @@
|
|
1099
1108
|
if (this._isOpen) {
|
1100
1109
|
this._lockInSelection();
|
1101
1110
|
this.expandedValue = false;
|
1111
|
+
this._dispatchClosedEvent();
|
1102
1112
|
}
|
1103
1113
|
}
|
1104
1114
|
|
@@ -1114,7 +1124,7 @@
|
|
1114
1124
|
const target = event.target;
|
1115
1125
|
|
1116
1126
|
if (!this._isOpen) return
|
1117
|
-
if (this.
|
1127
|
+
if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
|
1118
1128
|
if (this._withinElementBounds(event)) return
|
1119
1129
|
|
1120
1130
|
this.close();
|
@@ -1127,12 +1137,21 @@
|
|
1127
1137
|
this.close();
|
1128
1138
|
}
|
1129
1139
|
|
1140
|
+
clearOrToggleOnHandleClick() {
|
1141
|
+
if (this._isQueried) {
|
1142
|
+
this._clearQuery();
|
1143
|
+
this._actingCombobox.focus();
|
1144
|
+
} else {
|
1145
|
+
this.toggle();
|
1146
|
+
}
|
1147
|
+
}
|
1148
|
+
|
1130
1149
|
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
1131
1150
|
// Hovering over these elements emits a click event for some reason.
|
1132
1151
|
// These events don't contain any telling information, so we use `_withinElementBounds`
|
1133
1152
|
// as an alternative to check whether the click is legitimate.
|
1134
1153
|
_withinElementBounds(event) {
|
1135
|
-
const { left, right, top, bottom } = this.
|
1154
|
+
const { left, right, top, bottom } = this.mainWrapperTarget.getBoundingClientRect();
|
1136
1155
|
const { clientX, clientY } = event;
|
1137
1156
|
|
1138
1157
|
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
|
@@ -1279,7 +1298,8 @@
|
|
1279
1298
|
"endOfOptionsStream",
|
1280
1299
|
"handle",
|
1281
1300
|
"hiddenField",
|
1282
|
-
"listbox"
|
1301
|
+
"listbox",
|
1302
|
+
"mainWrapper"
|
1283
1303
|
]
|
1284
1304
|
|
1285
1305
|
static values = {
|
@@ -3,15 +3,20 @@ import { dispatch } from "hw_combobox/helpers"
|
|
3
3
|
|
4
4
|
Combobox.Events = Base => class extends Base {
|
5
5
|
_dispatchSelectionEvent({ isNew }) {
|
6
|
-
|
6
|
+
dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } })
|
7
|
+
}
|
8
|
+
|
9
|
+
_dispatchClosedEvent() {
|
10
|
+
dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails })
|
11
|
+
}
|
12
|
+
|
13
|
+
get _eventableDetails() {
|
14
|
+
return {
|
7
15
|
value: this.hiddenFieldTarget.value,
|
8
16
|
display: this._fullQuery,
|
9
17
|
query: this._typedQuery,
|
10
18
|
fieldName: this.hiddenFieldTarget.name,
|
11
|
-
isValid: this._valueIsValid
|
12
|
-
isNew: isNew
|
19
|
+
isValid: this._valueIsValid
|
13
20
|
}
|
14
|
-
|
15
|
-
dispatch("hw-combobox:selection", { target: this.element, detail })
|
16
21
|
}
|
17
22
|
}
|
@@ -10,6 +10,8 @@ Combobox.Filtering = Base => class extends Base {
|
|
10
10
|
} else {
|
11
11
|
this._filterSync(event)
|
12
12
|
}
|
13
|
+
|
14
|
+
this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
|
13
15
|
}
|
14
16
|
|
15
17
|
_initializeFiltering() {
|
@@ -41,6 +43,8 @@ Combobox.Filtering = Base => class extends Base {
|
|
41
43
|
this._selectNew()
|
42
44
|
} else if (isDeleteEvent(event)) {
|
43
45
|
this._deselect()
|
46
|
+
} else if (event.inputType === "hw:lockInSelection") {
|
47
|
+
this._select(this._ensurableOption)
|
44
48
|
} else if (this._isOpen) {
|
45
49
|
this._select(this._visibleOptionElements[0])
|
46
50
|
}
|
@@ -4,7 +4,7 @@ import { wrapAroundAccess } from "hw_combobox/helpers"
|
|
4
4
|
Combobox.Selection = Base => class extends Base {
|
5
5
|
selectOptionOnClick(event) {
|
6
6
|
this.filter(event)
|
7
|
-
this._select(event.currentTarget)
|
7
|
+
this._select(event.currentTarget, { forceAutocomplete: true })
|
8
8
|
this.close()
|
9
9
|
}
|
10
10
|
|
@@ -10,6 +10,7 @@ Combobox.Toggle = Base => class extends Base {
|
|
10
10
|
if (this._isOpen) {
|
11
11
|
this._lockInSelection()
|
12
12
|
this.expandedValue = false
|
13
|
+
this._dispatchClosedEvent()
|
13
14
|
}
|
14
15
|
}
|
15
16
|
|
@@ -25,7 +26,7 @@ Combobox.Toggle = Base => class extends Base {
|
|
25
26
|
const target = event.target
|
26
27
|
|
27
28
|
if (!this._isOpen) return
|
28
|
-
if (this.
|
29
|
+
if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
|
29
30
|
if (this._withinElementBounds(event)) return
|
30
31
|
|
31
32
|
this.close()
|
@@ -38,12 +39,21 @@ Combobox.Toggle = Base => class extends Base {
|
|
38
39
|
this.close()
|
39
40
|
}
|
40
41
|
|
42
|
+
clearOrToggleOnHandleClick() {
|
43
|
+
if (this._isQueried) {
|
44
|
+
this._clearQuery()
|
45
|
+
this._actingCombobox.focus()
|
46
|
+
} else {
|
47
|
+
this.toggle()
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
41
51
|
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
42
52
|
// Hovering over these elements emits a click event for some reason.
|
43
53
|
// These events don't contain any telling information, so we use `_withinElementBounds`
|
44
54
|
// as an alternative to check whether the click is legitimate.
|
45
55
|
_withinElementBounds(event) {
|
46
|
-
const { left, right, top, bottom } = this.
|
56
|
+
const { left, right, top, bottom } = this.mainWrapperTarget.getBoundingClientRect()
|
47
57
|
const { clientX, clientY } = event
|
48
58
|
|
49
59
|
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
|
@@ -1,7 +1,11 @@
|
|
1
1
|
:root {
|
2
2
|
--hw-active-bg-color: #F3F4F6;
|
3
|
-
|
4
3
|
--hw-border-color: #D1D5DB;
|
4
|
+
--hw-invalid-color: #EF4444;
|
5
|
+
--hw-dialog-label-color: #1D1D1D;
|
6
|
+
--hw-focus-color: #2563EB;
|
7
|
+
--hw-option-bg-color: #FFFFFF;
|
8
|
+
|
5
9
|
--hw-border-radius: 0.375rem;
|
6
10
|
--hw-border-width--slim: 1px;
|
7
11
|
--hw-border-width--thick: 2px;
|
@@ -9,7 +13,6 @@
|
|
9
13
|
--hw-dialog-font-size: 1.25rem;
|
10
14
|
--hw-dialog-input-height: 2.5rem;
|
11
15
|
--hw-dialog-label-alignment: center;
|
12
|
-
--hw-dialog-label-color: #1D1D1D;
|
13
16
|
--hw-dialog-label-padding: 0.5rem 0 0.375rem;
|
14
17
|
--hw-dialog-label-size: 1.05rem;
|
15
18
|
--hw-dialog-listbox-margin: 1.25rem 0 0;
|
@@ -19,21 +22,19 @@
|
|
19
22
|
--hw-font-size: 1rem;
|
20
23
|
|
21
24
|
--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
|
+
--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");
|
22
26
|
--hw-handle-offset-right: 0.375rem;
|
23
27
|
--hw-handle-width: 1.5em;
|
28
|
+
--hw-handle-width--queried: 1em;
|
24
29
|
|
25
30
|
--hw-combobox-width: 10rem;
|
26
31
|
|
27
|
-
--hw-focus-color: #2563EB;
|
28
|
-
|
29
32
|
--hw-line-height: 1.5rem;
|
30
33
|
|
31
34
|
--hw-listbox-height: calc(var(--hw-line-height) * 10);
|
32
35
|
--hw-listbox-offset-top: calc(var(--hw-line-height) * 1.625);
|
33
36
|
--hw-listbox-z-index: 10;
|
34
37
|
|
35
|
-
--hw-option-bg-color: #FFFFFF;
|
36
|
-
|
37
38
|
--hw-padding--slim: 0.375rem;
|
38
39
|
--hw-padding--thick: 0.75rem;
|
39
40
|
|
@@ -42,8 +43,10 @@
|
|
42
43
|
|
43
44
|
.hw-combobox {
|
44
45
|
border-width: 0;
|
45
|
-
display: inline-
|
46
|
+
display: inline-flex;
|
47
|
+
flex-direction: column;
|
46
48
|
font-size: var(--hw-font-size);
|
49
|
+
gap: var(--hw-padding--slim);
|
47
50
|
margin: 0;
|
48
51
|
padding: 0;
|
49
52
|
position: relative;
|
@@ -53,6 +56,11 @@
|
|
53
56
|
}
|
54
57
|
}
|
55
58
|
|
59
|
+
.hw-combobox__main__wrapper {
|
60
|
+
position: relative;
|
61
|
+
width: min-content;
|
62
|
+
}
|
63
|
+
|
56
64
|
.hw-combobox__input {
|
57
65
|
border: var(--hw-border-width--slim) solid var(--hw-border-color);
|
58
66
|
border-radius: var(--hw-border-radius);
|
@@ -64,6 +72,15 @@
|
|
64
72
|
text-overflow: ellipsis;
|
65
73
|
}
|
66
74
|
|
75
|
+
.hw-combobox__input:focus-visible {
|
76
|
+
box-shadow: 0 0 0 var(--hw-border-width--thick) var(--hw-focus-color);
|
77
|
+
outline: none;
|
78
|
+
}
|
79
|
+
|
80
|
+
.hw-combobox__input--invalid {
|
81
|
+
border: var(--hw-border-width--slim) solid var(--hw-invalid-color);
|
82
|
+
}
|
83
|
+
|
67
84
|
.hw-combobox__handle {
|
68
85
|
height: 100%;
|
69
86
|
position: absolute;
|
@@ -85,9 +102,10 @@
|
|
85
102
|
top: 0;
|
86
103
|
}
|
87
104
|
|
88
|
-
.hw-combobox__input
|
89
|
-
|
90
|
-
|
105
|
+
.hw-combobox__input[data-queried] + .hw-combobox__handle::before {
|
106
|
+
background-image: var(--hw-handle-image--queried);
|
107
|
+
background-size: var(--hw-handle-width--queried);
|
108
|
+
cursor: pointer;
|
91
109
|
}
|
92
110
|
|
93
111
|
.hw-combobox__listbox {
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module HotwireCombobox::Component::Customizable
|
2
|
+
CUSTOMIZABLE_ELEMENTS = %i[
|
3
|
+
fieldset
|
4
|
+
label
|
5
|
+
hidden_field
|
6
|
+
main_wrapper
|
7
|
+
input
|
8
|
+
handle
|
9
|
+
listbox
|
10
|
+
dialog
|
11
|
+
dialog_wrapper
|
12
|
+
dialog_label
|
13
|
+
dialog_input
|
14
|
+
dialog_listbox
|
15
|
+
].freeze
|
16
|
+
|
17
|
+
PROTECTED_ATTRS = %i[
|
18
|
+
id
|
19
|
+
name
|
20
|
+
value
|
21
|
+
open
|
22
|
+
role
|
23
|
+
hidden
|
24
|
+
for
|
25
|
+
].freeze
|
26
|
+
|
27
|
+
CUSTOMIZABLE_ELEMENTS.each do |element|
|
28
|
+
define_method "customize_#{element}" do |**attrs|
|
29
|
+
customize element, **attrs
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
def custom_attrs
|
35
|
+
@custom_attrs ||= Hash.new { |h, k| h[k] = {} }
|
36
|
+
end
|
37
|
+
|
38
|
+
def customize(element, **attrs)
|
39
|
+
element = element.to_sym.presence_in(CUSTOMIZABLE_ELEMENTS)
|
40
|
+
sanitized_attrs = attrs.deep_symbolize_keys.except(*PROTECTED_ATTRS)
|
41
|
+
|
42
|
+
custom_attrs.store element, sanitized_attrs
|
43
|
+
end
|
44
|
+
|
45
|
+
def apply_customizations_to(element, base: {})
|
46
|
+
custom = custom_attrs[element]
|
47
|
+
coalesce = ->(k, v) { v.is_a?(String) ? view.token_list(v, custom.delete(k)) : v }
|
48
|
+
default = base.map { |k, v| [ k, coalesce.(k, v) ] }.to_h
|
49
|
+
|
50
|
+
custom.deep_merge default
|
51
|
+
end
|
52
|
+
end
|
@@ -1,7 +1,9 @@
|
|
1
1
|
require "securerandom"
|
2
2
|
|
3
3
|
class HotwireCombobox::Component
|
4
|
-
|
4
|
+
include Customizable
|
5
|
+
|
6
|
+
attr_reader :options, :label
|
5
7
|
|
6
8
|
def initialize \
|
7
9
|
view, name,
|
@@ -13,31 +15,47 @@ class HotwireCombobox::Component
|
|
13
15
|
form: nil,
|
14
16
|
id: nil,
|
15
17
|
input: {},
|
18
|
+
label: nil,
|
16
19
|
mobile_at: "640px",
|
17
20
|
name_when_new: nil,
|
18
21
|
open: false,
|
19
22
|
options: [],
|
20
23
|
value: nil,
|
21
24
|
**rest
|
22
|
-
@view, @autocomplete, @id, @name, @value, @form, @async_src,
|
25
|
+
@view, @autocomplete, @id, @name, @value, @form, @async_src, @label,
|
23
26
|
@name_when_new, @open, @data, @mobile_at, @options, @dialog_label =
|
24
|
-
view, autocomplete, id, name.to_s, value, form, async_src,
|
27
|
+
view, autocomplete, id, name.to_s, value, form, async_src, label,
|
25
28
|
name_when_new, open, data, mobile_at, options, dialog_label
|
26
29
|
|
27
|
-
@combobox_attrs = input.reverse_merge(rest).
|
30
|
+
@combobox_attrs = input.reverse_merge(rest).deep_symbolize_keys
|
28
31
|
@association_name = association_name || infer_association_name
|
29
32
|
end
|
30
33
|
|
34
|
+
def render_in(view_context, &block)
|
35
|
+
block.call(self) if block_given?
|
36
|
+
view_context.render partial: "hotwire_combobox/component", locals: { component: self }
|
37
|
+
end
|
38
|
+
|
39
|
+
|
31
40
|
def fieldset_attrs
|
32
|
-
{
|
41
|
+
apply_customizations_to :fieldset, base: {
|
33
42
|
class: "hw-combobox",
|
34
43
|
data: fieldset_data
|
35
44
|
}
|
36
45
|
end
|
37
46
|
|
38
47
|
|
48
|
+
def label_attrs
|
49
|
+
apply_customizations_to :label, base: {
|
50
|
+
class: "hw-combobox__label",
|
51
|
+
for: input_id,
|
52
|
+
hidden: label.blank?
|
53
|
+
}
|
54
|
+
end
|
55
|
+
|
56
|
+
|
39
57
|
def hidden_field_attrs
|
40
|
-
{
|
58
|
+
apply_customizations_to :hidden_field, base: {
|
41
59
|
id: hidden_field_id,
|
42
60
|
name: hidden_field_name,
|
43
61
|
data: hidden_field_data,
|
@@ -46,10 +64,18 @@ class HotwireCombobox::Component
|
|
46
64
|
end
|
47
65
|
|
48
66
|
|
67
|
+
def main_wrapper_attrs
|
68
|
+
apply_customizations_to :main_wrapper, base: {
|
69
|
+
class: "hw-combobox__main__wrapper",
|
70
|
+
data: main_wrapper_data
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
|
49
75
|
def input_attrs
|
50
76
|
nested_attrs = %i[ data aria ]
|
51
77
|
|
52
|
-
{
|
78
|
+
base = {
|
53
79
|
id: input_id,
|
54
80
|
role: :combobox,
|
55
81
|
class: "hw-combobox__input",
|
@@ -57,12 +83,14 @@ class HotwireCombobox::Component
|
|
57
83
|
data: input_data,
|
58
84
|
aria: input_aria,
|
59
85
|
autocomplete: :off
|
60
|
-
}.
|
86
|
+
}.merge combobox_attrs.except(*nested_attrs)
|
87
|
+
|
88
|
+
apply_customizations_to :input, base: base
|
61
89
|
end
|
62
90
|
|
63
91
|
|
64
92
|
def handle_attrs
|
65
|
-
{
|
93
|
+
apply_customizations_to :handle, base: {
|
66
94
|
class: "hw-combobox__handle",
|
67
95
|
data: handle_data
|
68
96
|
}
|
@@ -70,7 +98,7 @@ class HotwireCombobox::Component
|
|
70
98
|
|
71
99
|
|
72
100
|
def listbox_attrs
|
73
|
-
{
|
101
|
+
apply_customizations_to :listbox, base: {
|
74
102
|
id: listbox_id,
|
75
103
|
role: :listbox,
|
76
104
|
class: "hw-combobox__listbox",
|
@@ -81,28 +109,32 @@ class HotwireCombobox::Component
|
|
81
109
|
|
82
110
|
|
83
111
|
def dialog_wrapper_attrs
|
84
|
-
{
|
112
|
+
apply_customizations_to :dialog_wrapper, base: {
|
85
113
|
class: "hw-combobox__dialog__wrapper"
|
86
114
|
}
|
87
115
|
end
|
88
116
|
|
89
117
|
def dialog_attrs
|
90
|
-
{
|
118
|
+
apply_customizations_to :dialog, base: {
|
91
119
|
class: "hw-combobox__dialog",
|
92
120
|
role: :dialog,
|
93
121
|
data: dialog_data
|
94
122
|
}
|
95
123
|
end
|
96
124
|
|
125
|
+
def dialog_label
|
126
|
+
@dialog_label || label
|
127
|
+
end
|
128
|
+
|
97
129
|
def dialog_label_attrs
|
98
|
-
{
|
130
|
+
apply_customizations_to :dialog_label, base: {
|
99
131
|
class: "hw-combobox__dialog__label",
|
100
132
|
for: dialog_input_id
|
101
133
|
}
|
102
134
|
end
|
103
135
|
|
104
136
|
def dialog_input_attrs
|
105
|
-
{
|
137
|
+
apply_customizations_to :dialog_input, base: {
|
106
138
|
id: dialog_input_id,
|
107
139
|
role: :combobox,
|
108
140
|
class: "hw-combobox__dialog__input",
|
@@ -114,7 +146,7 @@ class HotwireCombobox::Component
|
|
114
146
|
end
|
115
147
|
|
116
148
|
def dialog_listbox_attrs
|
117
|
-
{
|
149
|
+
apply_customizations_to :dialog_listbox, base: {
|
118
150
|
id: dialog_listbox_id,
|
119
151
|
class: "hw-combobox__dialog__listbox",
|
120
152
|
role: :listbox,
|
@@ -194,6 +226,11 @@ class HotwireCombobox::Component
|
|
194
226
|
end
|
195
227
|
|
196
228
|
|
229
|
+
def main_wrapper_data
|
230
|
+
{ hw_combobox_target: "mainWrapper" }
|
231
|
+
end
|
232
|
+
|
233
|
+
|
197
234
|
def hidden_field_id
|
198
235
|
"#{canonical_id}-hw-hidden-field"
|
199
236
|
end
|
@@ -250,7 +287,7 @@ class HotwireCombobox::Component
|
|
250
287
|
|
251
288
|
def handle_data
|
252
289
|
{
|
253
|
-
action: "click->hw-combobox#
|
290
|
+
action: "click->hw-combobox#clearOrToggleOnHandleClick",
|
254
291
|
hw_combobox_target: "handle"
|
255
292
|
}
|
256
293
|
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
<%= tag.fieldset **component.fieldset_attrs do %>
|
2
|
+
<%= tag.label component.label, **component.label_attrs %>
|
3
|
+
|
4
|
+
<%= render "hotwire_combobox/combobox/hidden_field", component: component %>
|
5
|
+
|
6
|
+
<%= tag.div **component.main_wrapper_attrs do %>
|
7
|
+
<%= render "hotwire_combobox/combobox/input", component: component %>
|
8
|
+
<%= render "hotwire_combobox/combobox/paginated_listbox", component: component %>
|
9
|
+
<%= render "hotwire_combobox/combobox/dialog", component: component %>
|
10
|
+
<% end %>
|
11
|
+
<% end %>
|
@@ -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
|
|
@@ -15,11 +15,10 @@ module HotwireCombobox
|
|
15
15
|
end
|
16
16
|
hw_alias :hw_combobox_style_tag
|
17
17
|
|
18
|
-
def hw_combobox_tag(name, options_or_src = [], render_in: {}, include_blank: nil, **kwargs)
|
18
|
+
def hw_combobox_tag(name, options_or_src = [], render_in: {}, include_blank: nil, **kwargs, &block)
|
19
19
|
options, src = hw_extract_options_and_src(options_or_src, render_in, include_blank)
|
20
20
|
component = HotwireCombobox::Component.new self, name, options: options, async_src: src, **kwargs
|
21
|
-
|
22
|
-
render "hotwire_combobox/combobox", component: component
|
21
|
+
render component, &block
|
23
22
|
end
|
24
23
|
hw_alias :hw_combobox_tag
|
25
24
|
|
@@ -27,11 +26,9 @@ module HotwireCombobox
|
|
27
26
|
if options.first.is_a? HotwireCombobox::Listbox::Option
|
28
27
|
options
|
29
28
|
else
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
options.unshift(hw_blank_option(include_blank)) if include_blank.present?
|
34
|
-
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
|
35
32
|
end
|
36
33
|
end
|
37
34
|
hw_alias :hw_combobox_options
|
@@ -86,7 +83,7 @@ module HotwireCombobox
|
|
86
83
|
if include_blank.is_a? Hash
|
87
84
|
text = include_blank.delete(:text)
|
88
85
|
|
89
|
-
[ text, hw_render_in_proc(include_blank)
|
86
|
+
[ text, hw_call_render_in_proc(hw_render_in_proc(include_blank), text, display: text, value: "") ]
|
90
87
|
else
|
91
88
|
[ include_blank, include_blank ]
|
92
89
|
end
|
@@ -103,7 +100,9 @@ module HotwireCombobox
|
|
103
100
|
|
104
101
|
private
|
105
102
|
def hw_render_in_proc(render_in)
|
106
|
-
|
103
|
+
if render_in.present?
|
104
|
+
->(object, locals) { render(**render_in.reverse_merge(object: object, locals: locals)) }
|
105
|
+
end
|
107
106
|
end
|
108
107
|
|
109
108
|
def hw_extract_options_and_src(options_or_src, render_in, include_blank)
|
@@ -114,40 +113,50 @@ module HotwireCombobox
|
|
114
113
|
end
|
115
114
|
end
|
116
115
|
|
117
|
-
def hw_parse_combobox_options(options,
|
116
|
+
def hw_parse_combobox_options(options, render_in_proc: nil, **methods)
|
118
117
|
options.map do |option|
|
119
118
|
HotwireCombobox::Listbox::Option.new \
|
120
|
-
**hw_option_attrs_for(option,
|
119
|
+
**hw_option_attrs_for(option, render_in_proc: render_in_proc, **methods)
|
121
120
|
end
|
122
121
|
end
|
123
122
|
|
124
|
-
def hw_option_attrs_for(option,
|
123
|
+
def hw_option_attrs_for(option, render_in_proc: nil, **methods)
|
125
124
|
case option
|
126
125
|
when Hash
|
127
|
-
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
|
128
129
|
when String
|
129
130
|
{}.tap do |attrs|
|
130
131
|
attrs[:display] = option
|
131
132
|
attrs[:value] = option
|
132
|
-
attrs[:content] =
|
133
|
+
attrs[:content] = hw_call_render_in_proc(render_in_proc, attrs[:display], attrs) if render_in_proc
|
133
134
|
end
|
134
135
|
when Array
|
135
136
|
{}.tap do |attrs|
|
136
137
|
attrs[:display] = option.first
|
137
138
|
attrs[:value] = option.last
|
138
|
-
attrs[:content] =
|
139
|
+
attrs[:content] = hw_call_render_in_proc(render_in_proc, attrs[:display], attrs) if render_in_proc
|
139
140
|
end
|
140
141
|
else
|
141
142
|
{}.tap do |attrs|
|
142
|
-
attrs[:value] = hw_call_method_or_proc(option, methods[:value] || :id)
|
143
|
-
|
144
143
|
attrs[:id] = hw_call_method_or_proc(option, methods[:id]) if methods[:id]
|
145
144
|
attrs[:display] = hw_call_method_or_proc(option, methods[:display]) if methods[:display]
|
146
|
-
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
|
147
152
|
end
|
148
153
|
end
|
149
154
|
end
|
150
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
|
+
|
151
160
|
def hw_call_method_or_proc(object, method_or_proc)
|
152
161
|
if method_or_proc.is_a? Proc
|
153
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.42
|
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-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -85,8 +85,9 @@ files:
|
|
85
85
|
- app/assets/javascripts/hw_combobox/vendor/requestjs.js
|
86
86
|
- app/assets/stylesheets/hotwire_combobox.css
|
87
87
|
- app/presenters/hotwire_combobox/component.rb
|
88
|
+
- app/presenters/hotwire_combobox/component/customizable.rb
|
88
89
|
- app/presenters/hotwire_combobox/listbox/option.rb
|
89
|
-
- app/views/hotwire_combobox/
|
90
|
+
- app/views/hotwire_combobox/_component.html.erb
|
90
91
|
- app/views/hotwire_combobox/_next_page.turbo_stream.erb
|
91
92
|
- app/views/hotwire_combobox/_paginated_options.turbo_stream.erb
|
92
93
|
- app/views/hotwire_combobox/_pagination.html.erb
|
@@ -1,6 +0,0 @@
|
|
1
|
-
<%= tag.fieldset **component.fieldset_attrs do %>
|
2
|
-
<%= render "hotwire_combobox/combobox/hidden_field", component: component %>
|
3
|
-
<%= render "hotwire_combobox/combobox/input", component: component %>
|
4
|
-
<%= render "hotwire_combobox/combobox/paginated_listbox", component: component %>
|
5
|
-
<%= render "hotwire_combobox/combobox/dialog", component: component %>
|
6
|
-
<% end %>
|