hotwire_combobox 0.1.42 → 0.2.0
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 -0
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +25 -3
- data/app/assets/javascripts/hotwire_combobox.esm.js +531 -127
- data/app/assets/javascripts/hotwire_combobox.umd.js +531 -127
- 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/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/dialog.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +33 -28
- 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 +29 -9
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +103 -51
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +45 -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 +95 -28
- 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
@@ -1,3 +1,6 @@
|
|
1
|
+
/*!
|
2
|
+
HotwireCombobox 0.2.0
|
3
|
+
*/
|
1
4
|
(function (global, factory) {
|
2
5
|
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('@hotwired/stimulus')) :
|
3
6
|
typeof define === 'function' && define.amd ? define(['@hotwired/stimulus'], factory) :
|
@@ -37,10 +40,20 @@
|
|
37
40
|
}
|
38
41
|
};
|
39
42
|
|
43
|
+
Combobox.Announcements = Base => class extends Base {
|
44
|
+
_announceToScreenReader(display, action) {
|
45
|
+
this.announcerTarget.innerText = `${display} ${action}`;
|
46
|
+
}
|
47
|
+
};
|
48
|
+
|
40
49
|
Combobox.AsyncLoading = Base => class extends Base {
|
41
50
|
get _isAsync() {
|
42
51
|
return this.hasAsyncSrcValue
|
43
52
|
}
|
53
|
+
|
54
|
+
get _isSync() {
|
55
|
+
return !this._isAsync
|
56
|
+
}
|
44
57
|
};
|
45
58
|
|
46
59
|
function Concerns(Base, ...mixins) {
|
@@ -125,6 +138,22 @@
|
|
125
138
|
return event
|
126
139
|
}
|
127
140
|
|
141
|
+
function nextRepaint() {
|
142
|
+
if (document.visibilityState === "hidden") {
|
143
|
+
return nextEventLoopTick()
|
144
|
+
} else {
|
145
|
+
return nextAnimationFrame()
|
146
|
+
}
|
147
|
+
}
|
148
|
+
|
149
|
+
function nextAnimationFrame() {
|
150
|
+
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
|
151
|
+
}
|
152
|
+
|
153
|
+
function nextEventLoopTick() {
|
154
|
+
return new Promise((resolve) => setTimeout(() => resolve(), 0))
|
155
|
+
}
|
156
|
+
|
128
157
|
Combobox.Autocomplete = Base => class extends Base {
|
129
158
|
_connectListAutocomplete() {
|
130
159
|
if (!this._autocompletesList) {
|
@@ -132,16 +161,18 @@
|
|
132
161
|
}
|
133
162
|
}
|
134
163
|
|
135
|
-
|
136
|
-
|
164
|
+
_replaceFullQueryWithAutocompletedValue(option) {
|
165
|
+
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
|
166
|
+
|
167
|
+
this._fullQuery = autocompletedValue;
|
168
|
+
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
|
169
|
+
}
|
137
170
|
|
171
|
+
_autocompleteMissingPortion(option) {
|
138
172
|
const typedValue = this._typedQuery;
|
139
173
|
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
|
140
174
|
|
141
|
-
if (
|
142
|
-
this._fullQuery = autocompletedValue;
|
143
|
-
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
|
144
|
-
} else if (startsWith(autocompletedValue, typedValue)) {
|
175
|
+
if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
|
145
176
|
this._fullQuery = autocompletedValue;
|
146
177
|
this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
|
147
178
|
}
|
@@ -230,7 +261,7 @@
|
|
230
261
|
this.dialogFocusTrapTarget.focus();
|
231
262
|
}
|
232
263
|
|
233
|
-
get
|
264
|
+
get _isSmallViewport() {
|
234
265
|
return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
|
235
266
|
}
|
236
267
|
|
@@ -240,20 +271,35 @@
|
|
240
271
|
};
|
241
272
|
|
242
273
|
Combobox.Events = Base => class extends Base {
|
243
|
-
|
244
|
-
|
274
|
+
_dispatchPreselectionEvent({ isNewAndAllowed, previousValue }) {
|
275
|
+
if (previousValue === this._incomingFieldValueString) return
|
276
|
+
|
277
|
+
dispatch("hw-combobox:preselection", {
|
278
|
+
target: this.element,
|
279
|
+
detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
|
280
|
+
});
|
245
281
|
}
|
246
282
|
|
247
|
-
|
248
|
-
dispatch("hw-combobox:
|
283
|
+
_dispatchSelectionEvent() {
|
284
|
+
dispatch("hw-combobox:selection", {
|
285
|
+
target: this.element,
|
286
|
+
detail: this._eventableDetails
|
287
|
+
});
|
288
|
+
}
|
289
|
+
|
290
|
+
_dispatchRemovalEvent({ removedDisplay, removedValue }) {
|
291
|
+
dispatch("hw-combobox:removal", {
|
292
|
+
target: this.element,
|
293
|
+
detail: { ...this._eventableDetails, removedDisplay, removedValue }
|
294
|
+
});
|
249
295
|
}
|
250
296
|
|
251
297
|
get _eventableDetails() {
|
252
298
|
return {
|
253
|
-
value: this.
|
299
|
+
value: this._incomingFieldValueString,
|
254
300
|
display: this._fullQuery,
|
255
301
|
query: this._typedQuery,
|
256
|
-
fieldName: this.
|
302
|
+
fieldName: this._fieldName,
|
257
303
|
isValid: this._valueIsValid
|
258
304
|
}
|
259
305
|
}
|
@@ -537,55 +583,58 @@
|
|
537
583
|
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
538
584
|
|
539
585
|
Combobox.Filtering = Base => class extends Base {
|
540
|
-
|
541
|
-
|
542
|
-
this._debouncedFilterAsync(event);
|
543
|
-
} else {
|
544
|
-
this._filterSync(event);
|
545
|
-
}
|
586
|
+
filterAndSelect({ inputType }) {
|
587
|
+
this._filter(inputType);
|
546
588
|
|
547
|
-
|
589
|
+
if (this._isSync) {
|
590
|
+
this._selectOnQuery(inputType);
|
591
|
+
}
|
548
592
|
}
|
549
593
|
|
550
594
|
_initializeFiltering() {
|
551
595
|
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
|
552
596
|
}
|
553
597
|
|
554
|
-
|
555
|
-
this.
|
598
|
+
_filter(inputType) {
|
599
|
+
if (this._isAsync) {
|
600
|
+
this._debouncedFilterAsync(inputType);
|
601
|
+
} else {
|
602
|
+
this._filterSync();
|
603
|
+
}
|
604
|
+
|
605
|
+
this._markQueried();
|
606
|
+
}
|
607
|
+
|
608
|
+
_debouncedFilterAsync(inputType) {
|
609
|
+
this._filterAsync(inputType);
|
556
610
|
}
|
557
611
|
|
558
|
-
async _filterAsync(
|
612
|
+
async _filterAsync(inputType) {
|
559
613
|
const query = {
|
560
614
|
q: this._fullQuery,
|
561
|
-
input_type:
|
615
|
+
input_type: inputType,
|
562
616
|
for_id: this.element.dataset.asyncId
|
563
617
|
};
|
564
618
|
|
565
619
|
await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
|
566
620
|
}
|
567
621
|
|
568
|
-
_filterSync(
|
569
|
-
this.
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
|
576
|
-
this._selectNew();
|
577
|
-
} else if (isDeleteEvent(event)) {
|
578
|
-
this._deselect();
|
579
|
-
} else if (event.inputType === "hw:lockInSelection") {
|
580
|
-
this._select(this._ensurableOption);
|
581
|
-
} else if (this._isOpen) {
|
582
|
-
this._select(this._visibleOptionElements[0]);
|
583
|
-
}
|
622
|
+
_filterSync() {
|
623
|
+
this._allFilterableOptionElements.forEach(
|
624
|
+
applyFilter(
|
625
|
+
this._fullQuery,
|
626
|
+
{ matching: this.filterableAttributeValue }
|
627
|
+
)
|
628
|
+
);
|
584
629
|
}
|
585
630
|
|
586
631
|
_clearQuery() {
|
587
632
|
this._fullQuery = "";
|
588
|
-
this.
|
633
|
+
this.filterAndSelect({ inputType: "deleteContentBackward" });
|
634
|
+
}
|
635
|
+
|
636
|
+
_markQueried() {
|
637
|
+
this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
|
589
638
|
}
|
590
639
|
|
591
640
|
get _isQueried() {
|
@@ -605,20 +654,255 @@
|
|
605
654
|
}
|
606
655
|
};
|
607
656
|
|
657
|
+
Combobox.FormField = Base => class extends Base {
|
658
|
+
get _fieldValue() {
|
659
|
+
if (this._isMultiselect) {
|
660
|
+
const currentValue = this.hiddenFieldTarget.value;
|
661
|
+
const arrayFromValue = currentValue ? currentValue.split(",") : [];
|
662
|
+
|
663
|
+
return new Set(arrayFromValue)
|
664
|
+
} else {
|
665
|
+
return this.hiddenFieldTarget.value
|
666
|
+
}
|
667
|
+
}
|
668
|
+
|
669
|
+
get _fieldValueString() {
|
670
|
+
if (this._isMultiselect) {
|
671
|
+
return this._fieldValueArray.join(",")
|
672
|
+
} else {
|
673
|
+
return this.hiddenFieldTarget.value
|
674
|
+
}
|
675
|
+
}
|
676
|
+
|
677
|
+
get _incomingFieldValueString() {
|
678
|
+
if (this._isMultiselect) {
|
679
|
+
const array = this._fieldValueArray;
|
680
|
+
|
681
|
+
if (this.hiddenFieldTarget.dataset.valueForMultiselect) {
|
682
|
+
array.push(this.hiddenFieldTarget.dataset.valueForMultiselect);
|
683
|
+
}
|
684
|
+
|
685
|
+
return array.join(",")
|
686
|
+
} else {
|
687
|
+
return this.hiddenFieldTarget.value
|
688
|
+
}
|
689
|
+
}
|
690
|
+
|
691
|
+
get _fieldValueArray() {
|
692
|
+
if (this._isMultiselect) {
|
693
|
+
return Array.from(this._fieldValue)
|
694
|
+
} else {
|
695
|
+
return [ this.hiddenFieldTarget.value ]
|
696
|
+
}
|
697
|
+
}
|
698
|
+
|
699
|
+
set _fieldValue(value) {
|
700
|
+
if (this._isMultiselect) {
|
701
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect = value?.replace(/,/g, "");
|
702
|
+
this.hiddenFieldTarget.dataset.displayForMultiselect = this._fullQuery;
|
703
|
+
} else {
|
704
|
+
this.hiddenFieldTarget.value = value;
|
705
|
+
}
|
706
|
+
}
|
707
|
+
|
708
|
+
get _hasEmptyFieldValue() {
|
709
|
+
if (this._isMultiselect) {
|
710
|
+
return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
|
711
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
|
712
|
+
} else {
|
713
|
+
return this.hiddenFieldTarget.value === ""
|
714
|
+
}
|
715
|
+
}
|
716
|
+
|
717
|
+
get _hasFieldValue() {
|
718
|
+
return !this._hasEmptyFieldValue
|
719
|
+
}
|
720
|
+
|
721
|
+
get _fieldName() {
|
722
|
+
return this.hiddenFieldTarget.name
|
723
|
+
}
|
724
|
+
|
725
|
+
set _fieldName(value) {
|
726
|
+
this.hiddenFieldTarget.name = value;
|
727
|
+
}
|
728
|
+
};
|
729
|
+
|
730
|
+
Combobox.Multiselect = Base => class extends Base {
|
731
|
+
navigateChip(event) {
|
732
|
+
this._chipKeyHandlers[event.key]?.call(this, event);
|
733
|
+
}
|
734
|
+
|
735
|
+
removeChip({ currentTarget, params }) {
|
736
|
+
let display;
|
737
|
+
const option = this._optionElementWithValue(params.value);
|
738
|
+
|
739
|
+
if (option) {
|
740
|
+
display = option.getAttribute(this.autocompletableAttributeValue);
|
741
|
+
this._markNotSelected(option);
|
742
|
+
this._markNotMultiselected(option);
|
743
|
+
} else {
|
744
|
+
display = params.value; // for new options
|
745
|
+
}
|
746
|
+
|
747
|
+
this._removeFromFieldValue(params.value);
|
748
|
+
this._filter("hw:multiselectSync");
|
749
|
+
|
750
|
+
currentTarget.closest("[data-hw-combobox-chip]").remove();
|
751
|
+
|
752
|
+
if (!this._isSmallViewport) {
|
753
|
+
this.openByFocusing();
|
754
|
+
}
|
755
|
+
|
756
|
+
this._announceToScreenReader(display, "removed");
|
757
|
+
this._dispatchRemovalEvent({ removedDisplay: display, removedValue: params.value });
|
758
|
+
}
|
759
|
+
|
760
|
+
hideChipsForCache() {
|
761
|
+
this.element.querySelectorAll("[data-hw-combobox-chip]").forEach(chip => chip.hidden = true);
|
762
|
+
}
|
763
|
+
|
764
|
+
_chipKeyHandlers = {
|
765
|
+
Backspace: (event) => {
|
766
|
+
this.removeChip(event);
|
767
|
+
cancel(event);
|
768
|
+
},
|
769
|
+
Enter: (event) => {
|
770
|
+
this.removeChip(event);
|
771
|
+
cancel(event);
|
772
|
+
},
|
773
|
+
Space: (event) => {
|
774
|
+
this.removeChip(event);
|
775
|
+
cancel(event);
|
776
|
+
},
|
777
|
+
Escape: (event) => {
|
778
|
+
this.openByFocusing();
|
779
|
+
cancel(event);
|
780
|
+
}
|
781
|
+
}
|
782
|
+
|
783
|
+
_initializeMultiselect() {
|
784
|
+
if (!this._isMultiPreselected) {
|
785
|
+
this._preselectMultiple();
|
786
|
+
this._markMultiPreselected();
|
787
|
+
}
|
788
|
+
}
|
789
|
+
|
790
|
+
async _createChip(shouldReopen) {
|
791
|
+
if (!this._isMultiselect) return
|
792
|
+
|
793
|
+
this._beforeClearingMultiselectQuery(async (display, value) => {
|
794
|
+
this._fullQuery = "";
|
795
|
+
this._filter("hw:multiselectSync");
|
796
|
+
this._requestChips(value);
|
797
|
+
this._addToFieldValue(value);
|
798
|
+
if (shouldReopen) {
|
799
|
+
await nextRepaint();
|
800
|
+
this.openByFocusing();
|
801
|
+
}
|
802
|
+
this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.");
|
803
|
+
});
|
804
|
+
}
|
805
|
+
|
806
|
+
async _requestChips(values) {
|
807
|
+
await get(this.selectionChipSrcValue, {
|
808
|
+
responseKind: "turbo-stream",
|
809
|
+
query: {
|
810
|
+
for_id: this.element.dataset.asyncId,
|
811
|
+
combobox_values: values
|
812
|
+
}
|
813
|
+
});
|
814
|
+
}
|
815
|
+
|
816
|
+
_beforeClearingMultiselectQuery(callback) {
|
817
|
+
const display = this.hiddenFieldTarget.dataset.displayForMultiselect;
|
818
|
+
const value = this.hiddenFieldTarget.dataset.valueForMultiselect;
|
819
|
+
|
820
|
+
if (value && !this._fieldValue.has(value)) {
|
821
|
+
callback(display, value);
|
822
|
+
}
|
823
|
+
|
824
|
+
this.hiddenFieldTarget.dataset.displayForMultiselect = "";
|
825
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect = "";
|
826
|
+
}
|
827
|
+
|
828
|
+
_resetMultiselectionMarks() {
|
829
|
+
if (!this._isMultiselect) return
|
830
|
+
|
831
|
+
this._fieldValueArray.forEach(value => {
|
832
|
+
const option = this._optionElementWithValue(value);
|
833
|
+
|
834
|
+
if (option) {
|
835
|
+
option.setAttribute("data-multiselected", "");
|
836
|
+
option.hidden = true;
|
837
|
+
}
|
838
|
+
});
|
839
|
+
}
|
840
|
+
|
841
|
+
_markNotMultiselected(option) {
|
842
|
+
if (!this._isMultiselect) return
|
843
|
+
|
844
|
+
option.removeAttribute("data-multiselected");
|
845
|
+
option.hidden = false;
|
846
|
+
}
|
847
|
+
|
848
|
+
_addToFieldValue(value) {
|
849
|
+
const newValue = this._fieldValue;
|
850
|
+
|
851
|
+
newValue.add(String(value));
|
852
|
+
this.hiddenFieldTarget.value = Array.from(newValue).join(",");
|
853
|
+
|
854
|
+
if (this._isSync) this._resetMultiselectionMarks();
|
855
|
+
}
|
856
|
+
|
857
|
+
_removeFromFieldValue(value) {
|
858
|
+
const newValue = this._fieldValue;
|
859
|
+
|
860
|
+
newValue.delete(String(value));
|
861
|
+
this.hiddenFieldTarget.value = Array.from(newValue).join(",");
|
862
|
+
|
863
|
+
if (this._isSync) this._resetMultiselectionMarks();
|
864
|
+
}
|
865
|
+
|
866
|
+
_focusLastChipDismisser() {
|
867
|
+
this.chipDismisserTargets[this.chipDismisserTargets.length - 1]?.focus();
|
868
|
+
}
|
869
|
+
|
870
|
+
_markMultiPreselected() {
|
871
|
+
this.element.dataset.multiPreselected = "";
|
872
|
+
}
|
873
|
+
|
874
|
+
get _isMultiselect() {
|
875
|
+
return this.hasSelectionChipSrcValue
|
876
|
+
}
|
877
|
+
|
878
|
+
get _isSingleSelect() {
|
879
|
+
return !this._isMultiselect
|
880
|
+
}
|
881
|
+
|
882
|
+
get _isMultiPreselected() {
|
883
|
+
return this.element.hasAttribute("data-multi-preselected")
|
884
|
+
}
|
885
|
+
};
|
886
|
+
|
608
887
|
Combobox.Navigation = Base => class extends Base {
|
609
888
|
navigate(event) {
|
610
889
|
if (this._autocompletesList) {
|
611
|
-
this.
|
890
|
+
this._navigationKeyHandlers[event.key]?.call(this, event);
|
612
891
|
}
|
613
892
|
}
|
614
893
|
|
615
|
-
|
894
|
+
_navigationKeyHandlers = {
|
616
895
|
ArrowUp: (event) => {
|
617
896
|
this._selectIndex(this._selectedOptionIndex - 1);
|
618
897
|
cancel(event);
|
619
898
|
},
|
620
899
|
ArrowDown: (event) => {
|
621
900
|
this._selectIndex(this._selectedOptionIndex + 1);
|
901
|
+
|
902
|
+
if (this._selectedOptionIndex === 0) {
|
903
|
+
this._actingListbox.scrollTop = 0;
|
904
|
+
}
|
905
|
+
|
622
906
|
cancel(event);
|
623
907
|
},
|
624
908
|
Home: (event) => {
|
@@ -630,14 +914,18 @@
|
|
630
914
|
cancel(event);
|
631
915
|
},
|
632
916
|
Enter: (event) => {
|
633
|
-
this.
|
634
|
-
this._actingCombobox.blur();
|
917
|
+
this._closeAndBlur("hw:keyHandler:enter");
|
635
918
|
cancel(event);
|
636
919
|
},
|
637
920
|
Escape: (event) => {
|
638
|
-
this.
|
639
|
-
this._actingCombobox.blur();
|
921
|
+
this._closeAndBlur("hw:keyHandler:escape");
|
640
922
|
cancel(event);
|
923
|
+
},
|
924
|
+
Backspace: (event) => {
|
925
|
+
if (this._isMultiselect && !this._fullQuery) {
|
926
|
+
this._focusLastChipDismisser();
|
927
|
+
cancel(event);
|
928
|
+
}
|
641
929
|
}
|
642
930
|
}
|
643
931
|
};
|
@@ -679,9 +967,25 @@
|
|
679
967
|
};
|
680
968
|
|
681
969
|
Combobox.Options = Base => class extends Base {
|
682
|
-
|
683
|
-
this._deselect();
|
684
|
-
|
970
|
+
_resetOptionsSilently() {
|
971
|
+
this._resetOptions(this._deselect.bind(this));
|
972
|
+
}
|
973
|
+
|
974
|
+
_resetOptionsAndNotify() {
|
975
|
+
this._resetOptions(this._deselectAndNotify.bind(this));
|
976
|
+
}
|
977
|
+
|
978
|
+
_resetOptions(deselectionStrategy) {
|
979
|
+
this._fieldName = this.originalNameValue;
|
980
|
+
deselectionStrategy();
|
981
|
+
}
|
982
|
+
|
983
|
+
_optionElementWithValue(value) {
|
984
|
+
return this._actingListbox.querySelector(`[${this.filterableAttributeValue}][data-value='${value}']`)
|
985
|
+
}
|
986
|
+
|
987
|
+
_displayForOptionElement(element) {
|
988
|
+
return element.getAttribute(this.autocompletableAttributeValue)
|
685
989
|
}
|
686
990
|
|
687
991
|
get _allowNew() {
|
@@ -689,19 +993,23 @@
|
|
689
993
|
}
|
690
994
|
|
691
995
|
get _allOptions() {
|
692
|
-
return Array.from(this.
|
996
|
+
return Array.from(this._allFilterableOptionElements)
|
693
997
|
}
|
694
998
|
|
695
|
-
get
|
696
|
-
return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
|
999
|
+
get _allFilterableOptionElements() {
|
1000
|
+
return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`)
|
697
1001
|
}
|
698
1002
|
|
699
1003
|
get _visibleOptionElements() {
|
700
|
-
return [ ...this.
|
1004
|
+
return [ ...this._allFilterableOptionElements ].filter(visible)
|
701
1005
|
}
|
702
1006
|
|
703
1007
|
get _selectedOptionElement() {
|
704
|
-
return this._actingListbox.querySelector("[role=option][aria-selected=true]")
|
1008
|
+
return this._actingListbox.querySelector("[role=option][aria-selected=true]:not([data-multiselected])")
|
1009
|
+
}
|
1010
|
+
|
1011
|
+
get _multiselectedOptionElements() {
|
1012
|
+
return this._actingListbox.querySelectorAll("[role=option][data-multiselected]")
|
705
1013
|
}
|
706
1014
|
|
707
1015
|
get _selectedOptionIndex() {
|
@@ -709,7 +1017,7 @@
|
|
709
1017
|
}
|
710
1018
|
|
711
1019
|
get _isUnjustifiablyBlank() {
|
712
|
-
const valueIsMissing =
|
1020
|
+
const valueIsMissing = this._hasEmptyFieldValue;
|
713
1021
|
const noBlankOptionSelected = !this._selectedOptionElement;
|
714
1022
|
|
715
1023
|
return valueIsMissing && noBlankOptionSelected
|
@@ -717,103 +1025,148 @@
|
|
717
1025
|
};
|
718
1026
|
|
719
1027
|
Combobox.Selection = Base => class extends Base {
|
720
|
-
|
721
|
-
this.
|
722
|
-
this.
|
723
|
-
this.close();
|
1028
|
+
selectOnClick({ currentTarget, inputType }) {
|
1029
|
+
this._forceSelectionAndFilter(currentTarget, inputType);
|
1030
|
+
this._closeAndBlur("hw:optionRoleClick");
|
724
1031
|
}
|
725
1032
|
|
726
1033
|
_connectSelection() {
|
727
1034
|
if (this.hasPrefilledDisplayValue) {
|
728
1035
|
this._fullQuery = this.prefilledDisplayValue;
|
1036
|
+
this._markQueried();
|
729
1037
|
}
|
730
1038
|
}
|
731
1039
|
|
732
|
-
|
733
|
-
this.
|
734
|
-
|
735
|
-
if (
|
736
|
-
this.
|
737
|
-
|
738
|
-
this.
|
739
|
-
} else {
|
1040
|
+
_selectOnQuery(inputType) {
|
1041
|
+
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
|
1042
|
+
this._selectNew();
|
1043
|
+
} else if (isDeleteEvent({ inputType: inputType })) {
|
1044
|
+
this._deselect();
|
1045
|
+
} else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
|
1046
|
+
this._selectAndAutocompleteMissingPortion(this._ensurableOption);
|
1047
|
+
} else if (this._isOpen && this._visibleOptionElements[0]) {
|
1048
|
+
this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
|
1049
|
+
} else if (this._isOpen) {
|
1050
|
+
this._resetOptionsAndNotify();
|
740
1051
|
this._markInvalid();
|
741
|
-
}
|
1052
|
+
} else ;
|
742
1053
|
}
|
743
1054
|
|
744
|
-
|
745
|
-
|
1055
|
+
_select(option, autocompleteStrategy) {
|
1056
|
+
const previousValue = this._fieldValueString;
|
746
1057
|
|
747
|
-
|
748
|
-
|
749
|
-
|
750
|
-
}
|
1058
|
+
this._resetOptionsSilently();
|
1059
|
+
|
1060
|
+
autocompleteStrategy(option);
|
751
1061
|
|
752
|
-
this.
|
1062
|
+
this._fieldValue = option.dataset.value;
|
1063
|
+
this._markSelected(option);
|
1064
|
+
this._markValid();
|
1065
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
|
1066
|
+
|
1067
|
+
option.scrollIntoView({ block: "nearest" });
|
753
1068
|
}
|
754
1069
|
|
755
|
-
|
756
|
-
|
757
|
-
option.classList.toggle(this.selectedClass, selected);
|
758
|
-
}
|
1070
|
+
_selectNew() {
|
1071
|
+
const previousValue = this._fieldValueString;
|
759
1072
|
|
760
|
-
|
761
|
-
this.
|
1073
|
+
this._resetOptionsSilently();
|
1074
|
+
this._fieldValue = this._fullQuery;
|
1075
|
+
this._fieldName = this.nameWhenNewValue;
|
1076
|
+
this._markValid();
|
1077
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
|
762
1078
|
}
|
763
1079
|
|
764
1080
|
_deselect() {
|
765
|
-
const
|
1081
|
+
const previousValue = this._fieldValueString;
|
766
1082
|
|
767
|
-
if (
|
1083
|
+
if (this._selectedOptionElement) {
|
1084
|
+
this._markNotSelected(this._selectedOptionElement);
|
1085
|
+
}
|
768
1086
|
|
769
|
-
this.
|
1087
|
+
this._fieldValue = "";
|
770
1088
|
this._setActiveDescendant("");
|
771
1089
|
|
772
|
-
|
1090
|
+
return previousValue
|
773
1091
|
}
|
774
1092
|
|
775
|
-
|
776
|
-
this.
|
777
|
-
this.
|
778
|
-
this.hiddenFieldTarget.name = this.nameWhenNewValue;
|
779
|
-
this._markValid();
|
780
|
-
|
781
|
-
this._dispatchSelectionEvent({ isNew: true });
|
1093
|
+
_deselectAndNotify() {
|
1094
|
+
const previousValue = this._deselect();
|
1095
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
|
782
1096
|
}
|
783
1097
|
|
784
1098
|
_selectIndex(index) {
|
785
1099
|
const option = wrapAroundAccess(this._visibleOptionElements, index);
|
786
|
-
this.
|
1100
|
+
this._forceSelectionWithoutFiltering(option);
|
787
1101
|
}
|
788
1102
|
|
789
|
-
|
790
|
-
if (this._hasValueButNoSelection && this._allOptions.length < 100) {
|
791
|
-
const option = this.
|
792
|
-
|
793
|
-
|
1103
|
+
_preselectSingle() {
|
1104
|
+
if (this._isSingleSelect && this._hasValueButNoSelection && this._allOptions.length < 100) {
|
1105
|
+
const option = this._optionElementWithValue(this._fieldValue);
|
1106
|
+
if (option) this._markSelected(option);
|
1107
|
+
}
|
1108
|
+
}
|
794
1109
|
|
795
|
-
|
1110
|
+
_preselectMultiple() {
|
1111
|
+
if (this._isMultiselect && this._hasValueButNoSelection) {
|
1112
|
+
this._requestChips(this._fieldValueString);
|
1113
|
+
this._resetMultiselectionMarks();
|
796
1114
|
}
|
797
1115
|
}
|
798
1116
|
|
1117
|
+
_selectAndAutocompleteMissingPortion(option) {
|
1118
|
+
this._select(option, this._autocompleteMissingPortion.bind(this));
|
1119
|
+
}
|
1120
|
+
|
1121
|
+
_selectAndAutocompleteFullQuery(option) {
|
1122
|
+
this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
|
1123
|
+
}
|
1124
|
+
|
1125
|
+
_forceSelectionAndFilter(option, inputType) {
|
1126
|
+
this._forceSelectionWithoutFiltering(option);
|
1127
|
+
this._filter(inputType);
|
1128
|
+
}
|
1129
|
+
|
1130
|
+
_forceSelectionWithoutFiltering(option) {
|
1131
|
+
this._selectAndAutocompleteFullQuery(option);
|
1132
|
+
}
|
1133
|
+
|
799
1134
|
_lockInSelection() {
|
800
1135
|
if (this._shouldLockInSelection) {
|
801
|
-
this.
|
802
|
-
this.filter({ inputType: "hw:lockInSelection" });
|
1136
|
+
this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection");
|
803
1137
|
}
|
1138
|
+
}
|
804
1139
|
|
805
|
-
|
806
|
-
|
807
|
-
|
808
|
-
|
1140
|
+
_markSelected(option) {
|
1141
|
+
if (this.hasSelectedClass) option.classList.add(this.selectedClass);
|
1142
|
+
option.setAttribute("aria-selected", true);
|
1143
|
+
this._setActiveDescendant(option.id);
|
1144
|
+
}
|
1145
|
+
|
1146
|
+
_markNotSelected(option) {
|
1147
|
+
if (this.hasSelectedClass) option.classList.remove(this.selectedClass);
|
1148
|
+
option.removeAttribute("aria-selected");
|
1149
|
+
this._removeActiveDescendant();
|
809
1150
|
}
|
810
1151
|
|
811
1152
|
_setActiveDescendant(id) {
|
812
1153
|
this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
|
813
1154
|
}
|
814
1155
|
|
1156
|
+
_removeActiveDescendant() {
|
1157
|
+
this._setActiveDescendant("");
|
1158
|
+
}
|
1159
|
+
|
815
1160
|
get _hasValueButNoSelection() {
|
816
|
-
return this.
|
1161
|
+
return this._hasFieldValue && !this._hasSelection
|
1162
|
+
}
|
1163
|
+
|
1164
|
+
get _hasSelection() {
|
1165
|
+
if (this._isSingleSelect) {
|
1166
|
+
this._selectedOptionElement;
|
1167
|
+
} else {
|
1168
|
+
this._multiselectedOptionElements.length > 0;
|
1169
|
+
}
|
817
1170
|
}
|
818
1171
|
|
819
1172
|
get _shouldLockInSelection() {
|
@@ -1104,19 +1457,40 @@
|
|
1104
1457
|
this.expandedValue = true;
|
1105
1458
|
}
|
1106
1459
|
|
1107
|
-
|
1460
|
+
openByFocusing() {
|
1461
|
+
this._actingCombobox.focus();
|
1462
|
+
}
|
1463
|
+
|
1464
|
+
close(inputType) {
|
1108
1465
|
if (this._isOpen) {
|
1466
|
+
const shouldReopen = this._isMultiselect &&
|
1467
|
+
this._isSync &&
|
1468
|
+
!this._isSmallViewport &&
|
1469
|
+
inputType != "hw:clickOutside" &&
|
1470
|
+
inputType != "hw:focusOutside";
|
1471
|
+
|
1109
1472
|
this._lockInSelection();
|
1473
|
+
this._clearInvalidQuery();
|
1474
|
+
|
1110
1475
|
this.expandedValue = false;
|
1111
|
-
|
1476
|
+
|
1477
|
+
this._dispatchSelectionEvent();
|
1478
|
+
|
1479
|
+
if (inputType != "hw:keyHandler:escape") {
|
1480
|
+
this._createChip(shouldReopen);
|
1481
|
+
}
|
1482
|
+
|
1483
|
+
if (this._isSingleSelect && this._selectedOptionElement) {
|
1484
|
+
this._announceToScreenReader(this._displayForOptionElement(this._selectedOptionElement), "selected");
|
1485
|
+
}
|
1112
1486
|
}
|
1113
1487
|
}
|
1114
1488
|
|
1115
1489
|
toggle() {
|
1116
1490
|
if (this.expandedValue) {
|
1117
|
-
this.
|
1491
|
+
this._closeAndBlur("hw:toggle");
|
1118
1492
|
} else {
|
1119
|
-
this.
|
1493
|
+
this.openByFocusing();
|
1120
1494
|
}
|
1121
1495
|
}
|
1122
1496
|
|
@@ -1127,14 +1501,14 @@
|
|
1127
1501
|
if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
|
1128
1502
|
if (this._withinElementBounds(event)) return
|
1129
1503
|
|
1130
|
-
this.
|
1504
|
+
this._closeAndBlur("hw:clickOutside");
|
1131
1505
|
}
|
1132
1506
|
|
1133
1507
|
closeOnFocusOutside({ target }) {
|
1134
1508
|
if (!this._isOpen) return
|
1135
1509
|
if (this.element.contains(target)) return
|
1136
1510
|
|
1137
|
-
this.
|
1511
|
+
this._closeAndBlur("hw:focusOutside");
|
1138
1512
|
}
|
1139
1513
|
|
1140
1514
|
clearOrToggleOnHandleClick() {
|
@@ -1146,6 +1520,11 @@
|
|
1146
1520
|
}
|
1147
1521
|
}
|
1148
1522
|
|
1523
|
+
_closeAndBlur(inputType) {
|
1524
|
+
this.close(inputType);
|
1525
|
+
this._actingCombobox.blur();
|
1526
|
+
}
|
1527
|
+
|
1149
1528
|
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
1150
1529
|
// Hovering over these elements emits a click event for some reason.
|
1151
1530
|
// These events don't contain any telling information, so we use `_withinElementBounds`
|
@@ -1157,18 +1536,16 @@
|
|
1157
1536
|
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
|
1158
1537
|
}
|
1159
1538
|
|
1160
|
-
_openByFocusing() {
|
1161
|
-
this._actingCombobox.focus();
|
1162
|
-
}
|
1163
|
-
|
1164
1539
|
_isDialogDismisser(target) {
|
1165
1540
|
return target.closest("dialog") && target.role != "combobox"
|
1166
1541
|
}
|
1167
1542
|
|
1168
1543
|
_expand() {
|
1169
|
-
if (this.
|
1544
|
+
if (this._isSync) {
|
1545
|
+
this._preselectSingle();
|
1546
|
+
}
|
1170
1547
|
|
1171
|
-
if (this._autocompletesList && this.
|
1548
|
+
if (this._autocompletesList && this._isSmallViewport) {
|
1172
1549
|
this._openInDialog();
|
1173
1550
|
} else {
|
1174
1551
|
this._openInline();
|
@@ -1177,6 +1554,8 @@
|
|
1177
1554
|
this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
|
1178
1555
|
}
|
1179
1556
|
|
1557
|
+
// +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
|
1558
|
+
// it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
|
1180
1559
|
_collapse() {
|
1181
1560
|
this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
|
1182
1561
|
|
@@ -1217,12 +1596,15 @@
|
|
1217
1596
|
enableBodyScroll(this.dialogListboxTarget);
|
1218
1597
|
}
|
1219
1598
|
|
1220
|
-
|
1221
|
-
|
1599
|
+
_clearInvalidQuery() {
|
1600
|
+
if (this._isUnjustifiablyBlank) {
|
1601
|
+
this._deselect();
|
1602
|
+
this._clearQuery();
|
1603
|
+
}
|
1222
1604
|
}
|
1223
1605
|
|
1224
|
-
get
|
1225
|
-
return
|
1606
|
+
get _isOpen() {
|
1607
|
+
return this.expandedValue
|
1226
1608
|
}
|
1227
1609
|
};
|
1228
1610
|
|
@@ -1260,7 +1642,7 @@
|
|
1260
1642
|
// +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
|
1261
1643
|
// because the `required` attribute is only forwarded to the `comboboxTarget` element
|
1262
1644
|
get _valueIsInvalid() {
|
1263
|
-
const isRequiredAndEmpty = this.comboboxTarget.required &&
|
1645
|
+
const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue;
|
1264
1646
|
return isRequiredAndEmpty
|
1265
1647
|
}
|
1266
1648
|
};
|
@@ -1270,11 +1652,14 @@
|
|
1270
1652
|
const concerns = [
|
1271
1653
|
stimulus.Controller,
|
1272
1654
|
Combobox.Actors,
|
1655
|
+
Combobox.Announcements,
|
1273
1656
|
Combobox.AsyncLoading,
|
1274
1657
|
Combobox.Autocomplete,
|
1275
1658
|
Combobox.Dialog,
|
1276
1659
|
Combobox.Events,
|
1277
1660
|
Combobox.Filtering,
|
1661
|
+
Combobox.FormField,
|
1662
|
+
Combobox.Multiselect,
|
1278
1663
|
Combobox.Navigation,
|
1279
1664
|
Combobox.NewOptions,
|
1280
1665
|
Combobox.Options,
|
@@ -1290,7 +1675,10 @@
|
|
1290
1675
|
]
|
1291
1676
|
|
1292
1677
|
static targets = [
|
1678
|
+
"announcer",
|
1293
1679
|
"combobox",
|
1680
|
+
"chipDismisser",
|
1681
|
+
"closer",
|
1294
1682
|
"dialog",
|
1295
1683
|
"dialogCombobox",
|
1296
1684
|
"dialogFocusTrap",
|
@@ -1311,12 +1699,14 @@
|
|
1311
1699
|
nameWhenNew: String,
|
1312
1700
|
originalName: String,
|
1313
1701
|
prefilledDisplay: String,
|
1702
|
+
selectionChipSrc: String,
|
1314
1703
|
smallViewportMaxWidth: String
|
1315
1704
|
}
|
1316
1705
|
|
1317
1706
|
initialize() {
|
1318
1707
|
this._initializeActors();
|
1319
1708
|
this._initializeFiltering();
|
1709
|
+
this._initializeMultiselect();
|
1320
1710
|
}
|
1321
1711
|
|
1322
1712
|
connect() {
|
@@ -1341,13 +1731,27 @@
|
|
1341
1731
|
const inputType = element.dataset.inputType;
|
1342
1732
|
const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
|
1343
1733
|
|
1344
|
-
|
1734
|
+
this._resetMultiselectionMarks();
|
1735
|
+
|
1736
|
+
if (inputType === "hw:multiselectSync") {
|
1737
|
+
this.openByFocusing();
|
1738
|
+
} else if (inputType && inputType !== "hw:lockInSelection") {
|
1345
1739
|
if (delay) await sleep(delay);
|
1346
|
-
this.
|
1740
|
+
this._selectOnQuery(inputType);
|
1347
1741
|
} else {
|
1348
|
-
this.
|
1742
|
+
this._preselectSingle();
|
1349
1743
|
}
|
1350
1744
|
}
|
1745
|
+
|
1746
|
+
closerTargetConnected() {
|
1747
|
+
this._closeAndBlur("hw:asyncCloser");
|
1748
|
+
}
|
1749
|
+
|
1750
|
+
// Use +_printStack+ for debugging purposes
|
1751
|
+
_printStack() {
|
1752
|
+
const err = new Error();
|
1753
|
+
console.log(err.stack || err.stacktrace);
|
1754
|
+
}
|
1351
1755
|
}
|
1352
1756
|
|
1353
1757
|
return HwComboboxController;
|