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