hotwire_combobox 0.1.43 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/javascripts/controllers/hw_combobox_controller.js +25 -3
  4. data/app/assets/javascripts/hotwire_combobox.esm.js +438 -104
  5. data/app/assets/javascripts/hotwire_combobox.umd.js +438 -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 +84 -18
  20. data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
  21. data/app/presenters/hotwire_combobox/component.rb +93 -26
  22. data/app/presenters/hotwire_combobox/listbox/group.rb +45 -0
  23. data/app/presenters/hotwire_combobox/listbox/item.rb +104 -0
  24. data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
  25. data/app/views/hotwire_combobox/_component.html.erb +1 -0
  26. data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
  27. data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
  28. data/lib/hotwire_combobox/helper.rb +111 -86
  29. data/lib/hotwire_combobox/version.rb +1 -1
  30. metadata +9 -2
@@ -1,3 +1,6 @@
1
+ /*!
2
+ HotwireCombobox 0.2.0
3
+ */
1
4
  (function (global, factory) {
2
5
  typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('@hotwired/stimulus')) :
3
6
  typeof define === 'function' && define.amd ? define(['@hotwired/stimulus'], factory) :
@@ -37,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,12 +1699,14 @@
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() {
@@ -1411,13 +1731,27 @@
1411
1731
  const inputType = element.dataset.inputType;
1412
1732
  const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
1413
1733
 
1414
- if (inputType && inputType !== "hw:lockInSelection") {
1734
+ this._resetMultiselectionMarks();
1735
+
1736
+ if (inputType === "hw:multiselectSync") {
1737
+ this.openByFocusing();
1738
+ } else if (inputType && inputType !== "hw:lockInSelection") {
1415
1739
  if (delay) await sleep(delay);
1416
- this._selectBasedOnQuery({ inputType });
1740
+ this._selectOnQuery(inputType);
1417
1741
  } else {
1418
- this._preselectOption();
1742
+ this._preselectSingle();
1419
1743
  }
1420
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
+ }
1421
1755
  }
1422
1756
 
1423
1757
  return HwComboboxController;