hotwire_combobox 0.1.41 → 0.1.43
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 +1 -1
- data/app/assets/javascripts/hotwire_combobox.esm.js +161 -85
- data/app/assets/javascripts/hotwire_combobox.umd.js +161 -85
- data/app/assets/javascripts/hw_combobox/models/combobox/async_loading.js +4 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +8 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/events.js +17 -7
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +19 -22
- data/app/assets/javascripts/hw_combobox/models/combobox/options.js +12 -4
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +95 -44
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +13 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
- data/app/presenters/hotwire_combobox/component.rb +2 -2
- data/lib/hotwire_combobox/engine.rb +2 -2
- data/lib/hotwire_combobox/helper.rb +26 -16
- data/lib/hotwire_combobox/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5d76e27ecc585f8ad3d4d596d2a6f8fe9916909734027d251cb35264604f192e
|
4
|
+
data.tar.gz: eda85b2dd3c21c407c806a5144c4d9b39c40a36c827c7d3b1986a5e27d432cf9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2cab9b83d4bf4caf3e741f5c12f336241e77f6eec0c97041f25162ecb4aade0d0c58b3ce59e5772b1e5edb89a2a6ad92051c8237f0d40bcee30e550e692add1b
|
7
|
+
data.tar.gz: 1d40dfa00ae3884e8c44693abfa542859ed27fb4fb124a90264c03a57ade1dcb31865f7d89078ea4304968f3adcaba51905d940c336a29ed860857fc5b6e5f40
|
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).
|
@@ -80,7 +80,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
|
|
80
80
|
|
81
81
|
if (inputType && inputType !== "hw:lockInSelection") {
|
82
82
|
if (delay) await sleep(delay)
|
83
|
-
this.
|
83
|
+
this._selectBasedOnQuery({ inputType })
|
84
84
|
} else {
|
85
85
|
this._preselectOption()
|
86
86
|
}
|
@@ -37,6 +37,10 @@ Combobox.AsyncLoading = Base => class extends Base {
|
|
37
37
|
get _isAsync() {
|
38
38
|
return this.hasAsyncSrcValue
|
39
39
|
}
|
40
|
+
|
41
|
+
get _isSync() {
|
42
|
+
return !this._isAsync
|
43
|
+
}
|
40
44
|
};
|
41
45
|
|
42
46
|
function Concerns(Base, ...mixins) {
|
@@ -128,16 +132,18 @@ Combobox.Autocomplete = Base => class extends Base {
|
|
128
132
|
}
|
129
133
|
}
|
130
134
|
|
131
|
-
|
132
|
-
|
135
|
+
_replaceFullQueryWithAutocompletedValue(option) {
|
136
|
+
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
|
137
|
+
|
138
|
+
this._fullQuery = autocompletedValue;
|
139
|
+
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
|
140
|
+
}
|
133
141
|
|
142
|
+
_autocompleteMissingPortion(option) {
|
134
143
|
const typedValue = this._typedQuery;
|
135
144
|
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
|
136
145
|
|
137
|
-
if (
|
138
|
-
this._fullQuery = autocompletedValue;
|
139
|
-
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
|
140
|
-
} else if (startsWith(autocompletedValue, typedValue)) {
|
146
|
+
if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
|
141
147
|
this._fullQuery = autocompletedValue;
|
142
148
|
this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
|
143
149
|
}
|
@@ -236,17 +242,27 @@ Combobox.Dialog = Base => class extends Base {
|
|
236
242
|
};
|
237
243
|
|
238
244
|
Combobox.Events = Base => class extends Base {
|
239
|
-
_dispatchSelectionEvent({
|
240
|
-
|
241
|
-
|
245
|
+
_dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
|
246
|
+
if (previousValue !== this._value) {
|
247
|
+
dispatch("hw-combobox:selection", {
|
248
|
+
target: this.element,
|
249
|
+
detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
|
250
|
+
});
|
251
|
+
}
|
252
|
+
}
|
253
|
+
|
254
|
+
_dispatchClosedEvent() {
|
255
|
+
dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
|
256
|
+
}
|
257
|
+
|
258
|
+
get _eventableDetails() {
|
259
|
+
return {
|
260
|
+
value: this._value,
|
242
261
|
display: this._fullQuery,
|
243
262
|
query: this._typedQuery,
|
244
263
|
fieldName: this.hiddenFieldTarget.name,
|
245
|
-
isValid: this._valueIsValid
|
246
|
-
|
247
|
-
};
|
248
|
-
|
249
|
-
dispatch("hw-combobox:selection", { target: this.element, detail });
|
264
|
+
isValid: this._valueIsValid
|
265
|
+
}
|
250
266
|
}
|
251
267
|
};
|
252
268
|
|
@@ -528,20 +544,28 @@ async function get(url, options) {
|
|
528
544
|
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
529
545
|
|
530
546
|
Combobox.Filtering = Base => class extends Base {
|
531
|
-
|
547
|
+
filterAndSelect(event) {
|
548
|
+
this._filter(event);
|
549
|
+
|
550
|
+
if (this._isSync) {
|
551
|
+
this._selectBasedOnQuery(event);
|
552
|
+
}
|
553
|
+
}
|
554
|
+
|
555
|
+
_initializeFiltering() {
|
556
|
+
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
|
557
|
+
}
|
558
|
+
|
559
|
+
_filter(event) {
|
532
560
|
if (this._isAsync) {
|
533
561
|
this._debouncedFilterAsync(event);
|
534
562
|
} else {
|
535
|
-
this._filterSync(
|
563
|
+
this._filterSync();
|
536
564
|
}
|
537
565
|
|
538
566
|
this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
|
539
567
|
}
|
540
568
|
|
541
|
-
_initializeFiltering() {
|
542
|
-
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
|
543
|
-
}
|
544
|
-
|
545
569
|
_debouncedFilterAsync(event) {
|
546
570
|
this._filterAsync(event);
|
547
571
|
}
|
@@ -556,27 +580,14 @@ Combobox.Filtering = Base => class extends Base {
|
|
556
580
|
await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
|
557
581
|
}
|
558
582
|
|
559
|
-
_filterSync(
|
583
|
+
_filterSync() {
|
560
584
|
this.open();
|
561
585
|
this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
|
562
|
-
this._commitFilter(event);
|
563
|
-
}
|
564
|
-
|
565
|
-
_commitFilter(event) {
|
566
|
-
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
|
567
|
-
this._selectNew();
|
568
|
-
} else if (isDeleteEvent(event)) {
|
569
|
-
this._deselect();
|
570
|
-
} else if (event.inputType === "hw:lockInSelection") {
|
571
|
-
this._select(this._ensurableOption);
|
572
|
-
} else if (this._isOpen) {
|
573
|
-
this._select(this._visibleOptionElements[0]);
|
574
|
-
}
|
575
586
|
}
|
576
587
|
|
577
588
|
_clearQuery() {
|
578
589
|
this._fullQuery = "";
|
579
|
-
this.
|
590
|
+
this.filterAndSelect({ inputType: "deleteContentBackward" });
|
580
591
|
}
|
581
592
|
|
582
593
|
get _isQueried() {
|
@@ -670,9 +681,17 @@ Combobox.NewOptions = Base => class extends Base {
|
|
670
681
|
};
|
671
682
|
|
672
683
|
Combobox.Options = Base => class extends Base {
|
673
|
-
|
674
|
-
this._deselect();
|
675
|
-
|
684
|
+
_resetOptionsSilently() {
|
685
|
+
this._resetOptions(this._deselect.bind(this));
|
686
|
+
}
|
687
|
+
|
688
|
+
_resetOptionsAndNotify() {
|
689
|
+
this._resetOptions(this._deselectAndNotify.bind(this));
|
690
|
+
}
|
691
|
+
|
692
|
+
_resetOptions(deselectionStrategy) {
|
693
|
+
this._setName(this.originalNameValue);
|
694
|
+
deselectionStrategy();
|
676
695
|
}
|
677
696
|
|
678
697
|
get _allowNew() {
|
@@ -700,7 +719,7 @@ Combobox.Options = Base => class extends Base {
|
|
700
719
|
}
|
701
720
|
|
702
721
|
get _isUnjustifiablyBlank() {
|
703
|
-
const valueIsMissing = !this.
|
722
|
+
const valueIsMissing = !this._value;
|
704
723
|
const noBlankOptionSelected = !this._selectedOptionElement;
|
705
724
|
|
706
725
|
return valueIsMissing && noBlankOptionSelected
|
@@ -709,8 +728,7 @@ Combobox.Options = Base => class extends Base {
|
|
709
728
|
|
710
729
|
Combobox.Selection = Base => class extends Base {
|
711
730
|
selectOptionOnClick(event) {
|
712
|
-
this.
|
713
|
-
this._select(event.currentTarget, { forceAutocomplete: true });
|
731
|
+
this._forceSelectionAndFilter(event.currentTarget, event);
|
714
732
|
this.close();
|
715
733
|
}
|
716
734
|
|
@@ -720,91 +738,136 @@ Combobox.Selection = Base => class extends Base {
|
|
720
738
|
}
|
721
739
|
}
|
722
740
|
|
723
|
-
|
724
|
-
this.
|
725
|
-
|
726
|
-
if (
|
727
|
-
this.
|
728
|
-
|
729
|
-
this.
|
730
|
-
} else {
|
741
|
+
_selectBasedOnQuery(event) {
|
742
|
+
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
|
743
|
+
this._selectNew();
|
744
|
+
} else if (isDeleteEvent(event)) {
|
745
|
+
this._deselect();
|
746
|
+
} else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
|
747
|
+
this._selectAndAutocompleteMissingPortion(this._ensurableOption);
|
748
|
+
} else if (this._isOpen && this._visibleOptionElements[0]) {
|
749
|
+
this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
|
750
|
+
} else if (this._isOpen) {
|
751
|
+
this._resetOptionsAndNotify();
|
731
752
|
this._markInvalid();
|
732
|
-
}
|
753
|
+
} else ;
|
733
754
|
}
|
734
755
|
|
735
|
-
|
736
|
-
|
756
|
+
_select(option, autocompleteStrategy) {
|
757
|
+
const previousValue = this._value;
|
737
758
|
|
738
|
-
|
739
|
-
|
740
|
-
|
741
|
-
}
|
759
|
+
this._resetOptionsSilently();
|
760
|
+
|
761
|
+
autocompleteStrategy(option);
|
742
762
|
|
743
|
-
this.
|
763
|
+
this._setValue(option.dataset.value);
|
764
|
+
this._markSelected(option);
|
765
|
+
this._markValid();
|
766
|
+
this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
|
767
|
+
|
768
|
+
option.scrollIntoView({ block: "nearest" });
|
744
769
|
}
|
745
770
|
|
746
|
-
|
747
|
-
|
748
|
-
option.classList.toggle(this.selectedClass, selected);
|
749
|
-
}
|
771
|
+
_selectNew() {
|
772
|
+
const previousValue = this._value;
|
750
773
|
|
751
|
-
|
752
|
-
this.
|
774
|
+
this._resetOptionsSilently();
|
775
|
+
this._setValue(this._fullQuery);
|
776
|
+
this._setName(this.nameWhenNewValue);
|
777
|
+
this._markValid();
|
778
|
+
this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
|
753
779
|
}
|
754
780
|
|
755
781
|
_deselect() {
|
756
|
-
const
|
782
|
+
const previousValue = this._value;
|
757
783
|
|
758
|
-
if (
|
784
|
+
if (this._selectedOptionElement) {
|
785
|
+
this._markNotSelected(this._selectedOptionElement);
|
786
|
+
}
|
759
787
|
|
760
|
-
this.
|
788
|
+
this._setValue(null);
|
761
789
|
this._setActiveDescendant("");
|
762
790
|
|
763
|
-
|
791
|
+
return previousValue
|
764
792
|
}
|
765
793
|
|
766
|
-
|
767
|
-
this.
|
768
|
-
this.
|
769
|
-
|
770
|
-
this._markValid();
|
794
|
+
_deselectAndNotify() {
|
795
|
+
const previousValue = this._deselect();
|
796
|
+
this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
|
797
|
+
}
|
771
798
|
|
772
|
-
|
799
|
+
_forceSelectionAndFilter(option, event) {
|
800
|
+
this._forceSelectionWithoutFiltering(option);
|
801
|
+
this._filter(event);
|
802
|
+
}
|
803
|
+
|
804
|
+
_forceSelectionWithoutFiltering(option) {
|
805
|
+
this._selectAndReplaceFullQuery(option);
|
773
806
|
}
|
774
807
|
|
775
808
|
_selectIndex(index) {
|
776
809
|
const option = wrapAroundAccess(this._visibleOptionElements, index);
|
777
|
-
this.
|
810
|
+
this._forceSelectionWithoutFiltering(option);
|
778
811
|
}
|
779
812
|
|
780
813
|
_preselectOption() {
|
781
814
|
if (this._hasValueButNoSelection && this._allOptions.length < 100) {
|
782
815
|
const option = this._allOptions.find(option => {
|
783
|
-
return option.dataset.value === this.
|
816
|
+
return option.dataset.value === this._value
|
784
817
|
});
|
785
818
|
|
786
|
-
if (option) this._markSelected(option
|
819
|
+
if (option) this._markSelected(option);
|
787
820
|
}
|
788
821
|
}
|
789
822
|
|
823
|
+
_selectAndReplaceFullQuery(option) {
|
824
|
+
this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
|
825
|
+
}
|
826
|
+
|
827
|
+
_selectAndAutocompleteMissingPortion(option) {
|
828
|
+
this._select(option, this._autocompleteMissingPortion.bind(this));
|
829
|
+
}
|
830
|
+
|
790
831
|
_lockInSelection() {
|
791
832
|
if (this._shouldLockInSelection) {
|
792
|
-
this.
|
793
|
-
this.filter({ inputType: "hw:lockInSelection" });
|
833
|
+
this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" });
|
794
834
|
}
|
835
|
+
}
|
795
836
|
|
796
|
-
|
797
|
-
|
798
|
-
|
799
|
-
|
837
|
+
_markSelected(option) {
|
838
|
+
if (this.hasSelectedClass) option.classList.add(this.selectedClass);
|
839
|
+
option.setAttribute("aria-selected", true);
|
840
|
+
this._setActiveDescendant(option.id);
|
841
|
+
}
|
842
|
+
|
843
|
+
_markNotSelected(option) {
|
844
|
+
if (this.hasSelectedClass) option.classList.remove(this.selectedClass);
|
845
|
+
option.removeAttribute("aria-selected");
|
846
|
+
this._removeActiveDescendant();
|
800
847
|
}
|
801
848
|
|
802
849
|
_setActiveDescendant(id) {
|
803
850
|
this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
|
804
851
|
}
|
805
852
|
|
853
|
+
_removeActiveDescendant() {
|
854
|
+
this._setActiveDescendant("");
|
855
|
+
}
|
856
|
+
|
857
|
+
_setValue(value) {
|
858
|
+
this.hiddenFieldTarget.value = value;
|
859
|
+
}
|
860
|
+
|
861
|
+
_setName(value) {
|
862
|
+
this.hiddenFieldTarget.name = value;
|
863
|
+
}
|
864
|
+
|
865
|
+
get _value() {
|
866
|
+
return this.hiddenFieldTarget.value
|
867
|
+
}
|
868
|
+
|
806
869
|
get _hasValueButNoSelection() {
|
807
|
-
return this.
|
870
|
+
return this._value && !this._selectedOptionElement
|
808
871
|
}
|
809
872
|
|
810
873
|
get _shouldLockInSelection() {
|
@@ -1098,7 +1161,11 @@ Combobox.Toggle = Base => class extends Base {
|
|
1098
1161
|
close() {
|
1099
1162
|
if (this._isOpen) {
|
1100
1163
|
this._lockInSelection();
|
1164
|
+
this._clearInvalidQuery();
|
1165
|
+
|
1101
1166
|
this.expandedValue = false;
|
1167
|
+
|
1168
|
+
this._dispatchClosedEvent();
|
1102
1169
|
}
|
1103
1170
|
}
|
1104
1171
|
|
@@ -1167,6 +1234,8 @@ Combobox.Toggle = Base => class extends Base {
|
|
1167
1234
|
this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
|
1168
1235
|
}
|
1169
1236
|
|
1237
|
+
// +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
|
1238
|
+
// it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
|
1170
1239
|
_collapse() {
|
1171
1240
|
this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
|
1172
1241
|
|
@@ -1207,6 +1276,13 @@ Combobox.Toggle = Base => class extends Base {
|
|
1207
1276
|
enableBodyScroll(this.dialogListboxTarget);
|
1208
1277
|
}
|
1209
1278
|
|
1279
|
+
_clearInvalidQuery() {
|
1280
|
+
if (this._isUnjustifiablyBlank) {
|
1281
|
+
this._deselect();
|
1282
|
+
this._clearQuery();
|
1283
|
+
}
|
1284
|
+
}
|
1285
|
+
|
1210
1286
|
get _isOpen() {
|
1211
1287
|
return this.expandedValue
|
1212
1288
|
}
|
@@ -1250,7 +1326,7 @@ Combobox.Validity = Base => class extends Base {
|
|
1250
1326
|
// +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
|
1251
1327
|
// because the `required` attribute is only forwarded to the `comboboxTarget` element
|
1252
1328
|
get _valueIsInvalid() {
|
1253
|
-
const isRequiredAndEmpty = this.comboboxTarget.required && !this.
|
1329
|
+
const isRequiredAndEmpty = this.comboboxTarget.required && !this._value;
|
1254
1330
|
return isRequiredAndEmpty
|
1255
1331
|
}
|
1256
1332
|
};
|
@@ -1333,7 +1409,7 @@ class HwComboboxController extends Concerns(...concerns) {
|
|
1333
1409
|
|
1334
1410
|
if (inputType && inputType !== "hw:lockInSelection") {
|
1335
1411
|
if (delay) await sleep(delay);
|
1336
|
-
this.
|
1412
|
+
this._selectBasedOnQuery({ inputType });
|
1337
1413
|
} else {
|
1338
1414
|
this._preselectOption();
|
1339
1415
|
}
|