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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -1
  3. data/app/assets/javascripts/controllers/hw_combobox_controller.js +29 -3
  4. data/app/assets/javascripts/hotwire_combobox.esm.js +442 -104
  5. data/app/assets/javascripts/hotwire_combobox.umd.js +442 -104
  6. data/app/assets/javascripts/hw_combobox/helpers.js +16 -0
  7. data/app/assets/javascripts/hw_combobox/models/combobox/announcements.js +7 -0
  8. data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -1
  9. data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -11
  10. data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +20 -12
  11. data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +74 -0
  12. data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +160 -0
  13. data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +15 -6
  14. data/app/assets/javascripts/hw_combobox/models/combobox/options.js +19 -7
  15. data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +50 -49
  16. data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +33 -16
  17. data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
  18. data/app/assets/javascripts/hw_combobox/models/combobox.js +3 -0
  19. data/app/assets/stylesheets/hotwire_combobox.css +90 -19
  20. data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
  21. data/app/presenters/hotwire_combobox/component.rb +106 -32
  22. data/app/presenters/hotwire_combobox/listbox/group.rb +47 -0
  23. data/app/presenters/hotwire_combobox/listbox/item/collection.rb +14 -0
  24. data/app/presenters/hotwire_combobox/listbox/item.rb +111 -0
  25. data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
  26. data/app/views/hotwire_combobox/_component.html.erb +1 -0
  27. data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
  28. data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
  29. data/lib/hotwire_combobox/helper.rb +112 -91
  30. data/lib/hotwire_combobox/version.rb +1 -1
  31. metadata +11 -3
@@ -1,3 +1,6 @@
1
+ /*!
2
+ HotwireCombobox 0.2.1
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,6 +40,12 @@
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
@@ -129,6 +138,22 @@
129
138
  return event
130
139
  }
131
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
+
132
157
  Combobox.Autocomplete = Base => class extends Base {
133
158
  _connectListAutocomplete() {
134
159
  if (!this._autocompletesList) {
@@ -236,7 +261,7 @@
236
261
  this.dialogFocusTrapTarget.focus();
237
262
  }
238
263
 
239
- get _smallViewport() {
264
+ get _isSmallViewport() {
240
265
  return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
241
266
  }
242
267
 
@@ -246,25 +271,35 @@
246
271
  };
247
272
 
248
273
  Combobox.Events = Base => class extends Base {
249
- _dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
250
- if (previousValue !== this._value) {
251
- dispatch("hw-combobox:selection", {
252
- target: this.element,
253
- detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
254
- });
255
- }
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
+ });
281
+ }
282
+
283
+ _dispatchSelectionEvent() {
284
+ dispatch("hw-combobox:selection", {
285
+ target: this.element,
286
+ detail: this._eventableDetails
287
+ });
256
288
  }
257
289
 
258
- _dispatchClosedEvent() {
259
- dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
290
+ _dispatchRemovalEvent({ removedDisplay, removedValue }) {
291
+ dispatch("hw-combobox:removal", {
292
+ target: this.element,
293
+ detail: { ...this._eventableDetails, removedDisplay, removedValue }
294
+ });
260
295
  }
261
296
 
262
297
  get _eventableDetails() {
263
298
  return {
264
- value: this._value,
299
+ value: this._incomingFieldValueString,
265
300
  display: this._fullQuery,
266
301
  query: this._typedQuery,
267
- fieldName: this.hiddenFieldTarget.name,
302
+ fieldName: this._fieldName,
268
303
  isValid: this._valueIsValid
269
304
  }
270
305
  }
@@ -548,11 +583,11 @@
548
583
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
549
584
 
550
585
  Combobox.Filtering = Base => class extends Base {
551
- filterAndSelect(event) {
552
- this._filter(event);
586
+ filterAndSelect({ inputType }) {
587
+ this._filter(inputType);
553
588
 
554
589
  if (this._isSync) {
555
- this._selectBasedOnQuery(event);
590
+ this._selectOnQuery(inputType);
556
591
  }
557
592
  }
558
593
 
@@ -560,24 +595,24 @@
560
595
  this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
561
596
  }
562
597
 
563
- _filter(event) {
598
+ _filter(inputType) {
564
599
  if (this._isAsync) {
565
- this._debouncedFilterAsync(event);
600
+ this._debouncedFilterAsync(inputType);
566
601
  } else {
567
602
  this._filterSync();
568
603
  }
569
604
 
570
- this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
605
+ this._markQueried();
571
606
  }
572
607
 
573
- _debouncedFilterAsync(event) {
574
- this._filterAsync(event);
608
+ _debouncedFilterAsync(inputType) {
609
+ this._filterAsync(inputType);
575
610
  }
576
611
 
577
- async _filterAsync(event) {
612
+ async _filterAsync(inputType) {
578
613
  const query = {
579
614
  q: this._fullQuery,
580
- input_type: event.inputType,
615
+ input_type: inputType,
581
616
  for_id: this.element.dataset.asyncId
582
617
  };
583
618
 
@@ -585,8 +620,12 @@
585
620
  }
586
621
 
587
622
  _filterSync() {
588
- this.open();
589
- this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
623
+ this._allFilterableOptionElements.forEach(
624
+ applyFilter(
625
+ this._fullQuery,
626
+ { matching: this.filterableAttributeValue }
627
+ )
628
+ );
590
629
  }
591
630
 
592
631
  _clearQuery() {
@@ -594,6 +633,10 @@
594
633
  this.filterAndSelect({ inputType: "deleteContentBackward" });
595
634
  }
596
635
 
636
+ _markQueried() {
637
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
638
+ }
639
+
597
640
  get _isQueried() {
598
641
  return this._fullQuery.length > 0
599
642
  }
@@ -611,20 +654,255 @@
611
654
  }
612
655
  };
613
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
+
614
887
  Combobox.Navigation = Base => class extends Base {
615
888
  navigate(event) {
616
889
  if (this._autocompletesList) {
617
- this._keyHandlers[event.key]?.call(this, event);
890
+ this._navigationKeyHandlers[event.key]?.call(this, event);
618
891
  }
619
892
  }
620
893
 
621
- _keyHandlers = {
894
+ _navigationKeyHandlers = {
622
895
  ArrowUp: (event) => {
623
896
  this._selectIndex(this._selectedOptionIndex - 1);
624
897
  cancel(event);
625
898
  },
626
899
  ArrowDown: (event) => {
627
900
  this._selectIndex(this._selectedOptionIndex + 1);
901
+
902
+ if (this._selectedOptionIndex === 0) {
903
+ this._actingListbox.scrollTop = 0;
904
+ }
905
+
628
906
  cancel(event);
629
907
  },
630
908
  Home: (event) => {
@@ -636,14 +914,18 @@
636
914
  cancel(event);
637
915
  },
638
916
  Enter: (event) => {
639
- this.close();
640
- this._actingCombobox.blur();
917
+ this._closeAndBlur("hw:keyHandler:enter");
641
918
  cancel(event);
642
919
  },
643
920
  Escape: (event) => {
644
- this.close();
645
- this._actingCombobox.blur();
921
+ this._closeAndBlur("hw:keyHandler:escape");
646
922
  cancel(event);
923
+ },
924
+ Backspace: (event) => {
925
+ if (this._isMultiselect && !this._fullQuery) {
926
+ this._focusLastChipDismisser();
927
+ cancel(event);
928
+ }
647
929
  }
648
930
  }
649
931
  };
@@ -694,28 +976,40 @@
694
976
  }
695
977
 
696
978
  _resetOptions(deselectionStrategy) {
697
- this._setName(this.originalNameValue);
979
+ this._fieldName = this.originalNameValue;
698
980
  deselectionStrategy();
699
981
  }
700
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)
989
+ }
990
+
701
991
  get _allowNew() {
702
992
  return !!this.nameWhenNewValue
703
993
  }
704
994
 
705
995
  get _allOptions() {
706
- return Array.from(this._allOptionElements)
996
+ return Array.from(this._allFilterableOptionElements)
707
997
  }
708
998
 
709
- get _allOptionElements() {
710
- return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
999
+ get _allFilterableOptionElements() {
1000
+ return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`)
711
1001
  }
712
1002
 
713
1003
  get _visibleOptionElements() {
714
- return [ ...this._allOptionElements ].filter(visible)
1004
+ return [ ...this._allFilterableOptionElements ].filter(visible)
715
1005
  }
716
1006
 
717
1007
  get _selectedOptionElement() {
718
- 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]")
719
1013
  }
720
1014
 
721
1015
  get _selectedOptionIndex() {
@@ -723,7 +1017,7 @@
723
1017
  }
724
1018
 
725
1019
  get _isUnjustifiablyBlank() {
726
- const valueIsMissing = !this._value;
1020
+ const valueIsMissing = this._hasEmptyFieldValue;
727
1021
  const noBlankOptionSelected = !this._selectedOptionElement;
728
1022
 
729
1023
  return valueIsMissing && noBlankOptionSelected
@@ -731,23 +1025,24 @@
731
1025
  };
732
1026
 
733
1027
  Combobox.Selection = Base => class extends Base {
734
- selectOptionOnClick(event) {
735
- this._forceSelectionAndFilter(event.currentTarget, event);
736
- this.close();
1028
+ selectOnClick({ currentTarget, inputType }) {
1029
+ this._forceSelectionAndFilter(currentTarget, inputType);
1030
+ this._closeAndBlur("hw:optionRoleClick");
737
1031
  }
738
1032
 
739
1033
  _connectSelection() {
740
1034
  if (this.hasPrefilledDisplayValue) {
741
1035
  this._fullQuery = this.prefilledDisplayValue;
1036
+ this._markQueried();
742
1037
  }
743
1038
  }
744
1039
 
745
- _selectBasedOnQuery(event) {
746
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
1040
+ _selectOnQuery(inputType) {
1041
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
747
1042
  this._selectNew();
748
- } else if (isDeleteEvent(event)) {
1043
+ } else if (isDeleteEvent({ inputType: inputType })) {
749
1044
  this._deselect();
750
- } else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
1045
+ } else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
751
1046
  this._selectAndAutocompleteMissingPortion(this._ensurableOption);
752
1047
  } else if (this._isOpen && this._visibleOptionElements[0]) {
753
1048
  this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
@@ -758,38 +1053,38 @@
758
1053
  }
759
1054
 
760
1055
  _select(option, autocompleteStrategy) {
761
- const previousValue = this._value;
1056
+ const previousValue = this._fieldValueString;
762
1057
 
763
1058
  this._resetOptionsSilently();
764
1059
 
765
1060
  autocompleteStrategy(option);
766
1061
 
767
- this._setValue(option.dataset.value);
1062
+ this._fieldValue = option.dataset.value;
768
1063
  this._markSelected(option);
769
1064
  this._markValid();
770
- this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
1065
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
771
1066
 
772
1067
  option.scrollIntoView({ block: "nearest" });
773
1068
  }
774
1069
 
775
1070
  _selectNew() {
776
- const previousValue = this._value;
1071
+ const previousValue = this._fieldValueString;
777
1072
 
778
1073
  this._resetOptionsSilently();
779
- this._setValue(this._fullQuery);
780
- this._setName(this.nameWhenNewValue);
1074
+ this._fieldValue = this._fullQuery;
1075
+ this._fieldName = this.nameWhenNewValue;
781
1076
  this._markValid();
782
- this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
1077
+ this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
783
1078
  }
784
1079
 
785
1080
  _deselect() {
786
- const previousValue = this._value;
1081
+ const previousValue = this._fieldValueString;
787
1082
 
788
1083
  if (this._selectedOptionElement) {
789
1084
  this._markNotSelected(this._selectedOptionElement);
790
1085
  }
791
1086
 
792
- this._setValue(null);
1087
+ this._fieldValue = "";
793
1088
  this._setActiveDescendant("");
794
1089
 
795
1090
  return previousValue
@@ -797,16 +1092,7 @@
797
1092
 
798
1093
  _deselectAndNotify() {
799
1094
  const previousValue = this._deselect();
800
- this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
801
- }
802
-
803
- _forceSelectionAndFilter(option, event) {
804
- this._forceSelectionWithoutFiltering(option);
805
- this._filter(event);
806
- }
807
-
808
- _forceSelectionWithoutFiltering(option) {
809
- this._selectAndReplaceFullQuery(option);
1095
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
810
1096
  }
811
1097
 
812
1098
  _selectIndex(index) {
@@ -814,27 +1100,40 @@
814
1100
  this._forceSelectionWithoutFiltering(option);
815
1101
  }
816
1102
 
817
- _preselectOption() {
818
- if (this._hasValueButNoSelection && this._allOptions.length < 100) {
819
- const option = this._allOptions.find(option => {
820
- return option.dataset.value === this._value
821
- });
822
-
1103
+ _preselectSingle() {
1104
+ if (this._isSingleSelect && this._hasValueButNoSelection && this._allOptions.length < 100) {
1105
+ const option = this._optionElementWithValue(this._fieldValue);
823
1106
  if (option) this._markSelected(option);
824
1107
  }
825
1108
  }
826
1109
 
827
- _selectAndReplaceFullQuery(option) {
828
- this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
1110
+ _preselectMultiple() {
1111
+ if (this._isMultiselect && this._hasValueButNoSelection) {
1112
+ this._requestChips(this._fieldValueString);
1113
+ this._resetMultiselectionMarks();
1114
+ }
829
1115
  }
830
1116
 
831
1117
  _selectAndAutocompleteMissingPortion(option) {
832
1118
  this._select(option, this._autocompleteMissingPortion.bind(this));
833
1119
  }
834
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
+
835
1134
  _lockInSelection() {
836
1135
  if (this._shouldLockInSelection) {
837
- this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" });
1136
+ this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection");
838
1137
  }
839
1138
  }
840
1139
 
@@ -858,20 +1157,16 @@
858
1157
  this._setActiveDescendant("");
859
1158
  }
860
1159
 
861
- _setValue(value) {
862
- this.hiddenFieldTarget.value = value;
863
- }
864
-
865
- _setName(value) {
866
- this.hiddenFieldTarget.name = value;
867
- }
868
-
869
- get _value() {
870
- return this.hiddenFieldTarget.value
1160
+ get _hasValueButNoSelection() {
1161
+ return this._hasFieldValue && !this._hasSelection
871
1162
  }
872
1163
 
873
- get _hasValueButNoSelection() {
874
- return this._value && !this._selectedOptionElement
1164
+ get _hasSelection() {
1165
+ if (this._isSingleSelect) {
1166
+ this._selectedOptionElement;
1167
+ } else {
1168
+ this._multiselectedOptionElements.length > 0;
1169
+ }
875
1170
  }
876
1171
 
877
1172
  get _shouldLockInSelection() {
@@ -1162,22 +1457,40 @@
1162
1457
  this.expandedValue = true;
1163
1458
  }
1164
1459
 
1165
- close() {
1460
+ openByFocusing() {
1461
+ this._actingCombobox.focus();
1462
+ }
1463
+
1464
+ close(inputType) {
1166
1465
  if (this._isOpen) {
1466
+ const shouldReopen = this._isMultiselect &&
1467
+ this._isSync &&
1468
+ !this._isSmallViewport &&
1469
+ inputType != "hw:clickOutside" &&
1470
+ inputType != "hw:focusOutside";
1471
+
1167
1472
  this._lockInSelection();
1168
1473
  this._clearInvalidQuery();
1169
1474
 
1170
1475
  this.expandedValue = false;
1171
1476
 
1172
- this._dispatchClosedEvent();
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
+ }
1173
1486
  }
1174
1487
  }
1175
1488
 
1176
1489
  toggle() {
1177
1490
  if (this.expandedValue) {
1178
- this.close();
1491
+ this._closeAndBlur("hw:toggle");
1179
1492
  } else {
1180
- this._openByFocusing();
1493
+ this.openByFocusing();
1181
1494
  }
1182
1495
  }
1183
1496
 
@@ -1188,14 +1501,14 @@
1188
1501
  if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
1189
1502
  if (this._withinElementBounds(event)) return
1190
1503
 
1191
- this.close();
1504
+ this._closeAndBlur("hw:clickOutside");
1192
1505
  }
1193
1506
 
1194
1507
  closeOnFocusOutside({ target }) {
1195
1508
  if (!this._isOpen) return
1196
1509
  if (this.element.contains(target)) return
1197
1510
 
1198
- this.close();
1511
+ this._closeAndBlur("hw:focusOutside");
1199
1512
  }
1200
1513
 
1201
1514
  clearOrToggleOnHandleClick() {
@@ -1207,6 +1520,11 @@
1207
1520
  }
1208
1521
  }
1209
1522
 
1523
+ _closeAndBlur(inputType) {
1524
+ this.close(inputType);
1525
+ this._actingCombobox.blur();
1526
+ }
1527
+
1210
1528
  // Some browser extensions like 1Password overlay elements on top of the combobox.
1211
1529
  // Hovering over these elements emits a click event for some reason.
1212
1530
  // These events don't contain any telling information, so we use `_withinElementBounds`
@@ -1218,18 +1536,16 @@
1218
1536
  return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
1219
1537
  }
1220
1538
 
1221
- _openByFocusing() {
1222
- this._actingCombobox.focus();
1223
- }
1224
-
1225
1539
  _isDialogDismisser(target) {
1226
1540
  return target.closest("dialog") && target.role != "combobox"
1227
1541
  }
1228
1542
 
1229
1543
  _expand() {
1230
- if (this._preselectOnExpansion) this._preselectOption();
1544
+ if (this._isSync) {
1545
+ this._preselectSingle();
1546
+ }
1231
1547
 
1232
- if (this._autocompletesList && this._smallViewport) {
1548
+ if (this._autocompletesList && this._isSmallViewport) {
1233
1549
  this._openInDialog();
1234
1550
  } else {
1235
1551
  this._openInline();
@@ -1290,10 +1606,6 @@
1290
1606
  get _isOpen() {
1291
1607
  return this.expandedValue
1292
1608
  }
1293
-
1294
- get _preselectOnExpansion() {
1295
- return !this._isAsync // async comboboxes preselect based on callbacks
1296
- }
1297
1609
  };
1298
1610
 
1299
1611
  Combobox.Validity = Base => class extends Base {
@@ -1330,7 +1642,7 @@
1330
1642
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1331
1643
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
1332
1644
  get _valueIsInvalid() {
1333
- const isRequiredAndEmpty = this.comboboxTarget.required && !this._value;
1645
+ const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue;
1334
1646
  return isRequiredAndEmpty
1335
1647
  }
1336
1648
  };
@@ -1340,11 +1652,14 @@
1340
1652
  const concerns = [
1341
1653
  stimulus.Controller,
1342
1654
  Combobox.Actors,
1655
+ Combobox.Announcements,
1343
1656
  Combobox.AsyncLoading,
1344
1657
  Combobox.Autocomplete,
1345
1658
  Combobox.Dialog,
1346
1659
  Combobox.Events,
1347
1660
  Combobox.Filtering,
1661
+ Combobox.FormField,
1662
+ Combobox.Multiselect,
1348
1663
  Combobox.Navigation,
1349
1664
  Combobox.NewOptions,
1350
1665
  Combobox.Options,
@@ -1360,7 +1675,10 @@
1360
1675
  ]
1361
1676
 
1362
1677
  static targets = [
1678
+ "announcer",
1363
1679
  "combobox",
1680
+ "chipDismisser",
1681
+ "closer",
1364
1682
  "dialog",
1365
1683
  "dialogCombobox",
1366
1684
  "dialogFocusTrap",
@@ -1381,15 +1699,21 @@
1381
1699
  nameWhenNew: String,
1382
1700
  originalName: String,
1383
1701
  prefilledDisplay: String,
1702
+ selectionChipSrc: String,
1384
1703
  smallViewportMaxWidth: String
1385
1704
  }
1386
1705
 
1387
1706
  initialize() {
1388
1707
  this._initializeActors();
1389
1708
  this._initializeFiltering();
1709
+ this._initializeMultiselect();
1390
1710
  }
1391
1711
 
1392
1712
  connect() {
1713
+ this.idempotentConnect();
1714
+ }
1715
+
1716
+ idempotentConnect() {
1393
1717
  this._connectSelection();
1394
1718
  this._connectListAutocomplete();
1395
1719
  this._connectDialog();
@@ -1411,13 +1735,27 @@
1411
1735
  const inputType = element.dataset.inputType;
1412
1736
  const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
1413
1737
 
1414
- if (inputType && inputType !== "hw:lockInSelection") {
1738
+ this._resetMultiselectionMarks();
1739
+
1740
+ if (inputType === "hw:multiselectSync") {
1741
+ this.openByFocusing();
1742
+ } else if (inputType && inputType !== "hw:lockInSelection") {
1415
1743
  if (delay) await sleep(delay);
1416
- this._selectBasedOnQuery({ inputType });
1744
+ this._selectOnQuery(inputType);
1417
1745
  } else {
1418
- this._preselectOption();
1746
+ this._preselectSingle();
1419
1747
  }
1420
1748
  }
1749
+
1750
+ closerTargetConnected() {
1751
+ this._closeAndBlur("hw:asyncCloser");
1752
+ }
1753
+
1754
+ // Use +_printStack+ for debugging purposes
1755
+ _printStack() {
1756
+ const err = new Error();
1757
+ console.log(err.stack || err.stacktrace);
1758
+ }
1421
1759
  }
1422
1760
 
1423
1761
  return HwComboboxController;