hotwire_combobox 0.1.42 → 0.2.0

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 (32) 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 +531 -127
  5. data/app/assets/javascripts/hotwire_combobox.umd.js +531 -127
  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/async_loading.js +4 -0
  9. data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +8 -6
  10. data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -1
  11. data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -6
  12. data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +33 -28
  13. data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +74 -0
  14. data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +160 -0
  15. data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +15 -6
  16. data/app/assets/javascripts/hw_combobox/models/combobox/options.js +29 -9
  17. data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +103 -51
  18. data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +45 -16
  19. data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
  20. data/app/assets/javascripts/hw_combobox/models/combobox.js +3 -0
  21. data/app/assets/stylesheets/hotwire_combobox.css +84 -18
  22. data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
  23. data/app/presenters/hotwire_combobox/component.rb +95 -28
  24. data/app/presenters/hotwire_combobox/listbox/group.rb +45 -0
  25. data/app/presenters/hotwire_combobox/listbox/item.rb +104 -0
  26. data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
  27. data/app/views/hotwire_combobox/_component.html.erb +1 -0
  28. data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
  29. data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
  30. data/lib/hotwire_combobox/helper.rb +111 -86
  31. data/lib/hotwire_combobox/version.rb +1 -1
  32. 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,10 +40,20 @@
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
43
52
  }
53
+
54
+ get _isSync() {
55
+ return !this._isAsync
56
+ }
44
57
  };
45
58
 
46
59
  function Concerns(Base, ...mixins) {
@@ -125,6 +138,22 @@
125
138
  return event
126
139
  }
127
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
+
128
157
  Combobox.Autocomplete = Base => class extends Base {
129
158
  _connectListAutocomplete() {
130
159
  if (!this._autocompletesList) {
@@ -132,16 +161,18 @@
132
161
  }
133
162
  }
134
163
 
135
- _autocompleteWith(option, { force }) {
136
- if (!this._autocompletesInline && !force) return
164
+ _replaceFullQueryWithAutocompletedValue(option) {
165
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
166
+
167
+ this._fullQuery = autocompletedValue;
168
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
169
+ }
137
170
 
171
+ _autocompleteMissingPortion(option) {
138
172
  const typedValue = this._typedQuery;
139
173
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
140
174
 
141
- if (force) {
142
- this._fullQuery = autocompletedValue;
143
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
144
- } else if (startsWith(autocompletedValue, typedValue)) {
175
+ if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
145
176
  this._fullQuery = autocompletedValue;
146
177
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
147
178
  }
@@ -230,7 +261,7 @@
230
261
  this.dialogFocusTrapTarget.focus();
231
262
  }
232
263
 
233
- get _smallViewport() {
264
+ get _isSmallViewport() {
234
265
  return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
235
266
  }
236
267
 
@@ -240,20 +271,35 @@
240
271
  };
241
272
 
242
273
  Combobox.Events = Base => class extends Base {
243
- _dispatchSelectionEvent({ isNew }) {
244
- dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } });
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
+ });
245
281
  }
246
282
 
247
- _dispatchClosedEvent() {
248
- dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
283
+ _dispatchSelectionEvent() {
284
+ dispatch("hw-combobox:selection", {
285
+ target: this.element,
286
+ detail: this._eventableDetails
287
+ });
288
+ }
289
+
290
+ _dispatchRemovalEvent({ removedDisplay, removedValue }) {
291
+ dispatch("hw-combobox:removal", {
292
+ target: this.element,
293
+ detail: { ...this._eventableDetails, removedDisplay, removedValue }
294
+ });
249
295
  }
250
296
 
251
297
  get _eventableDetails() {
252
298
  return {
253
- value: this.hiddenFieldTarget.value,
299
+ value: this._incomingFieldValueString,
254
300
  display: this._fullQuery,
255
301
  query: this._typedQuery,
256
- fieldName: this.hiddenFieldTarget.name,
302
+ fieldName: this._fieldName,
257
303
  isValid: this._valueIsValid
258
304
  }
259
305
  }
@@ -537,55 +583,58 @@
537
583
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
538
584
 
539
585
  Combobox.Filtering = Base => class extends Base {
540
- filter(event) {
541
- if (this._isAsync) {
542
- this._debouncedFilterAsync(event);
543
- } else {
544
- this._filterSync(event);
545
- }
586
+ filterAndSelect({ inputType }) {
587
+ this._filter(inputType);
546
588
 
547
- this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
589
+ if (this._isSync) {
590
+ this._selectOnQuery(inputType);
591
+ }
548
592
  }
549
593
 
550
594
  _initializeFiltering() {
551
595
  this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
552
596
  }
553
597
 
554
- _debouncedFilterAsync(event) {
555
- this._filterAsync(event);
598
+ _filter(inputType) {
599
+ if (this._isAsync) {
600
+ this._debouncedFilterAsync(inputType);
601
+ } else {
602
+ this._filterSync();
603
+ }
604
+
605
+ this._markQueried();
606
+ }
607
+
608
+ _debouncedFilterAsync(inputType) {
609
+ this._filterAsync(inputType);
556
610
  }
557
611
 
558
- async _filterAsync(event) {
612
+ async _filterAsync(inputType) {
559
613
  const query = {
560
614
  q: this._fullQuery,
561
- input_type: event.inputType,
615
+ input_type: inputType,
562
616
  for_id: this.element.dataset.asyncId
563
617
  };
564
618
 
565
619
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
566
620
  }
567
621
 
568
- _filterSync(event) {
569
- this.open();
570
- this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
571
- this._commitFilter(event);
572
- }
573
-
574
- _commitFilter(event) {
575
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
576
- this._selectNew();
577
- } else if (isDeleteEvent(event)) {
578
- this._deselect();
579
- } else if (event.inputType === "hw:lockInSelection") {
580
- this._select(this._ensurableOption);
581
- } else if (this._isOpen) {
582
- this._select(this._visibleOptionElements[0]);
583
- }
622
+ _filterSync() {
623
+ this._allFilterableOptionElements.forEach(
624
+ applyFilter(
625
+ this._fullQuery,
626
+ { matching: this.filterableAttributeValue }
627
+ )
628
+ );
584
629
  }
585
630
 
586
631
  _clearQuery() {
587
632
  this._fullQuery = "";
588
- this.filter({ inputType: "deleteContentBackward" });
633
+ this.filterAndSelect({ inputType: "deleteContentBackward" });
634
+ }
635
+
636
+ _markQueried() {
637
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
589
638
  }
590
639
 
591
640
  get _isQueried() {
@@ -605,20 +654,255 @@
605
654
  }
606
655
  };
607
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
+
608
887
  Combobox.Navigation = Base => class extends Base {
609
888
  navigate(event) {
610
889
  if (this._autocompletesList) {
611
- this._keyHandlers[event.key]?.call(this, event);
890
+ this._navigationKeyHandlers[event.key]?.call(this, event);
612
891
  }
613
892
  }
614
893
 
615
- _keyHandlers = {
894
+ _navigationKeyHandlers = {
616
895
  ArrowUp: (event) => {
617
896
  this._selectIndex(this._selectedOptionIndex - 1);
618
897
  cancel(event);
619
898
  },
620
899
  ArrowDown: (event) => {
621
900
  this._selectIndex(this._selectedOptionIndex + 1);
901
+
902
+ if (this._selectedOptionIndex === 0) {
903
+ this._actingListbox.scrollTop = 0;
904
+ }
905
+
622
906
  cancel(event);
623
907
  },
624
908
  Home: (event) => {
@@ -630,14 +914,18 @@
630
914
  cancel(event);
631
915
  },
632
916
  Enter: (event) => {
633
- this.close();
634
- this._actingCombobox.blur();
917
+ this._closeAndBlur("hw:keyHandler:enter");
635
918
  cancel(event);
636
919
  },
637
920
  Escape: (event) => {
638
- this.close();
639
- this._actingCombobox.blur();
921
+ this._closeAndBlur("hw:keyHandler:escape");
640
922
  cancel(event);
923
+ },
924
+ Backspace: (event) => {
925
+ if (this._isMultiselect && !this._fullQuery) {
926
+ this._focusLastChipDismisser();
927
+ cancel(event);
928
+ }
641
929
  }
642
930
  }
643
931
  };
@@ -679,9 +967,25 @@
679
967
  };
680
968
 
681
969
  Combobox.Options = Base => class extends Base {
682
- _resetOptions() {
683
- this._deselect();
684
- this.hiddenFieldTarget.name = this.originalNameValue;
970
+ _resetOptionsSilently() {
971
+ this._resetOptions(this._deselect.bind(this));
972
+ }
973
+
974
+ _resetOptionsAndNotify() {
975
+ this._resetOptions(this._deselectAndNotify.bind(this));
976
+ }
977
+
978
+ _resetOptions(deselectionStrategy) {
979
+ this._fieldName = this.originalNameValue;
980
+ deselectionStrategy();
981
+ }
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)
685
989
  }
686
990
 
687
991
  get _allowNew() {
@@ -689,19 +993,23 @@
689
993
  }
690
994
 
691
995
  get _allOptions() {
692
- return Array.from(this._allOptionElements)
996
+ return Array.from(this._allFilterableOptionElements)
693
997
  }
694
998
 
695
- get _allOptionElements() {
696
- return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
999
+ get _allFilterableOptionElements() {
1000
+ return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`)
697
1001
  }
698
1002
 
699
1003
  get _visibleOptionElements() {
700
- return [ ...this._allOptionElements ].filter(visible)
1004
+ return [ ...this._allFilterableOptionElements ].filter(visible)
701
1005
  }
702
1006
 
703
1007
  get _selectedOptionElement() {
704
- 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]")
705
1013
  }
706
1014
 
707
1015
  get _selectedOptionIndex() {
@@ -709,7 +1017,7 @@
709
1017
  }
710
1018
 
711
1019
  get _isUnjustifiablyBlank() {
712
- const valueIsMissing = !this.hiddenFieldTarget.value;
1020
+ const valueIsMissing = this._hasEmptyFieldValue;
713
1021
  const noBlankOptionSelected = !this._selectedOptionElement;
714
1022
 
715
1023
  return valueIsMissing && noBlankOptionSelected
@@ -717,103 +1025,148 @@
717
1025
  };
718
1026
 
719
1027
  Combobox.Selection = Base => class extends Base {
720
- selectOptionOnClick(event) {
721
- this.filter(event);
722
- this._select(event.currentTarget, { forceAutocomplete: true });
723
- this.close();
1028
+ selectOnClick({ currentTarget, inputType }) {
1029
+ this._forceSelectionAndFilter(currentTarget, inputType);
1030
+ this._closeAndBlur("hw:optionRoleClick");
724
1031
  }
725
1032
 
726
1033
  _connectSelection() {
727
1034
  if (this.hasPrefilledDisplayValue) {
728
1035
  this._fullQuery = this.prefilledDisplayValue;
1036
+ this._markQueried();
729
1037
  }
730
1038
  }
731
1039
 
732
- _select(option, { forceAutocomplete = false } = {}) {
733
- this._resetOptions();
734
-
735
- if (option) {
736
- this._autocompleteWith(option, { force: forceAutocomplete });
737
- this._commitSelection(option, { selected: true });
738
- this._markValid();
739
- } else {
1040
+ _selectOnQuery(inputType) {
1041
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
1042
+ this._selectNew();
1043
+ } else if (isDeleteEvent({ inputType: inputType })) {
1044
+ this._deselect();
1045
+ } else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
1046
+ this._selectAndAutocompleteMissingPortion(this._ensurableOption);
1047
+ } else if (this._isOpen && this._visibleOptionElements[0]) {
1048
+ this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
1049
+ } else if (this._isOpen) {
1050
+ this._resetOptionsAndNotify();
740
1051
  this._markInvalid();
741
- }
1052
+ } else ;
742
1053
  }
743
1054
 
744
- _commitSelection(option, { selected }) {
745
- this._markSelected(option, { selected });
1055
+ _select(option, autocompleteStrategy) {
1056
+ const previousValue = this._fieldValueString;
746
1057
 
747
- if (selected) {
748
- this.hiddenFieldTarget.value = option.dataset.value;
749
- option.scrollIntoView({ block: "nearest" });
750
- }
1058
+ this._resetOptionsSilently();
1059
+
1060
+ autocompleteStrategy(option);
751
1061
 
752
- this._dispatchSelectionEvent({ isNew: false });
1062
+ this._fieldValue = option.dataset.value;
1063
+ this._markSelected(option);
1064
+ this._markValid();
1065
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
1066
+
1067
+ option.scrollIntoView({ block: "nearest" });
753
1068
  }
754
1069
 
755
- _markSelected(option, { selected }) {
756
- if (this.hasSelectedClass) {
757
- option.classList.toggle(this.selectedClass, selected);
758
- }
1070
+ _selectNew() {
1071
+ const previousValue = this._fieldValueString;
759
1072
 
760
- option.setAttribute("aria-selected", selected);
761
- this._setActiveDescendant(selected ? option.id : "");
1073
+ this._resetOptionsSilently();
1074
+ this._fieldValue = this._fullQuery;
1075
+ this._fieldName = this.nameWhenNewValue;
1076
+ this._markValid();
1077
+ this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
762
1078
  }
763
1079
 
764
1080
  _deselect() {
765
- const option = this._selectedOptionElement;
1081
+ const previousValue = this._fieldValueString;
766
1082
 
767
- if (option) this._commitSelection(option, { selected: false });
1083
+ if (this._selectedOptionElement) {
1084
+ this._markNotSelected(this._selectedOptionElement);
1085
+ }
768
1086
 
769
- this.hiddenFieldTarget.value = null;
1087
+ this._fieldValue = "";
770
1088
  this._setActiveDescendant("");
771
1089
 
772
- if (!option) this._dispatchSelectionEvent({ isNew: false });
1090
+ return previousValue
773
1091
  }
774
1092
 
775
- _selectNew() {
776
- this._resetOptions();
777
- this.hiddenFieldTarget.value = this._fullQuery;
778
- this.hiddenFieldTarget.name = this.nameWhenNewValue;
779
- this._markValid();
780
-
781
- this._dispatchSelectionEvent({ isNew: true });
1093
+ _deselectAndNotify() {
1094
+ const previousValue = this._deselect();
1095
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
782
1096
  }
783
1097
 
784
1098
  _selectIndex(index) {
785
1099
  const option = wrapAroundAccess(this._visibleOptionElements, index);
786
- this._select(option, { forceAutocomplete: true });
1100
+ this._forceSelectionWithoutFiltering(option);
787
1101
  }
788
1102
 
789
- _preselectOption() {
790
- if (this._hasValueButNoSelection && this._allOptions.length < 100) {
791
- const option = this._allOptions.find(option => {
792
- return option.dataset.value === this.hiddenFieldTarget.value
793
- });
1103
+ _preselectSingle() {
1104
+ if (this._isSingleSelect && this._hasValueButNoSelection && this._allOptions.length < 100) {
1105
+ const option = this._optionElementWithValue(this._fieldValue);
1106
+ if (option) this._markSelected(option);
1107
+ }
1108
+ }
794
1109
 
795
- if (option) this._markSelected(option, { selected: true });
1110
+ _preselectMultiple() {
1111
+ if (this._isMultiselect && this._hasValueButNoSelection) {
1112
+ this._requestChips(this._fieldValueString);
1113
+ this._resetMultiselectionMarks();
796
1114
  }
797
1115
  }
798
1116
 
1117
+ _selectAndAutocompleteMissingPortion(option) {
1118
+ this._select(option, this._autocompleteMissingPortion.bind(this));
1119
+ }
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
+
799
1134
  _lockInSelection() {
800
1135
  if (this._shouldLockInSelection) {
801
- this._select(this._ensurableOption, { forceAutocomplete: true });
802
- this.filter({ inputType: "hw:lockInSelection" });
1136
+ this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection");
803
1137
  }
1138
+ }
804
1139
 
805
- if (this._isUnjustifiablyBlank) {
806
- this._deselect();
807
- this._clearQuery();
808
- }
1140
+ _markSelected(option) {
1141
+ if (this.hasSelectedClass) option.classList.add(this.selectedClass);
1142
+ option.setAttribute("aria-selected", true);
1143
+ this._setActiveDescendant(option.id);
1144
+ }
1145
+
1146
+ _markNotSelected(option) {
1147
+ if (this.hasSelectedClass) option.classList.remove(this.selectedClass);
1148
+ option.removeAttribute("aria-selected");
1149
+ this._removeActiveDescendant();
809
1150
  }
810
1151
 
811
1152
  _setActiveDescendant(id) {
812
1153
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
813
1154
  }
814
1155
 
1156
+ _removeActiveDescendant() {
1157
+ this._setActiveDescendant("");
1158
+ }
1159
+
815
1160
  get _hasValueButNoSelection() {
816
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
1161
+ return this._hasFieldValue && !this._hasSelection
1162
+ }
1163
+
1164
+ get _hasSelection() {
1165
+ if (this._isSingleSelect) {
1166
+ this._selectedOptionElement;
1167
+ } else {
1168
+ this._multiselectedOptionElements.length > 0;
1169
+ }
817
1170
  }
818
1171
 
819
1172
  get _shouldLockInSelection() {
@@ -1104,19 +1457,40 @@
1104
1457
  this.expandedValue = true;
1105
1458
  }
1106
1459
 
1107
- close() {
1460
+ openByFocusing() {
1461
+ this._actingCombobox.focus();
1462
+ }
1463
+
1464
+ close(inputType) {
1108
1465
  if (this._isOpen) {
1466
+ const shouldReopen = this._isMultiselect &&
1467
+ this._isSync &&
1468
+ !this._isSmallViewport &&
1469
+ inputType != "hw:clickOutside" &&
1470
+ inputType != "hw:focusOutside";
1471
+
1109
1472
  this._lockInSelection();
1473
+ this._clearInvalidQuery();
1474
+
1110
1475
  this.expandedValue = false;
1111
- this._dispatchClosedEvent();
1476
+
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
+ }
1112
1486
  }
1113
1487
  }
1114
1488
 
1115
1489
  toggle() {
1116
1490
  if (this.expandedValue) {
1117
- this.close();
1491
+ this._closeAndBlur("hw:toggle");
1118
1492
  } else {
1119
- this._openByFocusing();
1493
+ this.openByFocusing();
1120
1494
  }
1121
1495
  }
1122
1496
 
@@ -1127,14 +1501,14 @@
1127
1501
  if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
1128
1502
  if (this._withinElementBounds(event)) return
1129
1503
 
1130
- this.close();
1504
+ this._closeAndBlur("hw:clickOutside");
1131
1505
  }
1132
1506
 
1133
1507
  closeOnFocusOutside({ target }) {
1134
1508
  if (!this._isOpen) return
1135
1509
  if (this.element.contains(target)) return
1136
1510
 
1137
- this.close();
1511
+ this._closeAndBlur("hw:focusOutside");
1138
1512
  }
1139
1513
 
1140
1514
  clearOrToggleOnHandleClick() {
@@ -1146,6 +1520,11 @@
1146
1520
  }
1147
1521
  }
1148
1522
 
1523
+ _closeAndBlur(inputType) {
1524
+ this.close(inputType);
1525
+ this._actingCombobox.blur();
1526
+ }
1527
+
1149
1528
  // Some browser extensions like 1Password overlay elements on top of the combobox.
1150
1529
  // Hovering over these elements emits a click event for some reason.
1151
1530
  // These events don't contain any telling information, so we use `_withinElementBounds`
@@ -1157,18 +1536,16 @@
1157
1536
  return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
1158
1537
  }
1159
1538
 
1160
- _openByFocusing() {
1161
- this._actingCombobox.focus();
1162
- }
1163
-
1164
1539
  _isDialogDismisser(target) {
1165
1540
  return target.closest("dialog") && target.role != "combobox"
1166
1541
  }
1167
1542
 
1168
1543
  _expand() {
1169
- if (this._preselectOnExpansion) this._preselectOption();
1544
+ if (this._isSync) {
1545
+ this._preselectSingle();
1546
+ }
1170
1547
 
1171
- if (this._autocompletesList && this._smallViewport) {
1548
+ if (this._autocompletesList && this._isSmallViewport) {
1172
1549
  this._openInDialog();
1173
1550
  } else {
1174
1551
  this._openInline();
@@ -1177,6 +1554,8 @@
1177
1554
  this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
1178
1555
  }
1179
1556
 
1557
+ // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
1558
+ // it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
1180
1559
  _collapse() {
1181
1560
  this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1182
1561
 
@@ -1217,12 +1596,15 @@
1217
1596
  enableBodyScroll(this.dialogListboxTarget);
1218
1597
  }
1219
1598
 
1220
- get _isOpen() {
1221
- return this.expandedValue
1599
+ _clearInvalidQuery() {
1600
+ if (this._isUnjustifiablyBlank) {
1601
+ this._deselect();
1602
+ this._clearQuery();
1603
+ }
1222
1604
  }
1223
1605
 
1224
- get _preselectOnExpansion() {
1225
- return !this._isAsync // async comboboxes preselect based on callbacks
1606
+ get _isOpen() {
1607
+ return this.expandedValue
1226
1608
  }
1227
1609
  };
1228
1610
 
@@ -1260,7 +1642,7 @@
1260
1642
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1261
1643
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
1262
1644
  get _valueIsInvalid() {
1263
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1645
+ const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue;
1264
1646
  return isRequiredAndEmpty
1265
1647
  }
1266
1648
  };
@@ -1270,11 +1652,14 @@
1270
1652
  const concerns = [
1271
1653
  stimulus.Controller,
1272
1654
  Combobox.Actors,
1655
+ Combobox.Announcements,
1273
1656
  Combobox.AsyncLoading,
1274
1657
  Combobox.Autocomplete,
1275
1658
  Combobox.Dialog,
1276
1659
  Combobox.Events,
1277
1660
  Combobox.Filtering,
1661
+ Combobox.FormField,
1662
+ Combobox.Multiselect,
1278
1663
  Combobox.Navigation,
1279
1664
  Combobox.NewOptions,
1280
1665
  Combobox.Options,
@@ -1290,7 +1675,10 @@
1290
1675
  ]
1291
1676
 
1292
1677
  static targets = [
1678
+ "announcer",
1293
1679
  "combobox",
1680
+ "chipDismisser",
1681
+ "closer",
1294
1682
  "dialog",
1295
1683
  "dialogCombobox",
1296
1684
  "dialogFocusTrap",
@@ -1311,12 +1699,14 @@
1311
1699
  nameWhenNew: String,
1312
1700
  originalName: String,
1313
1701
  prefilledDisplay: String,
1702
+ selectionChipSrc: String,
1314
1703
  smallViewportMaxWidth: String
1315
1704
  }
1316
1705
 
1317
1706
  initialize() {
1318
1707
  this._initializeActors();
1319
1708
  this._initializeFiltering();
1709
+ this._initializeMultiselect();
1320
1710
  }
1321
1711
 
1322
1712
  connect() {
@@ -1341,13 +1731,27 @@
1341
1731
  const inputType = element.dataset.inputType;
1342
1732
  const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
1343
1733
 
1344
- if (inputType && inputType !== "hw:lockInSelection") {
1734
+ this._resetMultiselectionMarks();
1735
+
1736
+ if (inputType === "hw:multiselectSync") {
1737
+ this.openByFocusing();
1738
+ } else if (inputType && inputType !== "hw:lockInSelection") {
1345
1739
  if (delay) await sleep(delay);
1346
- this._commitFilter({ inputType });
1740
+ this._selectOnQuery(inputType);
1347
1741
  } else {
1348
- this._preselectOption();
1742
+ this._preselectSingle();
1349
1743
  }
1350
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
+ }
1351
1755
  }
1352
1756
 
1353
1757
  return HwComboboxController;