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