hotwire_combobox 0.1.42 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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
  import { Controller } from '@hotwired/stimulus';
2
5
 
3
6
  const Combobox = {};
@@ -33,10 +36,20 @@ 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
39
48
  }
49
+
50
+ get _isSync() {
51
+ return !this._isAsync
52
+ }
40
53
  };
41
54
 
42
55
  function Concerns(Base, ...mixins) {
@@ -121,6 +134,22 @@ function dispatch(eventName, { target, cancelable, detail } = {}) {
121
134
  return event
122
135
  }
123
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
+
124
153
  Combobox.Autocomplete = Base => class extends Base {
125
154
  _connectListAutocomplete() {
126
155
  if (!this._autocompletesList) {
@@ -128,16 +157,18 @@ Combobox.Autocomplete = Base => class extends Base {
128
157
  }
129
158
  }
130
159
 
131
- _autocompleteWith(option, { force }) {
132
- if (!this._autocompletesInline && !force) return
160
+ _replaceFullQueryWithAutocompletedValue(option) {
161
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
162
+
163
+ this._fullQuery = autocompletedValue;
164
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
165
+ }
133
166
 
167
+ _autocompleteMissingPortion(option) {
134
168
  const typedValue = this._typedQuery;
135
169
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
136
170
 
137
- if (force) {
138
- this._fullQuery = autocompletedValue;
139
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
140
- } else if (startsWith(autocompletedValue, typedValue)) {
171
+ if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
141
172
  this._fullQuery = autocompletedValue;
142
173
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
143
174
  }
@@ -226,7 +257,7 @@ Combobox.Dialog = Base => class extends Base {
226
257
  this.dialogFocusTrapTarget.focus();
227
258
  }
228
259
 
229
- get _smallViewport() {
260
+ get _isSmallViewport() {
230
261
  return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
231
262
  }
232
263
 
@@ -236,20 +267,35 @@ Combobox.Dialog = Base => class extends Base {
236
267
  };
237
268
 
238
269
  Combobox.Events = Base => class extends Base {
239
- _dispatchSelectionEvent({ isNew }) {
240
- dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } });
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
+ });
241
277
  }
242
278
 
243
- _dispatchClosedEvent() {
244
- dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
279
+ _dispatchSelectionEvent() {
280
+ dispatch("hw-combobox:selection", {
281
+ target: this.element,
282
+ detail: this._eventableDetails
283
+ });
284
+ }
285
+
286
+ _dispatchRemovalEvent({ removedDisplay, removedValue }) {
287
+ dispatch("hw-combobox:removal", {
288
+ target: this.element,
289
+ detail: { ...this._eventableDetails, removedDisplay, removedValue }
290
+ });
245
291
  }
246
292
 
247
293
  get _eventableDetails() {
248
294
  return {
249
- value: this.hiddenFieldTarget.value,
295
+ value: this._incomingFieldValueString,
250
296
  display: this._fullQuery,
251
297
  query: this._typedQuery,
252
- fieldName: this.hiddenFieldTarget.name,
298
+ fieldName: this._fieldName,
253
299
  isValid: this._valueIsValid
254
300
  }
255
301
  }
@@ -533,55 +579,58 @@ async function get(url, options) {
533
579
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
534
580
 
535
581
  Combobox.Filtering = Base => class extends Base {
536
- filter(event) {
537
- if (this._isAsync) {
538
- this._debouncedFilterAsync(event);
539
- } else {
540
- this._filterSync(event);
541
- }
582
+ filterAndSelect({ inputType }) {
583
+ this._filter(inputType);
542
584
 
543
- this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
585
+ if (this._isSync) {
586
+ this._selectOnQuery(inputType);
587
+ }
544
588
  }
545
589
 
546
590
  _initializeFiltering() {
547
591
  this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
548
592
  }
549
593
 
550
- _debouncedFilterAsync(event) {
551
- this._filterAsync(event);
594
+ _filter(inputType) {
595
+ if (this._isAsync) {
596
+ this._debouncedFilterAsync(inputType);
597
+ } else {
598
+ this._filterSync();
599
+ }
600
+
601
+ this._markQueried();
602
+ }
603
+
604
+ _debouncedFilterAsync(inputType) {
605
+ this._filterAsync(inputType);
552
606
  }
553
607
 
554
- async _filterAsync(event) {
608
+ async _filterAsync(inputType) {
555
609
  const query = {
556
610
  q: this._fullQuery,
557
- input_type: event.inputType,
611
+ input_type: inputType,
558
612
  for_id: this.element.dataset.asyncId
559
613
  };
560
614
 
561
615
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
562
616
  }
563
617
 
564
- _filterSync(event) {
565
- this.open();
566
- this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
567
- this._commitFilter(event);
568
- }
569
-
570
- _commitFilter(event) {
571
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
572
- this._selectNew();
573
- } else if (isDeleteEvent(event)) {
574
- this._deselect();
575
- } else if (event.inputType === "hw:lockInSelection") {
576
- this._select(this._ensurableOption);
577
- } else if (this._isOpen) {
578
- this._select(this._visibleOptionElements[0]);
579
- }
618
+ _filterSync() {
619
+ this._allFilterableOptionElements.forEach(
620
+ applyFilter(
621
+ this._fullQuery,
622
+ { matching: this.filterableAttributeValue }
623
+ )
624
+ );
580
625
  }
581
626
 
582
627
  _clearQuery() {
583
628
  this._fullQuery = "";
584
- this.filter({ inputType: "deleteContentBackward" });
629
+ this.filterAndSelect({ inputType: "deleteContentBackward" });
630
+ }
631
+
632
+ _markQueried() {
633
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
585
634
  }
586
635
 
587
636
  get _isQueried() {
@@ -601,20 +650,255 @@ Combobox.Filtering = Base => class extends Base {
601
650
  }
602
651
  };
603
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
+
604
883
  Combobox.Navigation = Base => class extends Base {
605
884
  navigate(event) {
606
885
  if (this._autocompletesList) {
607
- this._keyHandlers[event.key]?.call(this, event);
886
+ this._navigationKeyHandlers[event.key]?.call(this, event);
608
887
  }
609
888
  }
610
889
 
611
- _keyHandlers = {
890
+ _navigationKeyHandlers = {
612
891
  ArrowUp: (event) => {
613
892
  this._selectIndex(this._selectedOptionIndex - 1);
614
893
  cancel(event);
615
894
  },
616
895
  ArrowDown: (event) => {
617
896
  this._selectIndex(this._selectedOptionIndex + 1);
897
+
898
+ if (this._selectedOptionIndex === 0) {
899
+ this._actingListbox.scrollTop = 0;
900
+ }
901
+
618
902
  cancel(event);
619
903
  },
620
904
  Home: (event) => {
@@ -626,14 +910,18 @@ Combobox.Navigation = Base => class extends Base {
626
910
  cancel(event);
627
911
  },
628
912
  Enter: (event) => {
629
- this.close();
630
- this._actingCombobox.blur();
913
+ this._closeAndBlur("hw:keyHandler:enter");
631
914
  cancel(event);
632
915
  },
633
916
  Escape: (event) => {
634
- this.close();
635
- this._actingCombobox.blur();
917
+ this._closeAndBlur("hw:keyHandler:escape");
636
918
  cancel(event);
919
+ },
920
+ Backspace: (event) => {
921
+ if (this._isMultiselect && !this._fullQuery) {
922
+ this._focusLastChipDismisser();
923
+ cancel(event);
924
+ }
637
925
  }
638
926
  }
639
927
  };
@@ -675,9 +963,25 @@ Combobox.NewOptions = Base => class extends Base {
675
963
  };
676
964
 
677
965
  Combobox.Options = Base => class extends Base {
678
- _resetOptions() {
679
- this._deselect();
680
- this.hiddenFieldTarget.name = this.originalNameValue;
966
+ _resetOptionsSilently() {
967
+ this._resetOptions(this._deselect.bind(this));
968
+ }
969
+
970
+ _resetOptionsAndNotify() {
971
+ this._resetOptions(this._deselectAndNotify.bind(this));
972
+ }
973
+
974
+ _resetOptions(deselectionStrategy) {
975
+ this._fieldName = this.originalNameValue;
976
+ deselectionStrategy();
977
+ }
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)
681
985
  }
682
986
 
683
987
  get _allowNew() {
@@ -685,19 +989,23 @@ Combobox.Options = Base => class extends Base {
685
989
  }
686
990
 
687
991
  get _allOptions() {
688
- return Array.from(this._allOptionElements)
992
+ return Array.from(this._allFilterableOptionElements)
689
993
  }
690
994
 
691
- get _allOptionElements() {
692
- return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
995
+ get _allFilterableOptionElements() {
996
+ return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`)
693
997
  }
694
998
 
695
999
  get _visibleOptionElements() {
696
- return [ ...this._allOptionElements ].filter(visible)
1000
+ return [ ...this._allFilterableOptionElements ].filter(visible)
697
1001
  }
698
1002
 
699
1003
  get _selectedOptionElement() {
700
- 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]")
701
1009
  }
702
1010
 
703
1011
  get _selectedOptionIndex() {
@@ -705,7 +1013,7 @@ Combobox.Options = Base => class extends Base {
705
1013
  }
706
1014
 
707
1015
  get _isUnjustifiablyBlank() {
708
- const valueIsMissing = !this.hiddenFieldTarget.value;
1016
+ const valueIsMissing = this._hasEmptyFieldValue;
709
1017
  const noBlankOptionSelected = !this._selectedOptionElement;
710
1018
 
711
1019
  return valueIsMissing && noBlankOptionSelected
@@ -713,103 +1021,148 @@ Combobox.Options = Base => class extends Base {
713
1021
  };
714
1022
 
715
1023
  Combobox.Selection = Base => class extends Base {
716
- selectOptionOnClick(event) {
717
- this.filter(event);
718
- this._select(event.currentTarget, { forceAutocomplete: true });
719
- this.close();
1024
+ selectOnClick({ currentTarget, inputType }) {
1025
+ this._forceSelectionAndFilter(currentTarget, inputType);
1026
+ this._closeAndBlur("hw:optionRoleClick");
720
1027
  }
721
1028
 
722
1029
  _connectSelection() {
723
1030
  if (this.hasPrefilledDisplayValue) {
724
1031
  this._fullQuery = this.prefilledDisplayValue;
1032
+ this._markQueried();
725
1033
  }
726
1034
  }
727
1035
 
728
- _select(option, { forceAutocomplete = false } = {}) {
729
- this._resetOptions();
730
-
731
- if (option) {
732
- this._autocompleteWith(option, { force: forceAutocomplete });
733
- this._commitSelection(option, { selected: true });
734
- this._markValid();
735
- } else {
1036
+ _selectOnQuery(inputType) {
1037
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
1038
+ this._selectNew();
1039
+ } else if (isDeleteEvent({ inputType: inputType })) {
1040
+ this._deselect();
1041
+ } else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
1042
+ this._selectAndAutocompleteMissingPortion(this._ensurableOption);
1043
+ } else if (this._isOpen && this._visibleOptionElements[0]) {
1044
+ this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
1045
+ } else if (this._isOpen) {
1046
+ this._resetOptionsAndNotify();
736
1047
  this._markInvalid();
737
- }
1048
+ } else ;
738
1049
  }
739
1050
 
740
- _commitSelection(option, { selected }) {
741
- this._markSelected(option, { selected });
1051
+ _select(option, autocompleteStrategy) {
1052
+ const previousValue = this._fieldValueString;
742
1053
 
743
- if (selected) {
744
- this.hiddenFieldTarget.value = option.dataset.value;
745
- option.scrollIntoView({ block: "nearest" });
746
- }
1054
+ this._resetOptionsSilently();
1055
+
1056
+ autocompleteStrategy(option);
747
1057
 
748
- this._dispatchSelectionEvent({ isNew: false });
1058
+ this._fieldValue = option.dataset.value;
1059
+ this._markSelected(option);
1060
+ this._markValid();
1061
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
1062
+
1063
+ option.scrollIntoView({ block: "nearest" });
749
1064
  }
750
1065
 
751
- _markSelected(option, { selected }) {
752
- if (this.hasSelectedClass) {
753
- option.classList.toggle(this.selectedClass, selected);
754
- }
1066
+ _selectNew() {
1067
+ const previousValue = this._fieldValueString;
755
1068
 
756
- option.setAttribute("aria-selected", selected);
757
- this._setActiveDescendant(selected ? option.id : "");
1069
+ this._resetOptionsSilently();
1070
+ this._fieldValue = this._fullQuery;
1071
+ this._fieldName = this.nameWhenNewValue;
1072
+ this._markValid();
1073
+ this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
758
1074
  }
759
1075
 
760
1076
  _deselect() {
761
- const option = this._selectedOptionElement;
1077
+ const previousValue = this._fieldValueString;
762
1078
 
763
- if (option) this._commitSelection(option, { selected: false });
1079
+ if (this._selectedOptionElement) {
1080
+ this._markNotSelected(this._selectedOptionElement);
1081
+ }
764
1082
 
765
- this.hiddenFieldTarget.value = null;
1083
+ this._fieldValue = "";
766
1084
  this._setActiveDescendant("");
767
1085
 
768
- if (!option) this._dispatchSelectionEvent({ isNew: false });
1086
+ return previousValue
769
1087
  }
770
1088
 
771
- _selectNew() {
772
- this._resetOptions();
773
- this.hiddenFieldTarget.value = this._fullQuery;
774
- this.hiddenFieldTarget.name = this.nameWhenNewValue;
775
- this._markValid();
776
-
777
- this._dispatchSelectionEvent({ isNew: true });
1089
+ _deselectAndNotify() {
1090
+ const previousValue = this._deselect();
1091
+ this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
778
1092
  }
779
1093
 
780
1094
  _selectIndex(index) {
781
1095
  const option = wrapAroundAccess(this._visibleOptionElements, index);
782
- this._select(option, { forceAutocomplete: true });
1096
+ this._forceSelectionWithoutFiltering(option);
783
1097
  }
784
1098
 
785
- _preselectOption() {
786
- if (this._hasValueButNoSelection && this._allOptions.length < 100) {
787
- const option = this._allOptions.find(option => {
788
- return option.dataset.value === this.hiddenFieldTarget.value
789
- });
1099
+ _preselectSingle() {
1100
+ if (this._isSingleSelect && this._hasValueButNoSelection && this._allOptions.length < 100) {
1101
+ const option = this._optionElementWithValue(this._fieldValue);
1102
+ if (option) this._markSelected(option);
1103
+ }
1104
+ }
790
1105
 
791
- if (option) this._markSelected(option, { selected: true });
1106
+ _preselectMultiple() {
1107
+ if (this._isMultiselect && this._hasValueButNoSelection) {
1108
+ this._requestChips(this._fieldValueString);
1109
+ this._resetMultiselectionMarks();
792
1110
  }
793
1111
  }
794
1112
 
1113
+ _selectAndAutocompleteMissingPortion(option) {
1114
+ this._select(option, this._autocompleteMissingPortion.bind(this));
1115
+ }
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
+
795
1130
  _lockInSelection() {
796
1131
  if (this._shouldLockInSelection) {
797
- this._select(this._ensurableOption, { forceAutocomplete: true });
798
- this.filter({ inputType: "hw:lockInSelection" });
1132
+ this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection");
799
1133
  }
1134
+ }
800
1135
 
801
- if (this._isUnjustifiablyBlank) {
802
- this._deselect();
803
- this._clearQuery();
804
- }
1136
+ _markSelected(option) {
1137
+ if (this.hasSelectedClass) option.classList.add(this.selectedClass);
1138
+ option.setAttribute("aria-selected", true);
1139
+ this._setActiveDescendant(option.id);
1140
+ }
1141
+
1142
+ _markNotSelected(option) {
1143
+ if (this.hasSelectedClass) option.classList.remove(this.selectedClass);
1144
+ option.removeAttribute("aria-selected");
1145
+ this._removeActiveDescendant();
805
1146
  }
806
1147
 
807
1148
  _setActiveDescendant(id) {
808
1149
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
809
1150
  }
810
1151
 
1152
+ _removeActiveDescendant() {
1153
+ this._setActiveDescendant("");
1154
+ }
1155
+
811
1156
  get _hasValueButNoSelection() {
812
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
1157
+ return this._hasFieldValue && !this._hasSelection
1158
+ }
1159
+
1160
+ get _hasSelection() {
1161
+ if (this._isSingleSelect) {
1162
+ this._selectedOptionElement;
1163
+ } else {
1164
+ this._multiselectedOptionElements.length > 0;
1165
+ }
813
1166
  }
814
1167
 
815
1168
  get _shouldLockInSelection() {
@@ -1100,19 +1453,40 @@ Combobox.Toggle = Base => class extends Base {
1100
1453
  this.expandedValue = true;
1101
1454
  }
1102
1455
 
1103
- close() {
1456
+ openByFocusing() {
1457
+ this._actingCombobox.focus();
1458
+ }
1459
+
1460
+ close(inputType) {
1104
1461
  if (this._isOpen) {
1462
+ const shouldReopen = this._isMultiselect &&
1463
+ this._isSync &&
1464
+ !this._isSmallViewport &&
1465
+ inputType != "hw:clickOutside" &&
1466
+ inputType != "hw:focusOutside";
1467
+
1105
1468
  this._lockInSelection();
1469
+ this._clearInvalidQuery();
1470
+
1106
1471
  this.expandedValue = false;
1107
- this._dispatchClosedEvent();
1472
+
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
+ }
1108
1482
  }
1109
1483
  }
1110
1484
 
1111
1485
  toggle() {
1112
1486
  if (this.expandedValue) {
1113
- this.close();
1487
+ this._closeAndBlur("hw:toggle");
1114
1488
  } else {
1115
- this._openByFocusing();
1489
+ this.openByFocusing();
1116
1490
  }
1117
1491
  }
1118
1492
 
@@ -1123,14 +1497,14 @@ Combobox.Toggle = Base => class extends Base {
1123
1497
  if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
1124
1498
  if (this._withinElementBounds(event)) return
1125
1499
 
1126
- this.close();
1500
+ this._closeAndBlur("hw:clickOutside");
1127
1501
  }
1128
1502
 
1129
1503
  closeOnFocusOutside({ target }) {
1130
1504
  if (!this._isOpen) return
1131
1505
  if (this.element.contains(target)) return
1132
1506
 
1133
- this.close();
1507
+ this._closeAndBlur("hw:focusOutside");
1134
1508
  }
1135
1509
 
1136
1510
  clearOrToggleOnHandleClick() {
@@ -1142,6 +1516,11 @@ Combobox.Toggle = Base => class extends Base {
1142
1516
  }
1143
1517
  }
1144
1518
 
1519
+ _closeAndBlur(inputType) {
1520
+ this.close(inputType);
1521
+ this._actingCombobox.blur();
1522
+ }
1523
+
1145
1524
  // Some browser extensions like 1Password overlay elements on top of the combobox.
1146
1525
  // Hovering over these elements emits a click event for some reason.
1147
1526
  // These events don't contain any telling information, so we use `_withinElementBounds`
@@ -1153,18 +1532,16 @@ Combobox.Toggle = Base => class extends Base {
1153
1532
  return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
1154
1533
  }
1155
1534
 
1156
- _openByFocusing() {
1157
- this._actingCombobox.focus();
1158
- }
1159
-
1160
1535
  _isDialogDismisser(target) {
1161
1536
  return target.closest("dialog") && target.role != "combobox"
1162
1537
  }
1163
1538
 
1164
1539
  _expand() {
1165
- if (this._preselectOnExpansion) this._preselectOption();
1540
+ if (this._isSync) {
1541
+ this._preselectSingle();
1542
+ }
1166
1543
 
1167
- if (this._autocompletesList && this._smallViewport) {
1544
+ if (this._autocompletesList && this._isSmallViewport) {
1168
1545
  this._openInDialog();
1169
1546
  } else {
1170
1547
  this._openInline();
@@ -1173,6 +1550,8 @@ Combobox.Toggle = Base => class extends Base {
1173
1550
  this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
1174
1551
  }
1175
1552
 
1553
+ // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
1554
+ // it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
1176
1555
  _collapse() {
1177
1556
  this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1178
1557
 
@@ -1213,12 +1592,15 @@ Combobox.Toggle = Base => class extends Base {
1213
1592
  enableBodyScroll(this.dialogListboxTarget);
1214
1593
  }
1215
1594
 
1216
- get _isOpen() {
1217
- return this.expandedValue
1595
+ _clearInvalidQuery() {
1596
+ if (this._isUnjustifiablyBlank) {
1597
+ this._deselect();
1598
+ this._clearQuery();
1599
+ }
1218
1600
  }
1219
1601
 
1220
- get _preselectOnExpansion() {
1221
- return !this._isAsync // async comboboxes preselect based on callbacks
1602
+ get _isOpen() {
1603
+ return this.expandedValue
1222
1604
  }
1223
1605
  };
1224
1606
 
@@ -1256,7 +1638,7 @@ Combobox.Validity = Base => class extends Base {
1256
1638
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1257
1639
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
1258
1640
  get _valueIsInvalid() {
1259
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1641
+ const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue;
1260
1642
  return isRequiredAndEmpty
1261
1643
  }
1262
1644
  };
@@ -1266,11 +1648,14 @@ window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0; // ms, for testing purposes
1266
1648
  const concerns = [
1267
1649
  Controller,
1268
1650
  Combobox.Actors,
1651
+ Combobox.Announcements,
1269
1652
  Combobox.AsyncLoading,
1270
1653
  Combobox.Autocomplete,
1271
1654
  Combobox.Dialog,
1272
1655
  Combobox.Events,
1273
1656
  Combobox.Filtering,
1657
+ Combobox.FormField,
1658
+ Combobox.Multiselect,
1274
1659
  Combobox.Navigation,
1275
1660
  Combobox.NewOptions,
1276
1661
  Combobox.Options,
@@ -1286,7 +1671,10 @@ class HwComboboxController extends Concerns(...concerns) {
1286
1671
  ]
1287
1672
 
1288
1673
  static targets = [
1674
+ "announcer",
1289
1675
  "combobox",
1676
+ "chipDismisser",
1677
+ "closer",
1290
1678
  "dialog",
1291
1679
  "dialogCombobox",
1292
1680
  "dialogFocusTrap",
@@ -1307,12 +1695,14 @@ class HwComboboxController extends Concerns(...concerns) {
1307
1695
  nameWhenNew: String,
1308
1696
  originalName: String,
1309
1697
  prefilledDisplay: String,
1698
+ selectionChipSrc: String,
1310
1699
  smallViewportMaxWidth: String
1311
1700
  }
1312
1701
 
1313
1702
  initialize() {
1314
1703
  this._initializeActors();
1315
1704
  this._initializeFiltering();
1705
+ this._initializeMultiselect();
1316
1706
  }
1317
1707
 
1318
1708
  connect() {
@@ -1337,13 +1727,27 @@ class HwComboboxController extends Concerns(...concerns) {
1337
1727
  const inputType = element.dataset.inputType;
1338
1728
  const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
1339
1729
 
1340
- if (inputType && inputType !== "hw:lockInSelection") {
1730
+ this._resetMultiselectionMarks();
1731
+
1732
+ if (inputType === "hw:multiselectSync") {
1733
+ this.openByFocusing();
1734
+ } else if (inputType && inputType !== "hw:lockInSelection") {
1341
1735
  if (delay) await sleep(delay);
1342
- this._commitFilter({ inputType });
1736
+ this._selectOnQuery(inputType);
1343
1737
  } else {
1344
- this._preselectOption();
1738
+ this._preselectSingle();
1345
1739
  }
1346
1740
  }
1741
+
1742
+ closerTargetConnected() {
1743
+ this._closeAndBlur("hw:asyncCloser");
1744
+ }
1745
+
1746
+ // Use +_printStack+ for debugging purposes
1747
+ _printStack() {
1748
+ const err = new Error();
1749
+ console.log(err.stack || err.stacktrace);
1750
+ }
1347
1751
  }
1348
1752
 
1349
1753
  export { HwComboboxController as default };