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
  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 };