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.
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
  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 _smallViewport() {
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
- _dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
246
- if (previousValue !== this._value) {
247
- dispatch("hw-combobox:selection", {
248
- target: this.element,
249
- detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
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
- _dispatchClosedEvent() {
255
- dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
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._value,
295
+ value: this._incomingFieldValueString,
261
296
  display: this._fullQuery,
262
297
  query: this._typedQuery,
263
- fieldName: this.hiddenFieldTarget.name,
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(event) {
548
- this._filter(event);
582
+ filterAndSelect({ inputType }) {
583
+ this._filter(inputType);
549
584
 
550
585
  if (this._isSync) {
551
- this._selectBasedOnQuery(event);
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(event) {
594
+ _filter(inputType) {
560
595
  if (this._isAsync) {
561
- this._debouncedFilterAsync(event);
596
+ this._debouncedFilterAsync(inputType);
562
597
  } else {
563
598
  this._filterSync();
564
599
  }
565
600
 
566
- this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
601
+ this._markQueried();
567
602
  }
568
603
 
569
- _debouncedFilterAsync(event) {
570
- this._filterAsync(event);
604
+ _debouncedFilterAsync(inputType) {
605
+ this._filterAsync(inputType);
571
606
  }
572
607
 
573
- async _filterAsync(event) {
608
+ async _filterAsync(inputType) {
574
609
  const query = {
575
610
  q: this._fullQuery,
576
- input_type: event.inputType,
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.open();
585
- this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
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._keyHandlers[event.key]?.call(this, event);
886
+ this._navigationKeyHandlers[event.key]?.call(this, event);
614
887
  }
615
888
  }
616
889
 
617
- _keyHandlers = {
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.close();
636
- this._actingCombobox.blur();
913
+ this._closeAndBlur("hw:keyHandler:enter");
637
914
  cancel(event);
638
915
  },
639
916
  Escape: (event) => {
640
- this.close();
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._setName(this.originalNameValue);
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._allOptionElements)
992
+ return Array.from(this._allFilterableOptionElements)
703
993
  }
704
994
 
705
- get _allOptionElements() {
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._allOptionElements ].filter(visible)
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 = !this._value;
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
- selectOptionOnClick(event) {
731
- this._forceSelectionAndFilter(event.currentTarget, event);
732
- this.close();
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
- _selectBasedOnQuery(event) {
742
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
1036
+ _selectOnQuery(inputType) {
1037
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
743
1038
  this._selectNew();
744
- } else if (isDeleteEvent(event)) {
1039
+ } else if (isDeleteEvent({ inputType: inputType })) {
745
1040
  this._deselect();
746
- } else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
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._value;
1052
+ const previousValue = this._fieldValueString;
758
1053
 
759
1054
  this._resetOptionsSilently();
760
1055
 
761
1056
  autocompleteStrategy(option);
762
1057
 
763
- this._setValue(option.dataset.value);
1058
+ this._fieldValue = option.dataset.value;
764
1059
  this._markSelected(option);
765
1060
  this._markValid();
766
- this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
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._value;
1067
+ const previousValue = this._fieldValueString;
773
1068
 
774
1069
  this._resetOptionsSilently();
775
- this._setValue(this._fullQuery);
776
- this._setName(this.nameWhenNewValue);
1070
+ this._fieldValue = this._fullQuery;
1071
+ this._fieldName = this.nameWhenNewValue;
777
1072
  this._markValid();
778
- this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
1073
+ this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
779
1074
  }
780
1075
 
781
1076
  _deselect() {
782
- const previousValue = this._value;
1077
+ const previousValue = this._fieldValueString;
783
1078
 
784
1079
  if (this._selectedOptionElement) {
785
1080
  this._markNotSelected(this._selectedOptionElement);
786
1081
  }
787
1082
 
788
- this._setValue(null);
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._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
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
- _preselectOption() {
814
- if (this._hasValueButNoSelection && this._allOptions.length < 100) {
815
- const option = this._allOptions.find(option => {
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
- _selectAndReplaceFullQuery(option) {
824
- this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(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, { inputType: "hw:lockInSelection" });
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
- _setValue(value) {
858
- this.hiddenFieldTarget.value = value;
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 _hasValueButNoSelection() {
870
- return this._value && !this._selectedOptionElement
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
- close() {
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._dispatchClosedEvent();
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.close();
1487
+ this._closeAndBlur("hw:toggle");
1175
1488
  } else {
1176
- this._openByFocusing();
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.close();
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.close();
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._preselectOnExpansion) this._preselectOption();
1540
+ if (this._isSync) {
1541
+ this._preselectSingle();
1542
+ }
1227
1543
 
1228
- if (this._autocompletesList && this._smallViewport) {
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 && !this._value;
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
- if (inputType && inputType !== "hw:lockInSelection") {
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._selectBasedOnQuery({ inputType });
1740
+ this._selectOnQuery(inputType);
1413
1741
  } else {
1414
- this._preselectOption();
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 };