hotwire_combobox 0.1.43 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 };
|