hotwire_combobox 0.1.43 → 0.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +2 -1
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +29 -3
- data/app/assets/javascripts/hotwire_combobox.esm.js +442 -104
- data/app/assets/javascripts/hotwire_combobox.umd.js +442 -104
- data/app/assets/javascripts/hw_combobox/helpers.js +16 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/announcements.js +7 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -11
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +20 -12
- data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +74 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +160 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +15 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/options.js +19 -7
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +50 -49
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +33 -16
- data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox.js +3 -0
- data/app/assets/stylesheets/hotwire_combobox.css +90 -19
- data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
- data/app/presenters/hotwire_combobox/component.rb +106 -32
- data/app/presenters/hotwire_combobox/listbox/group.rb +47 -0
- data/app/presenters/hotwire_combobox/listbox/item/collection.rb +14 -0
- data/app/presenters/hotwire_combobox/listbox/item.rb +111 -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 +112 -91
- data/lib/hotwire_combobox/version.rb +1 -1
- metadata +11 -3
@@ -1,3 +1,6 @@
|
|
1
|
+
/*!
|
2
|
+
HotwireCombobox 0.2.1
|
3
|
+
*/
|
1
4
|
import { Controller } from '@hotwired/stimulus';
|
2
5
|
|
3
6
|
const Combobox = {};
|
@@ -33,6 +36,12 @@ Combobox.Actors = Base => class extends Base {
|
|
33
36
|
}
|
34
37
|
};
|
35
38
|
|
39
|
+
Combobox.Announcements = Base => class extends Base {
|
40
|
+
_announceToScreenReader(display, action) {
|
41
|
+
this.announcerTarget.innerText = `${display} ${action}`;
|
42
|
+
}
|
43
|
+
};
|
44
|
+
|
36
45
|
Combobox.AsyncLoading = Base => class extends Base {
|
37
46
|
get _isAsync() {
|
38
47
|
return this.hasAsyncSrcValue
|
@@ -125,6 +134,22 @@ function dispatch(eventName, { target, cancelable, detail } = {}) {
|
|
125
134
|
return event
|
126
135
|
}
|
127
136
|
|
137
|
+
function nextRepaint() {
|
138
|
+
if (document.visibilityState === "hidden") {
|
139
|
+
return nextEventLoopTick()
|
140
|
+
} else {
|
141
|
+
return nextAnimationFrame()
|
142
|
+
}
|
143
|
+
}
|
144
|
+
|
145
|
+
function nextAnimationFrame() {
|
146
|
+
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
|
147
|
+
}
|
148
|
+
|
149
|
+
function nextEventLoopTick() {
|
150
|
+
return new Promise((resolve) => setTimeout(() => resolve(), 0))
|
151
|
+
}
|
152
|
+
|
128
153
|
Combobox.Autocomplete = Base => class extends Base {
|
129
154
|
_connectListAutocomplete() {
|
130
155
|
if (!this._autocompletesList) {
|
@@ -232,7 +257,7 @@ Combobox.Dialog = Base => class extends Base {
|
|
232
257
|
this.dialogFocusTrapTarget.focus();
|
233
258
|
}
|
234
259
|
|
235
|
-
get
|
260
|
+
get _isSmallViewport() {
|
236
261
|
return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
|
237
262
|
}
|
238
263
|
|
@@ -242,25 +267,35 @@ Combobox.Dialog = Base => class extends Base {
|
|
242
267
|
};
|
243
268
|
|
244
269
|
Combobox.Events = Base => class extends Base {
|
245
|
-
|
246
|
-
if (previousValue
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
}
|
251
|
-
}
|
270
|
+
_dispatchPreselectionEvent({ isNewAndAllowed, previousValue }) {
|
271
|
+
if (previousValue === this._incomingFieldValueString) return
|
272
|
+
|
273
|
+
dispatch("hw-combobox:preselection", {
|
274
|
+
target: this.element,
|
275
|
+
detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
|
276
|
+
});
|
277
|
+
}
|
278
|
+
|
279
|
+
_dispatchSelectionEvent() {
|
280
|
+
dispatch("hw-combobox:selection", {
|
281
|
+
target: this.element,
|
282
|
+
detail: this._eventableDetails
|
283
|
+
});
|
252
284
|
}
|
253
285
|
|
254
|
-
|
255
|
-
dispatch("hw-combobox:
|
286
|
+
_dispatchRemovalEvent({ removedDisplay, removedValue }) {
|
287
|
+
dispatch("hw-combobox:removal", {
|
288
|
+
target: this.element,
|
289
|
+
detail: { ...this._eventableDetails, removedDisplay, removedValue }
|
290
|
+
});
|
256
291
|
}
|
257
292
|
|
258
293
|
get _eventableDetails() {
|
259
294
|
return {
|
260
|
-
value: this.
|
295
|
+
value: this._incomingFieldValueString,
|
261
296
|
display: this._fullQuery,
|
262
297
|
query: this._typedQuery,
|
263
|
-
fieldName: this.
|
298
|
+
fieldName: this._fieldName,
|
264
299
|
isValid: this._valueIsValid
|
265
300
|
}
|
266
301
|
}
|
@@ -544,11 +579,11 @@ async function get(url, options) {
|
|
544
579
|
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
545
580
|
|
546
581
|
Combobox.Filtering = Base => class extends Base {
|
547
|
-
filterAndSelect(
|
548
|
-
this._filter(
|
582
|
+
filterAndSelect({ inputType }) {
|
583
|
+
this._filter(inputType);
|
549
584
|
|
550
585
|
if (this._isSync) {
|
551
|
-
this.
|
586
|
+
this._selectOnQuery(inputType);
|
552
587
|
}
|
553
588
|
}
|
554
589
|
|
@@ -556,24 +591,24 @@ Combobox.Filtering = Base => class extends Base {
|
|
556
591
|
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
|
557
592
|
}
|
558
593
|
|
559
|
-
_filter(
|
594
|
+
_filter(inputType) {
|
560
595
|
if (this._isAsync) {
|
561
|
-
this._debouncedFilterAsync(
|
596
|
+
this._debouncedFilterAsync(inputType);
|
562
597
|
} else {
|
563
598
|
this._filterSync();
|
564
599
|
}
|
565
600
|
|
566
|
-
this.
|
601
|
+
this._markQueried();
|
567
602
|
}
|
568
603
|
|
569
|
-
_debouncedFilterAsync(
|
570
|
-
this._filterAsync(
|
604
|
+
_debouncedFilterAsync(inputType) {
|
605
|
+
this._filterAsync(inputType);
|
571
606
|
}
|
572
607
|
|
573
|
-
async _filterAsync(
|
608
|
+
async _filterAsync(inputType) {
|
574
609
|
const query = {
|
575
610
|
q: this._fullQuery,
|
576
|
-
input_type:
|
611
|
+
input_type: inputType,
|
577
612
|
for_id: this.element.dataset.asyncId
|
578
613
|
};
|
579
614
|
|
@@ -581,8 +616,12 @@ Combobox.Filtering = Base => class extends Base {
|
|
581
616
|
}
|
582
617
|
|
583
618
|
_filterSync() {
|
584
|
-
this.
|
585
|
-
|
619
|
+
this._allFilterableOptionElements.forEach(
|
620
|
+
applyFilter(
|
621
|
+
this._fullQuery,
|
622
|
+
{ matching: this.filterableAttributeValue }
|
623
|
+
)
|
624
|
+
);
|
586
625
|
}
|
587
626
|
|
588
627
|
_clearQuery() {
|
@@ -590,6 +629,10 @@ Combobox.Filtering = Base => class extends Base {
|
|
590
629
|
this.filterAndSelect({ inputType: "deleteContentBackward" });
|
591
630
|
}
|
592
631
|
|
632
|
+
_markQueried() {
|
633
|
+
this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
|
634
|
+
}
|
635
|
+
|
593
636
|
get _isQueried() {
|
594
637
|
return this._fullQuery.length > 0
|
595
638
|
}
|
@@ -607,20 +650,255 @@ Combobox.Filtering = Base => class extends Base {
|
|
607
650
|
}
|
608
651
|
};
|
609
652
|
|
653
|
+
Combobox.FormField = Base => class extends Base {
|
654
|
+
get _fieldValue() {
|
655
|
+
if (this._isMultiselect) {
|
656
|
+
const currentValue = this.hiddenFieldTarget.value;
|
657
|
+
const arrayFromValue = currentValue ? currentValue.split(",") : [];
|
658
|
+
|
659
|
+
return new Set(arrayFromValue)
|
660
|
+
} else {
|
661
|
+
return this.hiddenFieldTarget.value
|
662
|
+
}
|
663
|
+
}
|
664
|
+
|
665
|
+
get _fieldValueString() {
|
666
|
+
if (this._isMultiselect) {
|
667
|
+
return this._fieldValueArray.join(",")
|
668
|
+
} else {
|
669
|
+
return this.hiddenFieldTarget.value
|
670
|
+
}
|
671
|
+
}
|
672
|
+
|
673
|
+
get _incomingFieldValueString() {
|
674
|
+
if (this._isMultiselect) {
|
675
|
+
const array = this._fieldValueArray;
|
676
|
+
|
677
|
+
if (this.hiddenFieldTarget.dataset.valueForMultiselect) {
|
678
|
+
array.push(this.hiddenFieldTarget.dataset.valueForMultiselect);
|
679
|
+
}
|
680
|
+
|
681
|
+
return array.join(",")
|
682
|
+
} else {
|
683
|
+
return this.hiddenFieldTarget.value
|
684
|
+
}
|
685
|
+
}
|
686
|
+
|
687
|
+
get _fieldValueArray() {
|
688
|
+
if (this._isMultiselect) {
|
689
|
+
return Array.from(this._fieldValue)
|
690
|
+
} else {
|
691
|
+
return [ this.hiddenFieldTarget.value ]
|
692
|
+
}
|
693
|
+
}
|
694
|
+
|
695
|
+
set _fieldValue(value) {
|
696
|
+
if (this._isMultiselect) {
|
697
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect = value?.replace(/,/g, "");
|
698
|
+
this.hiddenFieldTarget.dataset.displayForMultiselect = this._fullQuery;
|
699
|
+
} else {
|
700
|
+
this.hiddenFieldTarget.value = value;
|
701
|
+
}
|
702
|
+
}
|
703
|
+
|
704
|
+
get _hasEmptyFieldValue() {
|
705
|
+
if (this._isMultiselect) {
|
706
|
+
return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
|
707
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
|
708
|
+
} else {
|
709
|
+
return this.hiddenFieldTarget.value === ""
|
710
|
+
}
|
711
|
+
}
|
712
|
+
|
713
|
+
get _hasFieldValue() {
|
714
|
+
return !this._hasEmptyFieldValue
|
715
|
+
}
|
716
|
+
|
717
|
+
get _fieldName() {
|
718
|
+
return this.hiddenFieldTarget.name
|
719
|
+
}
|
720
|
+
|
721
|
+
set _fieldName(value) {
|
722
|
+
this.hiddenFieldTarget.name = value;
|
723
|
+
}
|
724
|
+
};
|
725
|
+
|
726
|
+
Combobox.Multiselect = Base => class extends Base {
|
727
|
+
navigateChip(event) {
|
728
|
+
this._chipKeyHandlers[event.key]?.call(this, event);
|
729
|
+
}
|
730
|
+
|
731
|
+
removeChip({ currentTarget, params }) {
|
732
|
+
let display;
|
733
|
+
const option = this._optionElementWithValue(params.value);
|
734
|
+
|
735
|
+
if (option) {
|
736
|
+
display = option.getAttribute(this.autocompletableAttributeValue);
|
737
|
+
this._markNotSelected(option);
|
738
|
+
this._markNotMultiselected(option);
|
739
|
+
} else {
|
740
|
+
display = params.value; // for new options
|
741
|
+
}
|
742
|
+
|
743
|
+
this._removeFromFieldValue(params.value);
|
744
|
+
this._filter("hw:multiselectSync");
|
745
|
+
|
746
|
+
currentTarget.closest("[data-hw-combobox-chip]").remove();
|
747
|
+
|
748
|
+
if (!this._isSmallViewport) {
|
749
|
+
this.openByFocusing();
|
750
|
+
}
|
751
|
+
|
752
|
+
this._announceToScreenReader(display, "removed");
|
753
|
+
this._dispatchRemovalEvent({ removedDisplay: display, removedValue: params.value });
|
754
|
+
}
|
755
|
+
|
756
|
+
hideChipsForCache() {
|
757
|
+
this.element.querySelectorAll("[data-hw-combobox-chip]").forEach(chip => chip.hidden = true);
|
758
|
+
}
|
759
|
+
|
760
|
+
_chipKeyHandlers = {
|
761
|
+
Backspace: (event) => {
|
762
|
+
this.removeChip(event);
|
763
|
+
cancel(event);
|
764
|
+
},
|
765
|
+
Enter: (event) => {
|
766
|
+
this.removeChip(event);
|
767
|
+
cancel(event);
|
768
|
+
},
|
769
|
+
Space: (event) => {
|
770
|
+
this.removeChip(event);
|
771
|
+
cancel(event);
|
772
|
+
},
|
773
|
+
Escape: (event) => {
|
774
|
+
this.openByFocusing();
|
775
|
+
cancel(event);
|
776
|
+
}
|
777
|
+
}
|
778
|
+
|
779
|
+
_initializeMultiselect() {
|
780
|
+
if (!this._isMultiPreselected) {
|
781
|
+
this._preselectMultiple();
|
782
|
+
this._markMultiPreselected();
|
783
|
+
}
|
784
|
+
}
|
785
|
+
|
786
|
+
async _createChip(shouldReopen) {
|
787
|
+
if (!this._isMultiselect) return
|
788
|
+
|
789
|
+
this._beforeClearingMultiselectQuery(async (display, value) => {
|
790
|
+
this._fullQuery = "";
|
791
|
+
this._filter("hw:multiselectSync");
|
792
|
+
this._requestChips(value);
|
793
|
+
this._addToFieldValue(value);
|
794
|
+
if (shouldReopen) {
|
795
|
+
await nextRepaint();
|
796
|
+
this.openByFocusing();
|
797
|
+
}
|
798
|
+
this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.");
|
799
|
+
});
|
800
|
+
}
|
801
|
+
|
802
|
+
async _requestChips(values) {
|
803
|
+
await get(this.selectionChipSrcValue, {
|
804
|
+
responseKind: "turbo-stream",
|
805
|
+
query: {
|
806
|
+
for_id: this.element.dataset.asyncId,
|
807
|
+
combobox_values: values
|
808
|
+
}
|
809
|
+
});
|
810
|
+
}
|
811
|
+
|
812
|
+
_beforeClearingMultiselectQuery(callback) {
|
813
|
+
const display = this.hiddenFieldTarget.dataset.displayForMultiselect;
|
814
|
+
const value = this.hiddenFieldTarget.dataset.valueForMultiselect;
|
815
|
+
|
816
|
+
if (value && !this._fieldValue.has(value)) {
|
817
|
+
callback(display, value);
|
818
|
+
}
|
819
|
+
|
820
|
+
this.hiddenFieldTarget.dataset.displayForMultiselect = "";
|
821
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect = "";
|
822
|
+
}
|
823
|
+
|
824
|
+
_resetMultiselectionMarks() {
|
825
|
+
if (!this._isMultiselect) return
|
826
|
+
|
827
|
+
this._fieldValueArray.forEach(value => {
|
828
|
+
const option = this._optionElementWithValue(value);
|
829
|
+
|
830
|
+
if (option) {
|
831
|
+
option.setAttribute("data-multiselected", "");
|
832
|
+
option.hidden = true;
|
833
|
+
}
|
834
|
+
});
|
835
|
+
}
|
836
|
+
|
837
|
+
_markNotMultiselected(option) {
|
838
|
+
if (!this._isMultiselect) return
|
839
|
+
|
840
|
+
option.removeAttribute("data-multiselected");
|
841
|
+
option.hidden = false;
|
842
|
+
}
|
843
|
+
|
844
|
+
_addToFieldValue(value) {
|
845
|
+
const newValue = this._fieldValue;
|
846
|
+
|
847
|
+
newValue.add(String(value));
|
848
|
+
this.hiddenFieldTarget.value = Array.from(newValue).join(",");
|
849
|
+
|
850
|
+
if (this._isSync) this._resetMultiselectionMarks();
|
851
|
+
}
|
852
|
+
|
853
|
+
_removeFromFieldValue(value) {
|
854
|
+
const newValue = this._fieldValue;
|
855
|
+
|
856
|
+
newValue.delete(String(value));
|
857
|
+
this.hiddenFieldTarget.value = Array.from(newValue).join(",");
|
858
|
+
|
859
|
+
if (this._isSync) this._resetMultiselectionMarks();
|
860
|
+
}
|
861
|
+
|
862
|
+
_focusLastChipDismisser() {
|
863
|
+
this.chipDismisserTargets[this.chipDismisserTargets.length - 1]?.focus();
|
864
|
+
}
|
865
|
+
|
866
|
+
_markMultiPreselected() {
|
867
|
+
this.element.dataset.multiPreselected = "";
|
868
|
+
}
|
869
|
+
|
870
|
+
get _isMultiselect() {
|
871
|
+
return this.hasSelectionChipSrcValue
|
872
|
+
}
|
873
|
+
|
874
|
+
get _isSingleSelect() {
|
875
|
+
return !this._isMultiselect
|
876
|
+
}
|
877
|
+
|
878
|
+
get _isMultiPreselected() {
|
879
|
+
return this.element.hasAttribute("data-multi-preselected")
|
880
|
+
}
|
881
|
+
};
|
882
|
+
|
610
883
|
Combobox.Navigation = Base => class extends Base {
|
611
884
|
navigate(event) {
|
612
885
|
if (this._autocompletesList) {
|
613
|
-
this.
|
886
|
+
this._navigationKeyHandlers[event.key]?.call(this, event);
|
614
887
|
}
|
615
888
|
}
|
616
889
|
|
617
|
-
|
890
|
+
_navigationKeyHandlers = {
|
618
891
|
ArrowUp: (event) => {
|
619
892
|
this._selectIndex(this._selectedOptionIndex - 1);
|
620
893
|
cancel(event);
|
621
894
|
},
|
622
895
|
ArrowDown: (event) => {
|
623
896
|
this._selectIndex(this._selectedOptionIndex + 1);
|
897
|
+
|
898
|
+
if (this._selectedOptionIndex === 0) {
|
899
|
+
this._actingListbox.scrollTop = 0;
|
900
|
+
}
|
901
|
+
|
624
902
|
cancel(event);
|
625
903
|
},
|
626
904
|
Home: (event) => {
|
@@ -632,14 +910,18 @@ Combobox.Navigation = Base => class extends Base {
|
|
632
910
|
cancel(event);
|
633
911
|
},
|
634
912
|
Enter: (event) => {
|
635
|
-
this.
|
636
|
-
this._actingCombobox.blur();
|
913
|
+
this._closeAndBlur("hw:keyHandler:enter");
|
637
914
|
cancel(event);
|
638
915
|
},
|
639
916
|
Escape: (event) => {
|
640
|
-
this.
|
641
|
-
this._actingCombobox.blur();
|
917
|
+
this._closeAndBlur("hw:keyHandler:escape");
|
642
918
|
cancel(event);
|
919
|
+
},
|
920
|
+
Backspace: (event) => {
|
921
|
+
if (this._isMultiselect && !this._fullQuery) {
|
922
|
+
this._focusLastChipDismisser();
|
923
|
+
cancel(event);
|
924
|
+
}
|
643
925
|
}
|
644
926
|
}
|
645
927
|
};
|
@@ -690,28 +972,40 @@ Combobox.Options = Base => class extends Base {
|
|
690
972
|
}
|
691
973
|
|
692
974
|
_resetOptions(deselectionStrategy) {
|
693
|
-
this.
|
975
|
+
this._fieldName = this.originalNameValue;
|
694
976
|
deselectionStrategy();
|
695
977
|
}
|
696
978
|
|
979
|
+
_optionElementWithValue(value) {
|
980
|
+
return this._actingListbox.querySelector(`[${this.filterableAttributeValue}][data-value='${value}']`)
|
981
|
+
}
|
982
|
+
|
983
|
+
_displayForOptionElement(element) {
|
984
|
+
return element.getAttribute(this.autocompletableAttributeValue)
|
985
|
+
}
|
986
|
+
|
697
987
|
get _allowNew() {
|
698
988
|
return !!this.nameWhenNewValue
|
699
989
|
}
|
700
990
|
|
701
991
|
get _allOptions() {
|
702
|
-
return Array.from(this.
|
992
|
+
return Array.from(this._allFilterableOptionElements)
|
703
993
|
}
|
704
994
|
|
705
|
-
get
|
706
|
-
return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
|
995
|
+
get _allFilterableOptionElements() {
|
996
|
+
return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`)
|
707
997
|
}
|
708
998
|
|
709
999
|
get _visibleOptionElements() {
|
710
|
-
return [ ...this.
|
1000
|
+
return [ ...this._allFilterableOptionElements ].filter(visible)
|
711
1001
|
}
|
712
1002
|
|
713
1003
|
get _selectedOptionElement() {
|
714
|
-
return this._actingListbox.querySelector("[role=option][aria-selected=true]")
|
1004
|
+
return this._actingListbox.querySelector("[role=option][aria-selected=true]:not([data-multiselected])")
|
1005
|
+
}
|
1006
|
+
|
1007
|
+
get _multiselectedOptionElements() {
|
1008
|
+
return this._actingListbox.querySelectorAll("[role=option][data-multiselected]")
|
715
1009
|
}
|
716
1010
|
|
717
1011
|
get _selectedOptionIndex() {
|
@@ -719,7 +1013,7 @@ Combobox.Options = Base => class extends Base {
|
|
719
1013
|
}
|
720
1014
|
|
721
1015
|
get _isUnjustifiablyBlank() {
|
722
|
-
const valueIsMissing =
|
1016
|
+
const valueIsMissing = this._hasEmptyFieldValue;
|
723
1017
|
const noBlankOptionSelected = !this._selectedOptionElement;
|
724
1018
|
|
725
1019
|
return valueIsMissing && noBlankOptionSelected
|
@@ -727,23 +1021,24 @@ Combobox.Options = Base => class extends Base {
|
|
727
1021
|
};
|
728
1022
|
|
729
1023
|
Combobox.Selection = Base => class extends Base {
|
730
|
-
|
731
|
-
this._forceSelectionAndFilter(
|
732
|
-
this.
|
1024
|
+
selectOnClick({ currentTarget, inputType }) {
|
1025
|
+
this._forceSelectionAndFilter(currentTarget, inputType);
|
1026
|
+
this._closeAndBlur("hw:optionRoleClick");
|
733
1027
|
}
|
734
1028
|
|
735
1029
|
_connectSelection() {
|
736
1030
|
if (this.hasPrefilledDisplayValue) {
|
737
1031
|
this._fullQuery = this.prefilledDisplayValue;
|
1032
|
+
this._markQueried();
|
738
1033
|
}
|
739
1034
|
}
|
740
1035
|
|
741
|
-
|
742
|
-
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(
|
1036
|
+
_selectOnQuery(inputType) {
|
1037
|
+
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
|
743
1038
|
this._selectNew();
|
744
|
-
} else if (isDeleteEvent(
|
1039
|
+
} else if (isDeleteEvent({ inputType: inputType })) {
|
745
1040
|
this._deselect();
|
746
|
-
} else if (
|
1041
|
+
} else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
|
747
1042
|
this._selectAndAutocompleteMissingPortion(this._ensurableOption);
|
748
1043
|
} else if (this._isOpen && this._visibleOptionElements[0]) {
|
749
1044
|
this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
|
@@ -754,38 +1049,38 @@ Combobox.Selection = Base => class extends Base {
|
|
754
1049
|
}
|
755
1050
|
|
756
1051
|
_select(option, autocompleteStrategy) {
|
757
|
-
const previousValue = this.
|
1052
|
+
const previousValue = this._fieldValueString;
|
758
1053
|
|
759
1054
|
this._resetOptionsSilently();
|
760
1055
|
|
761
1056
|
autocompleteStrategy(option);
|
762
1057
|
|
763
|
-
this.
|
1058
|
+
this._fieldValue = option.dataset.value;
|
764
1059
|
this._markSelected(option);
|
765
1060
|
this._markValid();
|
766
|
-
this.
|
1061
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
|
767
1062
|
|
768
1063
|
option.scrollIntoView({ block: "nearest" });
|
769
1064
|
}
|
770
1065
|
|
771
1066
|
_selectNew() {
|
772
|
-
const previousValue = this.
|
1067
|
+
const previousValue = this._fieldValueString;
|
773
1068
|
|
774
1069
|
this._resetOptionsSilently();
|
775
|
-
this.
|
776
|
-
this.
|
1070
|
+
this._fieldValue = this._fullQuery;
|
1071
|
+
this._fieldName = this.nameWhenNewValue;
|
777
1072
|
this._markValid();
|
778
|
-
this.
|
1073
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
|
779
1074
|
}
|
780
1075
|
|
781
1076
|
_deselect() {
|
782
|
-
const previousValue = this.
|
1077
|
+
const previousValue = this._fieldValueString;
|
783
1078
|
|
784
1079
|
if (this._selectedOptionElement) {
|
785
1080
|
this._markNotSelected(this._selectedOptionElement);
|
786
1081
|
}
|
787
1082
|
|
788
|
-
this.
|
1083
|
+
this._fieldValue = "";
|
789
1084
|
this._setActiveDescendant("");
|
790
1085
|
|
791
1086
|
return previousValue
|
@@ -793,16 +1088,7 @@ Combobox.Selection = Base => class extends Base {
|
|
793
1088
|
|
794
1089
|
_deselectAndNotify() {
|
795
1090
|
const previousValue = this._deselect();
|
796
|
-
this.
|
797
|
-
}
|
798
|
-
|
799
|
-
_forceSelectionAndFilter(option, event) {
|
800
|
-
this._forceSelectionWithoutFiltering(option);
|
801
|
-
this._filter(event);
|
802
|
-
}
|
803
|
-
|
804
|
-
_forceSelectionWithoutFiltering(option) {
|
805
|
-
this._selectAndReplaceFullQuery(option);
|
1091
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
|
806
1092
|
}
|
807
1093
|
|
808
1094
|
_selectIndex(index) {
|
@@ -810,27 +1096,40 @@ Combobox.Selection = Base => class extends Base {
|
|
810
1096
|
this._forceSelectionWithoutFiltering(option);
|
811
1097
|
}
|
812
1098
|
|
813
|
-
|
814
|
-
if (this._hasValueButNoSelection && this._allOptions.length < 100) {
|
815
|
-
const option = this.
|
816
|
-
return option.dataset.value === this._value
|
817
|
-
});
|
818
|
-
|
1099
|
+
_preselectSingle() {
|
1100
|
+
if (this._isSingleSelect && this._hasValueButNoSelection && this._allOptions.length < 100) {
|
1101
|
+
const option = this._optionElementWithValue(this._fieldValue);
|
819
1102
|
if (option) this._markSelected(option);
|
820
1103
|
}
|
821
1104
|
}
|
822
1105
|
|
823
|
-
|
824
|
-
this.
|
1106
|
+
_preselectMultiple() {
|
1107
|
+
if (this._isMultiselect && this._hasValueButNoSelection) {
|
1108
|
+
this._requestChips(this._fieldValueString);
|
1109
|
+
this._resetMultiselectionMarks();
|
1110
|
+
}
|
825
1111
|
}
|
826
1112
|
|
827
1113
|
_selectAndAutocompleteMissingPortion(option) {
|
828
1114
|
this._select(option, this._autocompleteMissingPortion.bind(this));
|
829
1115
|
}
|
830
1116
|
|
1117
|
+
_selectAndAutocompleteFullQuery(option) {
|
1118
|
+
this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
|
1119
|
+
}
|
1120
|
+
|
1121
|
+
_forceSelectionAndFilter(option, inputType) {
|
1122
|
+
this._forceSelectionWithoutFiltering(option);
|
1123
|
+
this._filter(inputType);
|
1124
|
+
}
|
1125
|
+
|
1126
|
+
_forceSelectionWithoutFiltering(option) {
|
1127
|
+
this._selectAndAutocompleteFullQuery(option);
|
1128
|
+
}
|
1129
|
+
|
831
1130
|
_lockInSelection() {
|
832
1131
|
if (this._shouldLockInSelection) {
|
833
|
-
this._forceSelectionAndFilter(this._ensurableOption,
|
1132
|
+
this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection");
|
834
1133
|
}
|
835
1134
|
}
|
836
1135
|
|
@@ -854,20 +1153,16 @@ Combobox.Selection = Base => class extends Base {
|
|
854
1153
|
this._setActiveDescendant("");
|
855
1154
|
}
|
856
1155
|
|
857
|
-
|
858
|
-
this.
|
859
|
-
}
|
860
|
-
|
861
|
-
_setName(value) {
|
862
|
-
this.hiddenFieldTarget.name = value;
|
863
|
-
}
|
864
|
-
|
865
|
-
get _value() {
|
866
|
-
return this.hiddenFieldTarget.value
|
1156
|
+
get _hasValueButNoSelection() {
|
1157
|
+
return this._hasFieldValue && !this._hasSelection
|
867
1158
|
}
|
868
1159
|
|
869
|
-
get
|
870
|
-
|
1160
|
+
get _hasSelection() {
|
1161
|
+
if (this._isSingleSelect) {
|
1162
|
+
this._selectedOptionElement;
|
1163
|
+
} else {
|
1164
|
+
this._multiselectedOptionElements.length > 0;
|
1165
|
+
}
|
871
1166
|
}
|
872
1167
|
|
873
1168
|
get _shouldLockInSelection() {
|
@@ -1158,22 +1453,40 @@ Combobox.Toggle = Base => class extends Base {
|
|
1158
1453
|
this.expandedValue = true;
|
1159
1454
|
}
|
1160
1455
|
|
1161
|
-
|
1456
|
+
openByFocusing() {
|
1457
|
+
this._actingCombobox.focus();
|
1458
|
+
}
|
1459
|
+
|
1460
|
+
close(inputType) {
|
1162
1461
|
if (this._isOpen) {
|
1462
|
+
const shouldReopen = this._isMultiselect &&
|
1463
|
+
this._isSync &&
|
1464
|
+
!this._isSmallViewport &&
|
1465
|
+
inputType != "hw:clickOutside" &&
|
1466
|
+
inputType != "hw:focusOutside";
|
1467
|
+
|
1163
1468
|
this._lockInSelection();
|
1164
1469
|
this._clearInvalidQuery();
|
1165
1470
|
|
1166
1471
|
this.expandedValue = false;
|
1167
1472
|
|
1168
|
-
this.
|
1473
|
+
this._dispatchSelectionEvent();
|
1474
|
+
|
1475
|
+
if (inputType != "hw:keyHandler:escape") {
|
1476
|
+
this._createChip(shouldReopen);
|
1477
|
+
}
|
1478
|
+
|
1479
|
+
if (this._isSingleSelect && this._selectedOptionElement) {
|
1480
|
+
this._announceToScreenReader(this._displayForOptionElement(this._selectedOptionElement), "selected");
|
1481
|
+
}
|
1169
1482
|
}
|
1170
1483
|
}
|
1171
1484
|
|
1172
1485
|
toggle() {
|
1173
1486
|
if (this.expandedValue) {
|
1174
|
-
this.
|
1487
|
+
this._closeAndBlur("hw:toggle");
|
1175
1488
|
} else {
|
1176
|
-
this.
|
1489
|
+
this.openByFocusing();
|
1177
1490
|
}
|
1178
1491
|
}
|
1179
1492
|
|
@@ -1184,14 +1497,14 @@ Combobox.Toggle = Base => class extends Base {
|
|
1184
1497
|
if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
|
1185
1498
|
if (this._withinElementBounds(event)) return
|
1186
1499
|
|
1187
|
-
this.
|
1500
|
+
this._closeAndBlur("hw:clickOutside");
|
1188
1501
|
}
|
1189
1502
|
|
1190
1503
|
closeOnFocusOutside({ target }) {
|
1191
1504
|
if (!this._isOpen) return
|
1192
1505
|
if (this.element.contains(target)) return
|
1193
1506
|
|
1194
|
-
this.
|
1507
|
+
this._closeAndBlur("hw:focusOutside");
|
1195
1508
|
}
|
1196
1509
|
|
1197
1510
|
clearOrToggleOnHandleClick() {
|
@@ -1203,6 +1516,11 @@ Combobox.Toggle = Base => class extends Base {
|
|
1203
1516
|
}
|
1204
1517
|
}
|
1205
1518
|
|
1519
|
+
_closeAndBlur(inputType) {
|
1520
|
+
this.close(inputType);
|
1521
|
+
this._actingCombobox.blur();
|
1522
|
+
}
|
1523
|
+
|
1206
1524
|
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
1207
1525
|
// Hovering over these elements emits a click event for some reason.
|
1208
1526
|
// These events don't contain any telling information, so we use `_withinElementBounds`
|
@@ -1214,18 +1532,16 @@ Combobox.Toggle = Base => class extends Base {
|
|
1214
1532
|
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
|
1215
1533
|
}
|
1216
1534
|
|
1217
|
-
_openByFocusing() {
|
1218
|
-
this._actingCombobox.focus();
|
1219
|
-
}
|
1220
|
-
|
1221
1535
|
_isDialogDismisser(target) {
|
1222
1536
|
return target.closest("dialog") && target.role != "combobox"
|
1223
1537
|
}
|
1224
1538
|
|
1225
1539
|
_expand() {
|
1226
|
-
if (this.
|
1540
|
+
if (this._isSync) {
|
1541
|
+
this._preselectSingle();
|
1542
|
+
}
|
1227
1543
|
|
1228
|
-
if (this._autocompletesList && this.
|
1544
|
+
if (this._autocompletesList && this._isSmallViewport) {
|
1229
1545
|
this._openInDialog();
|
1230
1546
|
} else {
|
1231
1547
|
this._openInline();
|
@@ -1286,10 +1602,6 @@ Combobox.Toggle = Base => class extends Base {
|
|
1286
1602
|
get _isOpen() {
|
1287
1603
|
return this.expandedValue
|
1288
1604
|
}
|
1289
|
-
|
1290
|
-
get _preselectOnExpansion() {
|
1291
|
-
return !this._isAsync // async comboboxes preselect based on callbacks
|
1292
|
-
}
|
1293
1605
|
};
|
1294
1606
|
|
1295
1607
|
Combobox.Validity = Base => class extends Base {
|
@@ -1326,7 +1638,7 @@ Combobox.Validity = Base => class extends Base {
|
|
1326
1638
|
// +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
|
1327
1639
|
// because the `required` attribute is only forwarded to the `comboboxTarget` element
|
1328
1640
|
get _valueIsInvalid() {
|
1329
|
-
const isRequiredAndEmpty = this.comboboxTarget.required &&
|
1641
|
+
const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue;
|
1330
1642
|
return isRequiredAndEmpty
|
1331
1643
|
}
|
1332
1644
|
};
|
@@ -1336,11 +1648,14 @@ window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0; // ms, for testing purposes
|
|
1336
1648
|
const concerns = [
|
1337
1649
|
Controller,
|
1338
1650
|
Combobox.Actors,
|
1651
|
+
Combobox.Announcements,
|
1339
1652
|
Combobox.AsyncLoading,
|
1340
1653
|
Combobox.Autocomplete,
|
1341
1654
|
Combobox.Dialog,
|
1342
1655
|
Combobox.Events,
|
1343
1656
|
Combobox.Filtering,
|
1657
|
+
Combobox.FormField,
|
1658
|
+
Combobox.Multiselect,
|
1344
1659
|
Combobox.Navigation,
|
1345
1660
|
Combobox.NewOptions,
|
1346
1661
|
Combobox.Options,
|
@@ -1356,7 +1671,10 @@ class HwComboboxController extends Concerns(...concerns) {
|
|
1356
1671
|
]
|
1357
1672
|
|
1358
1673
|
static targets = [
|
1674
|
+
"announcer",
|
1359
1675
|
"combobox",
|
1676
|
+
"chipDismisser",
|
1677
|
+
"closer",
|
1360
1678
|
"dialog",
|
1361
1679
|
"dialogCombobox",
|
1362
1680
|
"dialogFocusTrap",
|
@@ -1377,15 +1695,21 @@ class HwComboboxController extends Concerns(...concerns) {
|
|
1377
1695
|
nameWhenNew: String,
|
1378
1696
|
originalName: String,
|
1379
1697
|
prefilledDisplay: String,
|
1698
|
+
selectionChipSrc: String,
|
1380
1699
|
smallViewportMaxWidth: String
|
1381
1700
|
}
|
1382
1701
|
|
1383
1702
|
initialize() {
|
1384
1703
|
this._initializeActors();
|
1385
1704
|
this._initializeFiltering();
|
1705
|
+
this._initializeMultiselect();
|
1386
1706
|
}
|
1387
1707
|
|
1388
1708
|
connect() {
|
1709
|
+
this.idempotentConnect();
|
1710
|
+
}
|
1711
|
+
|
1712
|
+
idempotentConnect() {
|
1389
1713
|
this._connectSelection();
|
1390
1714
|
this._connectListAutocomplete();
|
1391
1715
|
this._connectDialog();
|
@@ -1407,13 +1731,27 @@ class HwComboboxController extends Concerns(...concerns) {
|
|
1407
1731
|
const inputType = element.dataset.inputType;
|
1408
1732
|
const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
|
1409
1733
|
|
1410
|
-
|
1734
|
+
this._resetMultiselectionMarks();
|
1735
|
+
|
1736
|
+
if (inputType === "hw:multiselectSync") {
|
1737
|
+
this.openByFocusing();
|
1738
|
+
} else if (inputType && inputType !== "hw:lockInSelection") {
|
1411
1739
|
if (delay) await sleep(delay);
|
1412
|
-
this.
|
1740
|
+
this._selectOnQuery(inputType);
|
1413
1741
|
} else {
|
1414
|
-
this.
|
1742
|
+
this._preselectSingle();
|
1415
1743
|
}
|
1416
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
|
+
}
|
1417
1755
|
}
|
1418
1756
|
|
1419
1757
|
export { HwComboboxController as default };
|