hotwire_combobox 0.1.38 → 0.1.40
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +1 -0
- data/app/assets/javascripts/hotwire_combobox.esm.js +90 -15
- data/app/assets/javascripts/hotwire_combobox.umd.js +90 -15
- data/app/assets/javascripts/hw_combobox/helpers.js +17 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +10 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/events.js +17 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +6 -1
- data/app/assets/javascripts/hw_combobox/models/combobox/options.js +7 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +17 -3
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +17 -11
- data/app/assets/javascripts/hw_combobox/models/combobox.js +1 -0
- data/app/presenters/hotwire_combobox/component.rb +8 -6
- data/app/presenters/hotwire_combobox/listbox/option.rb +2 -2
- data/lib/hotwire_combobox/version.rb +1 -1
- metadata +9 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e3b6e7d5c71ba2836a388709890b4402ab2b3ff4ab194a2d1ce75928d96b3886
|
4
|
+
data.tar.gz: 78a2543c2354e7dccfe0477288144845b6d06a7611c01204612fd24b53cb6bc9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d1e72f48fb5b0387b454afe6cfa7484b0e5c84dfe18000d025005558fc2e29232047f5179e82dbc41b7b48147f3b76d66ae72ff2d0f8c777340a1ab06a3ab9d8
|
7
|
+
data.tar.gz: 840447f162fba6892a920e74ddde5b862bb9ac4deac7d56948e62998470d19e9747112a066dfbd45db476c628d1910442d33b7f28b2fed242bc1de698a4a8a12
|
data/README.md
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
<img src="docs/assets/images/logo.png" height=150>
|
3
3
|
</p>
|
4
4
|
|
5
|
-
# Easy Autocomplete for Ruby on Rails
|
5
|
+
# Easy and Accessible Autocomplete for Ruby on Rails
|
6
6
|
|
7
7
|
[![CI Tests](https://github.com/josefarias/hotwire_combobox/actions/workflows/ci_tests.yml/badge.svg)](https://github.com/josefarias/hotwire_combobox/actions/workflows/ci_tests.yml) [![Gem Version](https://badge.fury.io/rb/hotwire_combobox.svg)](https://badge.fury.io/rb/hotwire_combobox)
|
8
8
|
|
@@ -104,6 +104,23 @@ function unselectedPortion(element) {
|
|
104
104
|
}
|
105
105
|
}
|
106
106
|
|
107
|
+
function dispatch(eventName, { target, cancelable, detail } = {}) {
|
108
|
+
const event = new CustomEvent(eventName, {
|
109
|
+
cancelable,
|
110
|
+
bubbles: true,
|
111
|
+
composed: true,
|
112
|
+
detail
|
113
|
+
});
|
114
|
+
|
115
|
+
if (target && target.isConnected) {
|
116
|
+
target.dispatchEvent(event);
|
117
|
+
} else {
|
118
|
+
document.documentElement.dispatchEvent(event);
|
119
|
+
}
|
120
|
+
|
121
|
+
return event
|
122
|
+
}
|
123
|
+
|
107
124
|
Combobox.Autocomplete = Base => class extends Base {
|
108
125
|
_connectListAutocomplete() {
|
109
126
|
if (!this._autocompletesList) {
|
@@ -157,6 +174,12 @@ Combobox.Autocomplete = Base => class extends Base {
|
|
157
174
|
};
|
158
175
|
|
159
176
|
Combobox.Dialog = Base => class extends Base {
|
177
|
+
rerouteListboxStreamToDialog({ detail: { newStream } }) {
|
178
|
+
if (newStream.target == this.listboxTarget.id && this._dialogIsOpen) {
|
179
|
+
newStream.setAttribute("target", this.dialogListboxTarget.id);
|
180
|
+
}
|
181
|
+
}
|
182
|
+
|
160
183
|
_connectDialog() {
|
161
184
|
if (window.visualViewport) {
|
162
185
|
window.visualViewport.addEventListener("resize", this._resizeDialog);
|
@@ -206,6 +229,25 @@ Combobox.Dialog = Base => class extends Base {
|
|
206
229
|
get _smallViewport() {
|
207
230
|
return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
|
208
231
|
}
|
232
|
+
|
233
|
+
get _dialogIsOpen() {
|
234
|
+
return this.dialogTarget.open
|
235
|
+
}
|
236
|
+
};
|
237
|
+
|
238
|
+
Combobox.Events = Base => class extends Base {
|
239
|
+
_dispatchSelectionEvent({ isNew }) {
|
240
|
+
const detail = {
|
241
|
+
value: this.hiddenFieldTarget.value,
|
242
|
+
display: this._fullQuery,
|
243
|
+
query: this._typedQuery,
|
244
|
+
fieldName: this.hiddenFieldTarget.name,
|
245
|
+
isValid: this._valueIsValid,
|
246
|
+
isNew: isNew
|
247
|
+
};
|
248
|
+
|
249
|
+
dispatch("hw-combobox:selection", { target: this.element, detail });
|
250
|
+
}
|
209
251
|
};
|
210
252
|
|
211
253
|
class FetchResponse {
|
@@ -523,11 +565,16 @@ Combobox.Filtering = Base => class extends Base {
|
|
523
565
|
this._selectNew();
|
524
566
|
} else if (isDeleteEvent(event)) {
|
525
567
|
this._deselect();
|
526
|
-
} else {
|
568
|
+
} else if (this._isOpen) {
|
527
569
|
this._select(this._visibleOptionElements[0]);
|
528
570
|
}
|
529
571
|
}
|
530
572
|
|
573
|
+
_clearQuery() {
|
574
|
+
this._fullQuery = "";
|
575
|
+
this.filter({ inputType: "deleteContentBackward" });
|
576
|
+
}
|
577
|
+
|
531
578
|
get _isQueried() {
|
532
579
|
return this._fullQuery.length > 0
|
533
580
|
}
|
@@ -647,12 +694,19 @@ Combobox.Options = Base => class extends Base {
|
|
647
694
|
get _selectedOptionIndex() {
|
648
695
|
return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement)
|
649
696
|
}
|
697
|
+
|
698
|
+
get _isUnjustifiablyBlank() {
|
699
|
+
const valueIsMissing = !this.hiddenFieldTarget.value;
|
700
|
+
const noBlankOptionSelected = !this._selectedOptionElement;
|
701
|
+
|
702
|
+
return valueIsMissing && noBlankOptionSelected
|
703
|
+
}
|
650
704
|
};
|
651
705
|
|
652
706
|
Combobox.Selection = Base => class extends Base {
|
653
|
-
|
654
|
-
this._select(event.currentTarget);
|
707
|
+
selectOptionOnClick(event) {
|
655
708
|
this.filter(event);
|
709
|
+
this._select(event.currentTarget);
|
656
710
|
this.close();
|
657
711
|
}
|
658
712
|
|
@@ -666,9 +720,9 @@ Combobox.Selection = Base => class extends Base {
|
|
666
720
|
this._resetOptions();
|
667
721
|
|
668
722
|
if (option) {
|
669
|
-
this._markValid();
|
670
723
|
this._autocompleteWith(option, { force: forceAutocomplete });
|
671
724
|
this._commitSelection(option, { selected: true });
|
725
|
+
this._markValid();
|
672
726
|
} else {
|
673
727
|
this._markInvalid();
|
674
728
|
}
|
@@ -681,6 +735,8 @@ Combobox.Selection = Base => class extends Base {
|
|
681
735
|
this.hiddenFieldTarget.value = option.dataset.value;
|
682
736
|
option.scrollIntoView({ block: "nearest" });
|
683
737
|
}
|
738
|
+
|
739
|
+
this._dispatchSelectionEvent({ isNew: false });
|
684
740
|
}
|
685
741
|
|
686
742
|
_markSelected(option, { selected }) {
|
@@ -694,15 +750,22 @@ Combobox.Selection = Base => class extends Base {
|
|
694
750
|
|
695
751
|
_deselect() {
|
696
752
|
const option = this._selectedOptionElement;
|
753
|
+
|
697
754
|
if (option) this._commitSelection(option, { selected: false });
|
755
|
+
|
698
756
|
this.hiddenFieldTarget.value = null;
|
699
757
|
this._setActiveDescendant("");
|
758
|
+
|
759
|
+
if (!option) this._dispatchSelectionEvent({ isNew: false });
|
700
760
|
}
|
701
761
|
|
702
762
|
_selectNew() {
|
703
763
|
this._resetOptions();
|
704
764
|
this.hiddenFieldTarget.value = this._fullQuery;
|
705
765
|
this.hiddenFieldTarget.name = this.nameWhenNewValue;
|
766
|
+
this._markValid();
|
767
|
+
|
768
|
+
this._dispatchSelectionEvent({ isNew: true });
|
706
769
|
}
|
707
770
|
|
708
771
|
_selectIndex(index) {
|
@@ -725,6 +788,11 @@ Combobox.Selection = Base => class extends Base {
|
|
725
788
|
this._select(this._ensurableOption, { forceAutocomplete: true });
|
726
789
|
this.filter({ inputType: "hw:lockInSelection" });
|
727
790
|
}
|
791
|
+
|
792
|
+
if (this._isUnjustifiablyBlank) {
|
793
|
+
this._deselect();
|
794
|
+
this._clearQuery();
|
795
|
+
}
|
728
796
|
}
|
729
797
|
|
730
798
|
_setActiveDescendant(id) {
|
@@ -1089,7 +1157,7 @@ Combobox.Toggle = Base => class extends Base {
|
|
1089
1157
|
_collapse() {
|
1090
1158
|
this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
|
1091
1159
|
|
1092
|
-
if (this.
|
1160
|
+
if (this._dialogIsOpen) {
|
1093
1161
|
this._closeInDialog();
|
1094
1162
|
} else {
|
1095
1163
|
this._closeInline();
|
@@ -1139,29 +1207,35 @@ Combobox.Validity = Base => class extends Base {
|
|
1139
1207
|
_markValid() {
|
1140
1208
|
if (this._valueIsInvalid) return
|
1141
1209
|
|
1142
|
-
|
1143
|
-
|
1144
|
-
|
1210
|
+
this._forAllComboboxes(combobox => {
|
1211
|
+
if (this.hasInvalidClass) {
|
1212
|
+
combobox.classList.remove(this.invalidClass);
|
1213
|
+
}
|
1145
1214
|
|
1146
|
-
|
1147
|
-
|
1215
|
+
combobox.removeAttribute("aria-invalid");
|
1216
|
+
combobox.removeAttribute("aria-errormessage");
|
1217
|
+
});
|
1148
1218
|
}
|
1149
1219
|
|
1150
1220
|
_markInvalid() {
|
1151
1221
|
if (this._valueIsValid) return
|
1152
1222
|
|
1153
|
-
|
1154
|
-
|
1155
|
-
|
1223
|
+
this._forAllComboboxes(combobox => {
|
1224
|
+
if (this.hasInvalidClass) {
|
1225
|
+
combobox.classList.add(this.invalidClass);
|
1226
|
+
}
|
1156
1227
|
|
1157
|
-
|
1158
|
-
|
1228
|
+
combobox.setAttribute("aria-invalid", true);
|
1229
|
+
combobox.setAttribute("aria-errormessage", `Please select a valid option for ${combobox.name}`);
|
1230
|
+
});
|
1159
1231
|
}
|
1160
1232
|
|
1161
1233
|
get _valueIsValid() {
|
1162
1234
|
return !this._valueIsInvalid
|
1163
1235
|
}
|
1164
1236
|
|
1237
|
+
// +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
|
1238
|
+
// because the `required` attribute is only forwarded to the `comboboxTarget` element
|
1165
1239
|
get _valueIsInvalid() {
|
1166
1240
|
const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
|
1167
1241
|
return isRequiredAndEmpty
|
@@ -1176,6 +1250,7 @@ const concerns = [
|
|
1176
1250
|
Combobox.AsyncLoading,
|
1177
1251
|
Combobox.Autocomplete,
|
1178
1252
|
Combobox.Dialog,
|
1253
|
+
Combobox.Events,
|
1179
1254
|
Combobox.Filtering,
|
1180
1255
|
Combobox.Navigation,
|
1181
1256
|
Combobox.NewOptions,
|
@@ -108,6 +108,23 @@
|
|
108
108
|
}
|
109
109
|
}
|
110
110
|
|
111
|
+
function dispatch(eventName, { target, cancelable, detail } = {}) {
|
112
|
+
const event = new CustomEvent(eventName, {
|
113
|
+
cancelable,
|
114
|
+
bubbles: true,
|
115
|
+
composed: true,
|
116
|
+
detail
|
117
|
+
});
|
118
|
+
|
119
|
+
if (target && target.isConnected) {
|
120
|
+
target.dispatchEvent(event);
|
121
|
+
} else {
|
122
|
+
document.documentElement.dispatchEvent(event);
|
123
|
+
}
|
124
|
+
|
125
|
+
return event
|
126
|
+
}
|
127
|
+
|
111
128
|
Combobox.Autocomplete = Base => class extends Base {
|
112
129
|
_connectListAutocomplete() {
|
113
130
|
if (!this._autocompletesList) {
|
@@ -161,6 +178,12 @@
|
|
161
178
|
};
|
162
179
|
|
163
180
|
Combobox.Dialog = Base => class extends Base {
|
181
|
+
rerouteListboxStreamToDialog({ detail: { newStream } }) {
|
182
|
+
if (newStream.target == this.listboxTarget.id && this._dialogIsOpen) {
|
183
|
+
newStream.setAttribute("target", this.dialogListboxTarget.id);
|
184
|
+
}
|
185
|
+
}
|
186
|
+
|
164
187
|
_connectDialog() {
|
165
188
|
if (window.visualViewport) {
|
166
189
|
window.visualViewport.addEventListener("resize", this._resizeDialog);
|
@@ -210,6 +233,25 @@
|
|
210
233
|
get _smallViewport() {
|
211
234
|
return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
|
212
235
|
}
|
236
|
+
|
237
|
+
get _dialogIsOpen() {
|
238
|
+
return this.dialogTarget.open
|
239
|
+
}
|
240
|
+
};
|
241
|
+
|
242
|
+
Combobox.Events = Base => class extends Base {
|
243
|
+
_dispatchSelectionEvent({ isNew }) {
|
244
|
+
const detail = {
|
245
|
+
value: this.hiddenFieldTarget.value,
|
246
|
+
display: this._fullQuery,
|
247
|
+
query: this._typedQuery,
|
248
|
+
fieldName: this.hiddenFieldTarget.name,
|
249
|
+
isValid: this._valueIsValid,
|
250
|
+
isNew: isNew
|
251
|
+
};
|
252
|
+
|
253
|
+
dispatch("hw-combobox:selection", { target: this.element, detail });
|
254
|
+
}
|
213
255
|
};
|
214
256
|
|
215
257
|
class FetchResponse {
|
@@ -527,11 +569,16 @@
|
|
527
569
|
this._selectNew();
|
528
570
|
} else if (isDeleteEvent(event)) {
|
529
571
|
this._deselect();
|
530
|
-
} else {
|
572
|
+
} else if (this._isOpen) {
|
531
573
|
this._select(this._visibleOptionElements[0]);
|
532
574
|
}
|
533
575
|
}
|
534
576
|
|
577
|
+
_clearQuery() {
|
578
|
+
this._fullQuery = "";
|
579
|
+
this.filter({ inputType: "deleteContentBackward" });
|
580
|
+
}
|
581
|
+
|
535
582
|
get _isQueried() {
|
536
583
|
return this._fullQuery.length > 0
|
537
584
|
}
|
@@ -651,12 +698,19 @@
|
|
651
698
|
get _selectedOptionIndex() {
|
652
699
|
return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement)
|
653
700
|
}
|
701
|
+
|
702
|
+
get _isUnjustifiablyBlank() {
|
703
|
+
const valueIsMissing = !this.hiddenFieldTarget.value;
|
704
|
+
const noBlankOptionSelected = !this._selectedOptionElement;
|
705
|
+
|
706
|
+
return valueIsMissing && noBlankOptionSelected
|
707
|
+
}
|
654
708
|
};
|
655
709
|
|
656
710
|
Combobox.Selection = Base => class extends Base {
|
657
|
-
|
658
|
-
this._select(event.currentTarget);
|
711
|
+
selectOptionOnClick(event) {
|
659
712
|
this.filter(event);
|
713
|
+
this._select(event.currentTarget);
|
660
714
|
this.close();
|
661
715
|
}
|
662
716
|
|
@@ -670,9 +724,9 @@
|
|
670
724
|
this._resetOptions();
|
671
725
|
|
672
726
|
if (option) {
|
673
|
-
this._markValid();
|
674
727
|
this._autocompleteWith(option, { force: forceAutocomplete });
|
675
728
|
this._commitSelection(option, { selected: true });
|
729
|
+
this._markValid();
|
676
730
|
} else {
|
677
731
|
this._markInvalid();
|
678
732
|
}
|
@@ -685,6 +739,8 @@
|
|
685
739
|
this.hiddenFieldTarget.value = option.dataset.value;
|
686
740
|
option.scrollIntoView({ block: "nearest" });
|
687
741
|
}
|
742
|
+
|
743
|
+
this._dispatchSelectionEvent({ isNew: false });
|
688
744
|
}
|
689
745
|
|
690
746
|
_markSelected(option, { selected }) {
|
@@ -698,15 +754,22 @@
|
|
698
754
|
|
699
755
|
_deselect() {
|
700
756
|
const option = this._selectedOptionElement;
|
757
|
+
|
701
758
|
if (option) this._commitSelection(option, { selected: false });
|
759
|
+
|
702
760
|
this.hiddenFieldTarget.value = null;
|
703
761
|
this._setActiveDescendant("");
|
762
|
+
|
763
|
+
if (!option) this._dispatchSelectionEvent({ isNew: false });
|
704
764
|
}
|
705
765
|
|
706
766
|
_selectNew() {
|
707
767
|
this._resetOptions();
|
708
768
|
this.hiddenFieldTarget.value = this._fullQuery;
|
709
769
|
this.hiddenFieldTarget.name = this.nameWhenNewValue;
|
770
|
+
this._markValid();
|
771
|
+
|
772
|
+
this._dispatchSelectionEvent({ isNew: true });
|
710
773
|
}
|
711
774
|
|
712
775
|
_selectIndex(index) {
|
@@ -729,6 +792,11 @@
|
|
729
792
|
this._select(this._ensurableOption, { forceAutocomplete: true });
|
730
793
|
this.filter({ inputType: "hw:lockInSelection" });
|
731
794
|
}
|
795
|
+
|
796
|
+
if (this._isUnjustifiablyBlank) {
|
797
|
+
this._deselect();
|
798
|
+
this._clearQuery();
|
799
|
+
}
|
732
800
|
}
|
733
801
|
|
734
802
|
_setActiveDescendant(id) {
|
@@ -1093,7 +1161,7 @@
|
|
1093
1161
|
_collapse() {
|
1094
1162
|
this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
|
1095
1163
|
|
1096
|
-
if (this.
|
1164
|
+
if (this._dialogIsOpen) {
|
1097
1165
|
this._closeInDialog();
|
1098
1166
|
} else {
|
1099
1167
|
this._closeInline();
|
@@ -1143,29 +1211,35 @@
|
|
1143
1211
|
_markValid() {
|
1144
1212
|
if (this._valueIsInvalid) return
|
1145
1213
|
|
1146
|
-
|
1147
|
-
|
1148
|
-
|
1214
|
+
this._forAllComboboxes(combobox => {
|
1215
|
+
if (this.hasInvalidClass) {
|
1216
|
+
combobox.classList.remove(this.invalidClass);
|
1217
|
+
}
|
1149
1218
|
|
1150
|
-
|
1151
|
-
|
1219
|
+
combobox.removeAttribute("aria-invalid");
|
1220
|
+
combobox.removeAttribute("aria-errormessage");
|
1221
|
+
});
|
1152
1222
|
}
|
1153
1223
|
|
1154
1224
|
_markInvalid() {
|
1155
1225
|
if (this._valueIsValid) return
|
1156
1226
|
|
1157
|
-
|
1158
|
-
|
1159
|
-
|
1227
|
+
this._forAllComboboxes(combobox => {
|
1228
|
+
if (this.hasInvalidClass) {
|
1229
|
+
combobox.classList.add(this.invalidClass);
|
1230
|
+
}
|
1160
1231
|
|
1161
|
-
|
1162
|
-
|
1232
|
+
combobox.setAttribute("aria-invalid", true);
|
1233
|
+
combobox.setAttribute("aria-errormessage", `Please select a valid option for ${combobox.name}`);
|
1234
|
+
});
|
1163
1235
|
}
|
1164
1236
|
|
1165
1237
|
get _valueIsValid() {
|
1166
1238
|
return !this._valueIsInvalid
|
1167
1239
|
}
|
1168
1240
|
|
1241
|
+
// +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
|
1242
|
+
// because the `required` attribute is only forwarded to the `comboboxTarget` element
|
1169
1243
|
get _valueIsInvalid() {
|
1170
1244
|
const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
|
1171
1245
|
return isRequiredAndEmpty
|
@@ -1180,6 +1254,7 @@
|
|
1180
1254
|
Combobox.AsyncLoading,
|
1181
1255
|
Combobox.Autocomplete,
|
1182
1256
|
Combobox.Dialog,
|
1257
|
+
Combobox.Events,
|
1183
1258
|
Combobox.Filtering,
|
1184
1259
|
Combobox.Navigation,
|
1185
1260
|
Combobox.NewOptions,
|
@@ -62,3 +62,20 @@ export function unselectedPortion(element) {
|
|
62
62
|
return element.value.substring(0, element.selectionStart)
|
63
63
|
}
|
64
64
|
}
|
65
|
+
|
66
|
+
export function dispatch(eventName, { target, cancelable, detail } = {}) {
|
67
|
+
const event = new CustomEvent(eventName, {
|
68
|
+
cancelable,
|
69
|
+
bubbles: true,
|
70
|
+
composed: true,
|
71
|
+
detail
|
72
|
+
})
|
73
|
+
|
74
|
+
if (target && target.isConnected) {
|
75
|
+
target.dispatchEvent(event)
|
76
|
+
} else {
|
77
|
+
document.documentElement.dispatchEvent(event)
|
78
|
+
}
|
79
|
+
|
80
|
+
return event
|
81
|
+
}
|
@@ -1,6 +1,12 @@
|
|
1
1
|
import Combobox from "hw_combobox/models/combobox/base"
|
2
2
|
|
3
3
|
Combobox.Dialog = Base => class extends Base {
|
4
|
+
rerouteListboxStreamToDialog({ detail: { newStream } }) {
|
5
|
+
if (newStream.target == this.listboxTarget.id && this._dialogIsOpen) {
|
6
|
+
newStream.setAttribute("target", this.dialogListboxTarget.id)
|
7
|
+
}
|
8
|
+
}
|
9
|
+
|
4
10
|
_connectDialog() {
|
5
11
|
if (window.visualViewport) {
|
6
12
|
window.visualViewport.addEventListener("resize", this._resizeDialog)
|
@@ -50,4 +56,8 @@ Combobox.Dialog = Base => class extends Base {
|
|
50
56
|
get _smallViewport() {
|
51
57
|
return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
|
52
58
|
}
|
59
|
+
|
60
|
+
get _dialogIsOpen() {
|
61
|
+
return this.dialogTarget.open
|
62
|
+
}
|
53
63
|
}
|
@@ -0,0 +1,17 @@
|
|
1
|
+
import Combobox from "hw_combobox/models/combobox/base"
|
2
|
+
import { dispatch } from "hw_combobox/helpers"
|
3
|
+
|
4
|
+
Combobox.Events = Base => class extends Base {
|
5
|
+
_dispatchSelectionEvent({ isNew }) {
|
6
|
+
const detail = {
|
7
|
+
value: this.hiddenFieldTarget.value,
|
8
|
+
display: this._fullQuery,
|
9
|
+
query: this._typedQuery,
|
10
|
+
fieldName: this.hiddenFieldTarget.name,
|
11
|
+
isValid: this._valueIsValid,
|
12
|
+
isNew: isNew
|
13
|
+
}
|
14
|
+
|
15
|
+
dispatch("hw-combobox:selection", { target: this.element, detail })
|
16
|
+
}
|
17
|
+
}
|
@@ -41,11 +41,16 @@ Combobox.Filtering = Base => class extends Base {
|
|
41
41
|
this._selectNew()
|
42
42
|
} else if (isDeleteEvent(event)) {
|
43
43
|
this._deselect()
|
44
|
-
} else {
|
44
|
+
} else if (this._isOpen) {
|
45
45
|
this._select(this._visibleOptionElements[0])
|
46
46
|
}
|
47
47
|
}
|
48
48
|
|
49
|
+
_clearQuery() {
|
50
|
+
this._fullQuery = ""
|
51
|
+
this.filter({ inputType: "deleteContentBackward" })
|
52
|
+
}
|
53
|
+
|
49
54
|
get _isQueried() {
|
50
55
|
return this._fullQuery.length > 0
|
51
56
|
}
|
@@ -30,4 +30,11 @@ Combobox.Options = Base => class extends Base {
|
|
30
30
|
get _selectedOptionIndex() {
|
31
31
|
return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement)
|
32
32
|
}
|
33
|
+
|
34
|
+
get _isUnjustifiablyBlank() {
|
35
|
+
const valueIsMissing = !this.hiddenFieldTarget.value
|
36
|
+
const noBlankOptionSelected = !this._selectedOptionElement
|
37
|
+
|
38
|
+
return valueIsMissing && noBlankOptionSelected
|
39
|
+
}
|
33
40
|
}
|
@@ -2,9 +2,9 @@ import Combobox from "hw_combobox/models/combobox/base"
|
|
2
2
|
import { wrapAroundAccess } from "hw_combobox/helpers"
|
3
3
|
|
4
4
|
Combobox.Selection = Base => class extends Base {
|
5
|
-
|
6
|
-
this._select(event.currentTarget)
|
5
|
+
selectOptionOnClick(event) {
|
7
6
|
this.filter(event)
|
7
|
+
this._select(event.currentTarget)
|
8
8
|
this.close()
|
9
9
|
}
|
10
10
|
|
@@ -18,9 +18,9 @@ Combobox.Selection = Base => class extends Base {
|
|
18
18
|
this._resetOptions()
|
19
19
|
|
20
20
|
if (option) {
|
21
|
-
this._markValid()
|
22
21
|
this._autocompleteWith(option, { force: forceAutocomplete })
|
23
22
|
this._commitSelection(option, { selected: true })
|
23
|
+
this._markValid()
|
24
24
|
} else {
|
25
25
|
this._markInvalid()
|
26
26
|
}
|
@@ -33,6 +33,8 @@ Combobox.Selection = Base => class extends Base {
|
|
33
33
|
this.hiddenFieldTarget.value = option.dataset.value
|
34
34
|
option.scrollIntoView({ block: "nearest" })
|
35
35
|
}
|
36
|
+
|
37
|
+
this._dispatchSelectionEvent({ isNew: false })
|
36
38
|
}
|
37
39
|
|
38
40
|
_markSelected(option, { selected }) {
|
@@ -46,15 +48,22 @@ Combobox.Selection = Base => class extends Base {
|
|
46
48
|
|
47
49
|
_deselect() {
|
48
50
|
const option = this._selectedOptionElement
|
51
|
+
|
49
52
|
if (option) this._commitSelection(option, { selected: false })
|
53
|
+
|
50
54
|
this.hiddenFieldTarget.value = null
|
51
55
|
this._setActiveDescendant("")
|
56
|
+
|
57
|
+
if (!option) this._dispatchSelectionEvent({ isNew: false })
|
52
58
|
}
|
53
59
|
|
54
60
|
_selectNew() {
|
55
61
|
this._resetOptions()
|
56
62
|
this.hiddenFieldTarget.value = this._fullQuery
|
57
63
|
this.hiddenFieldTarget.name = this.nameWhenNewValue
|
64
|
+
this._markValid()
|
65
|
+
|
66
|
+
this._dispatchSelectionEvent({ isNew: true })
|
58
67
|
}
|
59
68
|
|
60
69
|
_selectIndex(index) {
|
@@ -77,6 +86,11 @@ Combobox.Selection = Base => class extends Base {
|
|
77
86
|
this._select(this._ensurableOption, { forceAutocomplete: true })
|
78
87
|
this.filter({ inputType: "hw:lockInSelection" })
|
79
88
|
}
|
89
|
+
|
90
|
+
if (this._isUnjustifiablyBlank) {
|
91
|
+
this._deselect()
|
92
|
+
this._clearQuery()
|
93
|
+
}
|
80
94
|
}
|
81
95
|
|
82
96
|
_setActiveDescendant(id) {
|
@@ -72,7 +72,7 @@ Combobox.Toggle = Base => class extends Base {
|
|
72
72
|
_collapse() {
|
73
73
|
this._actingCombobox.setAttribute("aria-expanded", false) // needs to happen before resetting acting combobox
|
74
74
|
|
75
|
-
if (this.
|
75
|
+
if (this._dialogIsOpen) {
|
76
76
|
this._closeInDialog()
|
77
77
|
} else {
|
78
78
|
this._closeInline()
|
@@ -4,29 +4,35 @@ Combobox.Validity = Base => class extends Base {
|
|
4
4
|
_markValid() {
|
5
5
|
if (this._valueIsInvalid) return
|
6
6
|
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
7
|
+
this._forAllComboboxes(combobox => {
|
8
|
+
if (this.hasInvalidClass) {
|
9
|
+
combobox.classList.remove(this.invalidClass)
|
10
|
+
}
|
11
|
+
|
12
|
+
combobox.removeAttribute("aria-invalid")
|
13
|
+
combobox.removeAttribute("aria-errormessage")
|
14
|
+
})
|
13
15
|
}
|
14
16
|
|
15
17
|
_markInvalid() {
|
16
18
|
if (this._valueIsValid) return
|
17
19
|
|
18
|
-
|
19
|
-
|
20
|
-
|
20
|
+
this._forAllComboboxes(combobox => {
|
21
|
+
if (this.hasInvalidClass) {
|
22
|
+
combobox.classList.add(this.invalidClass)
|
23
|
+
}
|
21
24
|
|
22
|
-
|
23
|
-
|
25
|
+
combobox.setAttribute("aria-invalid", true)
|
26
|
+
combobox.setAttribute("aria-errormessage", `Please select a valid option for ${combobox.name}`)
|
27
|
+
})
|
24
28
|
}
|
25
29
|
|
26
30
|
get _valueIsValid() {
|
27
31
|
return !this._valueIsInvalid
|
28
32
|
}
|
29
33
|
|
34
|
+
// +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
|
35
|
+
// because the `required` attribute is only forwarded to the `comboboxTarget` element
|
30
36
|
get _valueIsInvalid() {
|
31
37
|
const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value
|
32
38
|
return isRequiredAndEmpty
|
@@ -4,6 +4,7 @@ import "hw_combobox/models/combobox/actors"
|
|
4
4
|
import "hw_combobox/models/combobox/async_loading"
|
5
5
|
import "hw_combobox/models/combobox/autocomplete"
|
6
6
|
import "hw_combobox/models/combobox/dialog"
|
7
|
+
import "hw_combobox/models/combobox/events"
|
7
8
|
import "hw_combobox/models/combobox/filtering"
|
8
9
|
import "hw_combobox/models/combobox/navigation"
|
9
10
|
import "hw_combobox/models/combobox/new_options"
|
@@ -150,7 +150,7 @@ class HotwireCombobox::Component
|
|
150
150
|
end
|
151
151
|
|
152
152
|
def fieldset_data
|
153
|
-
data.
|
153
|
+
data.merge \
|
154
154
|
async_id: canonical_id,
|
155
155
|
controller: view.token_list("hw-combobox", data[:controller]),
|
156
156
|
hw_combobox_expanded_value: open,
|
@@ -162,7 +162,8 @@ class HotwireCombobox::Component
|
|
162
162
|
hw_combobox_prefilled_display_value: prefilled_display,
|
163
163
|
hw_combobox_filterable_attribute_value: "data-filterable-as",
|
164
164
|
hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
|
165
|
-
hw_combobox_selected_class: "hw-combobox__option--selected"
|
165
|
+
hw_combobox_selected_class: "hw-combobox__option--selected",
|
166
|
+
hw_combobox_invalid_class: "hw-combobox__input--invalid"
|
166
167
|
end
|
167
168
|
|
168
169
|
def prefilled_display
|
@@ -189,7 +190,7 @@ class HotwireCombobox::Component
|
|
189
190
|
|
190
191
|
|
191
192
|
def canonical_id
|
192
|
-
id || form&.field_id(name) || SecureRandom.uuid
|
193
|
+
@canonical_id ||= id || form&.field_id(name) || SecureRandom.uuid
|
193
194
|
end
|
194
195
|
|
195
196
|
|
@@ -225,19 +226,20 @@ class HotwireCombobox::Component
|
|
225
226
|
end
|
226
227
|
|
227
228
|
def input_data
|
228
|
-
combobox_attrs.fetch(:data, {}).
|
229
|
+
combobox_attrs.fetch(:data, {}).merge \
|
229
230
|
action: "
|
230
231
|
focus->hw-combobox#open
|
231
232
|
input->hw-combobox#filter
|
232
233
|
keydown->hw-combobox#navigate
|
233
234
|
click@window->hw-combobox#closeOnClickOutside
|
234
|
-
focusin@window->hw-combobox#closeOnFocusOutside
|
235
|
+
focusin@window->hw-combobox#closeOnFocusOutside
|
236
|
+
turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog".squish,
|
235
237
|
hw_combobox_target: "combobox",
|
236
238
|
async_id: canonical_id
|
237
239
|
end
|
238
240
|
|
239
241
|
def input_aria
|
240
|
-
combobox_attrs.fetch(:aria, {}).
|
242
|
+
combobox_attrs.fetch(:aria, {}).merge \
|
241
243
|
controls: listbox_id,
|
242
244
|
owns: listbox_id,
|
243
245
|
haspopup: "listbox",
|
@@ -32,12 +32,12 @@ class HotwireCombobox::Listbox::Option
|
|
32
32
|
end
|
33
33
|
|
34
34
|
def id
|
35
|
-
option.try(:id) || SecureRandom.uuid
|
35
|
+
@id ||= option.try(:id) || SecureRandom.uuid
|
36
36
|
end
|
37
37
|
|
38
38
|
def data
|
39
39
|
{
|
40
|
-
action: "click->hw-combobox#
|
40
|
+
action: "click->hw-combobox#selectOptionOnClick",
|
41
41
|
filterable_as: filterable_as,
|
42
42
|
autocompletable_as: autocompletable_as,
|
43
43
|
value: value
|
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.40
|
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-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -52,8 +52,7 @@ dependencies:
|
|
52
52
|
- - ">="
|
53
53
|
- !ruby/object:Gem::Version
|
54
54
|
version: '1.2'
|
55
|
-
description: An autocomplete
|
56
|
-
Hotwire.
|
55
|
+
description: An accessible autocomplete for Ruby on Rails apps using Hotwire.
|
57
56
|
email:
|
58
57
|
- jose@farias.mx
|
59
58
|
executables: []
|
@@ -74,6 +73,7 @@ files:
|
|
74
73
|
- app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js
|
75
74
|
- app/assets/javascripts/hw_combobox/models/combobox/base.js
|
76
75
|
- app/assets/javascripts/hw_combobox/models/combobox/dialog.js
|
76
|
+
- app/assets/javascripts/hw_combobox/models/combobox/events.js
|
77
77
|
- app/assets/javascripts/hw_combobox/models/combobox/filtering.js
|
78
78
|
- app/assets/javascripts/hw_combobox/models/combobox/navigation.js
|
79
79
|
- app/assets/javascripts/hw_combobox/models/combobox/new_options.js
|
@@ -99,13 +99,13 @@ files:
|
|
99
99
|
- lib/hotwire_combobox/engine.rb
|
100
100
|
- lib/hotwire_combobox/helper.rb
|
101
101
|
- lib/hotwire_combobox/version.rb
|
102
|
-
homepage: https://
|
102
|
+
homepage: https://hotwirecombobox.com/
|
103
103
|
licenses:
|
104
104
|
- MIT
|
105
105
|
metadata:
|
106
|
-
homepage_uri: https://
|
106
|
+
homepage_uri: https://hotwirecombobox.com/
|
107
107
|
source_code_uri: https://github.com/josefarias/hotwire_combobox
|
108
|
-
changelog_uri: https://github.com/josefarias/hotwire_combobox
|
108
|
+
changelog_uri: https://github.com/josefarias/hotwire_combobox/releases
|
109
109
|
post_install_message:
|
110
110
|
rdoc_options: []
|
111
111
|
require_paths:
|
@@ -114,7 +114,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
114
114
|
requirements:
|
115
115
|
- - ">="
|
116
116
|
- !ruby/object:Gem::Version
|
117
|
-
version:
|
117
|
+
version: 2.7.0
|
118
118
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
119
119
|
requirements:
|
120
120
|
- - ">="
|
@@ -124,5 +124,5 @@ requirements: []
|
|
124
124
|
rubygems_version: 3.5.6
|
125
125
|
signing_key:
|
126
126
|
specification_version: 4
|
127
|
-
summary: Autocomplete for Rails apps
|
127
|
+
summary: Accessible Autocomplete for Rails apps
|
128
128
|
test_files: []
|