hotwire_combobox 0.1.42 → 0.1.43

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fc3750ecb7bd58bd28e13d0cc2d543ae4c614535c927c9483e504b643171a8d
4
- data.tar.gz: dc6482de8a4344a6d801797d497f6adec8afcc0357e821e1dff8188ecf4c2f68
3
+ metadata.gz: 5d76e27ecc585f8ad3d4d596d2a6f8fe9916909734027d251cb35264604f192e
4
+ data.tar.gz: eda85b2dd3c21c407c806a5144c4d9b39c40a36c827c7d3b1986a5e27d432cf9
5
5
  SHA512:
6
- metadata.gz: 8884e9e8295b73d37d15762f08c1a69452a7117f99af09dba4369aedea9af2116bc9683127f6f29535813fa5182b21969eb35e4d077edee1f94ce1a6808aadfe
7
- data.tar.gz: b58dd5870a0508912c910b387f7970ffc4a08a16e40612b448e7c35ae244d0f8e02f9dd9356e5cdd8ae7f0c45c1bd65513ece94fcbeba38818c39d39c9ecd870
6
+ metadata.gz: 2cab9b83d4bf4caf3e741f5c12f336241e77f6eec0c97041f25162ecb4aade0d0c58b3ce59e5772b1e5edb89a2a6ad92051c8237f0d40bcee30e550e692add1b
7
+ data.tar.gz: 1d40dfa00ae3884e8c44693abfa542859ed27fb4fb124a90264c03a57ade1dcb31865f7d89078ea4304968f3adcaba51905d940c336a29ed860857fc5b6e5f40
@@ -80,7 +80,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
80
80
 
81
81
  if (inputType && inputType !== "hw:lockInSelection") {
82
82
  if (delay) await sleep(delay)
83
- this._commitFilter({ inputType })
83
+ this._selectBasedOnQuery({ inputType })
84
84
  } else {
85
85
  this._preselectOption()
86
86
  }
@@ -37,6 +37,10 @@ Combobox.AsyncLoading = Base => class extends Base {
37
37
  get _isAsync() {
38
38
  return this.hasAsyncSrcValue
39
39
  }
40
+
41
+ get _isSync() {
42
+ return !this._isAsync
43
+ }
40
44
  };
41
45
 
42
46
  function Concerns(Base, ...mixins) {
@@ -128,16 +132,18 @@ Combobox.Autocomplete = Base => class extends Base {
128
132
  }
129
133
  }
130
134
 
131
- _autocompleteWith(option, { force }) {
132
- if (!this._autocompletesInline && !force) return
135
+ _replaceFullQueryWithAutocompletedValue(option) {
136
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
137
+
138
+ this._fullQuery = autocompletedValue;
139
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
140
+ }
133
141
 
142
+ _autocompleteMissingPortion(option) {
134
143
  const typedValue = this._typedQuery;
135
144
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
136
145
 
137
- if (force) {
138
- this._fullQuery = autocompletedValue;
139
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
140
- } else if (startsWith(autocompletedValue, typedValue)) {
146
+ if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
141
147
  this._fullQuery = autocompletedValue;
142
148
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
143
149
  }
@@ -236,8 +242,13 @@ Combobox.Dialog = Base => class extends Base {
236
242
  };
237
243
 
238
244
  Combobox.Events = Base => class extends Base {
239
- _dispatchSelectionEvent({ isNew }) {
240
- dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } });
245
+ _dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
246
+ if (previousValue !== this._value) {
247
+ dispatch("hw-combobox:selection", {
248
+ target: this.element,
249
+ detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
250
+ });
251
+ }
241
252
  }
242
253
 
243
254
  _dispatchClosedEvent() {
@@ -246,7 +257,7 @@ Combobox.Events = Base => class extends Base {
246
257
 
247
258
  get _eventableDetails() {
248
259
  return {
249
- value: this.hiddenFieldTarget.value,
260
+ value: this._value,
250
261
  display: this._fullQuery,
251
262
  query: this._typedQuery,
252
263
  fieldName: this.hiddenFieldTarget.name,
@@ -533,20 +544,28 @@ async function get(url, options) {
533
544
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
534
545
 
535
546
  Combobox.Filtering = Base => class extends Base {
536
- filter(event) {
547
+ filterAndSelect(event) {
548
+ this._filter(event);
549
+
550
+ if (this._isSync) {
551
+ this._selectBasedOnQuery(event);
552
+ }
553
+ }
554
+
555
+ _initializeFiltering() {
556
+ this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
557
+ }
558
+
559
+ _filter(event) {
537
560
  if (this._isAsync) {
538
561
  this._debouncedFilterAsync(event);
539
562
  } else {
540
- this._filterSync(event);
563
+ this._filterSync();
541
564
  }
542
565
 
543
566
  this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
544
567
  }
545
568
 
546
- _initializeFiltering() {
547
- this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
548
- }
549
-
550
569
  _debouncedFilterAsync(event) {
551
570
  this._filterAsync(event);
552
571
  }
@@ -561,27 +580,14 @@ Combobox.Filtering = Base => class extends Base {
561
580
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
562
581
  }
563
582
 
564
- _filterSync(event) {
583
+ _filterSync() {
565
584
  this.open();
566
585
  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
- }
580
586
  }
581
587
 
582
588
  _clearQuery() {
583
589
  this._fullQuery = "";
584
- this.filter({ inputType: "deleteContentBackward" });
590
+ this.filterAndSelect({ inputType: "deleteContentBackward" });
585
591
  }
586
592
 
587
593
  get _isQueried() {
@@ -675,9 +681,17 @@ Combobox.NewOptions = Base => class extends Base {
675
681
  };
676
682
 
677
683
  Combobox.Options = Base => class extends Base {
678
- _resetOptions() {
679
- this._deselect();
680
- this.hiddenFieldTarget.name = this.originalNameValue;
684
+ _resetOptionsSilently() {
685
+ this._resetOptions(this._deselect.bind(this));
686
+ }
687
+
688
+ _resetOptionsAndNotify() {
689
+ this._resetOptions(this._deselectAndNotify.bind(this));
690
+ }
691
+
692
+ _resetOptions(deselectionStrategy) {
693
+ this._setName(this.originalNameValue);
694
+ deselectionStrategy();
681
695
  }
682
696
 
683
697
  get _allowNew() {
@@ -705,7 +719,7 @@ Combobox.Options = Base => class extends Base {
705
719
  }
706
720
 
707
721
  get _isUnjustifiablyBlank() {
708
- const valueIsMissing = !this.hiddenFieldTarget.value;
722
+ const valueIsMissing = !this._value;
709
723
  const noBlankOptionSelected = !this._selectedOptionElement;
710
724
 
711
725
  return valueIsMissing && noBlankOptionSelected
@@ -714,8 +728,7 @@ Combobox.Options = Base => class extends Base {
714
728
 
715
729
  Combobox.Selection = Base => class extends Base {
716
730
  selectOptionOnClick(event) {
717
- this.filter(event);
718
- this._select(event.currentTarget, { forceAutocomplete: true });
731
+ this._forceSelectionAndFilter(event.currentTarget, event);
719
732
  this.close();
720
733
  }
721
734
 
@@ -725,91 +738,136 @@ Combobox.Selection = Base => class extends Base {
725
738
  }
726
739
  }
727
740
 
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 {
741
+ _selectBasedOnQuery(event) {
742
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
743
+ this._selectNew();
744
+ } else if (isDeleteEvent(event)) {
745
+ this._deselect();
746
+ } else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
747
+ this._selectAndAutocompleteMissingPortion(this._ensurableOption);
748
+ } else if (this._isOpen && this._visibleOptionElements[0]) {
749
+ this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
750
+ } else if (this._isOpen) {
751
+ this._resetOptionsAndNotify();
736
752
  this._markInvalid();
737
- }
753
+ } else ;
738
754
  }
739
755
 
740
- _commitSelection(option, { selected }) {
741
- this._markSelected(option, { selected });
756
+ _select(option, autocompleteStrategy) {
757
+ const previousValue = this._value;
742
758
 
743
- if (selected) {
744
- this.hiddenFieldTarget.value = option.dataset.value;
745
- option.scrollIntoView({ block: "nearest" });
746
- }
759
+ this._resetOptionsSilently();
747
760
 
748
- this._dispatchSelectionEvent({ isNew: false });
761
+ autocompleteStrategy(option);
762
+
763
+ this._setValue(option.dataset.value);
764
+ this._markSelected(option);
765
+ this._markValid();
766
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
767
+
768
+ option.scrollIntoView({ block: "nearest" });
749
769
  }
750
770
 
751
- _markSelected(option, { selected }) {
752
- if (this.hasSelectedClass) {
753
- option.classList.toggle(this.selectedClass, selected);
754
- }
771
+ _selectNew() {
772
+ const previousValue = this._value;
755
773
 
756
- option.setAttribute("aria-selected", selected);
757
- this._setActiveDescendant(selected ? option.id : "");
774
+ this._resetOptionsSilently();
775
+ this._setValue(this._fullQuery);
776
+ this._setName(this.nameWhenNewValue);
777
+ this._markValid();
778
+ this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
758
779
  }
759
780
 
760
781
  _deselect() {
761
- const option = this._selectedOptionElement;
782
+ const previousValue = this._value;
762
783
 
763
- if (option) this._commitSelection(option, { selected: false });
784
+ if (this._selectedOptionElement) {
785
+ this._markNotSelected(this._selectedOptionElement);
786
+ }
764
787
 
765
- this.hiddenFieldTarget.value = null;
788
+ this._setValue(null);
766
789
  this._setActiveDescendant("");
767
790
 
768
- if (!option) this._dispatchSelectionEvent({ isNew: false });
791
+ return previousValue
769
792
  }
770
793
 
771
- _selectNew() {
772
- this._resetOptions();
773
- this.hiddenFieldTarget.value = this._fullQuery;
774
- this.hiddenFieldTarget.name = this.nameWhenNewValue;
775
- this._markValid();
794
+ _deselectAndNotify() {
795
+ const previousValue = this._deselect();
796
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
797
+ }
776
798
 
777
- this._dispatchSelectionEvent({ isNew: true });
799
+ _forceSelectionAndFilter(option, event) {
800
+ this._forceSelectionWithoutFiltering(option);
801
+ this._filter(event);
802
+ }
803
+
804
+ _forceSelectionWithoutFiltering(option) {
805
+ this._selectAndReplaceFullQuery(option);
778
806
  }
779
807
 
780
808
  _selectIndex(index) {
781
809
  const option = wrapAroundAccess(this._visibleOptionElements, index);
782
- this._select(option, { forceAutocomplete: true });
810
+ this._forceSelectionWithoutFiltering(option);
783
811
  }
784
812
 
785
813
  _preselectOption() {
786
814
  if (this._hasValueButNoSelection && this._allOptions.length < 100) {
787
815
  const option = this._allOptions.find(option => {
788
- return option.dataset.value === this.hiddenFieldTarget.value
816
+ return option.dataset.value === this._value
789
817
  });
790
818
 
791
- if (option) this._markSelected(option, { selected: true });
819
+ if (option) this._markSelected(option);
792
820
  }
793
821
  }
794
822
 
823
+ _selectAndReplaceFullQuery(option) {
824
+ this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
825
+ }
826
+
827
+ _selectAndAutocompleteMissingPortion(option) {
828
+ this._select(option, this._autocompleteMissingPortion.bind(this));
829
+ }
830
+
795
831
  _lockInSelection() {
796
832
  if (this._shouldLockInSelection) {
797
- this._select(this._ensurableOption, { forceAutocomplete: true });
798
- this.filter({ inputType: "hw:lockInSelection" });
833
+ this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" });
799
834
  }
835
+ }
800
836
 
801
- if (this._isUnjustifiablyBlank) {
802
- this._deselect();
803
- this._clearQuery();
804
- }
837
+ _markSelected(option) {
838
+ if (this.hasSelectedClass) option.classList.add(this.selectedClass);
839
+ option.setAttribute("aria-selected", true);
840
+ this._setActiveDescendant(option.id);
841
+ }
842
+
843
+ _markNotSelected(option) {
844
+ if (this.hasSelectedClass) option.classList.remove(this.selectedClass);
845
+ option.removeAttribute("aria-selected");
846
+ this._removeActiveDescendant();
805
847
  }
806
848
 
807
849
  _setActiveDescendant(id) {
808
850
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
809
851
  }
810
852
 
853
+ _removeActiveDescendant() {
854
+ this._setActiveDescendant("");
855
+ }
856
+
857
+ _setValue(value) {
858
+ this.hiddenFieldTarget.value = value;
859
+ }
860
+
861
+ _setName(value) {
862
+ this.hiddenFieldTarget.name = value;
863
+ }
864
+
865
+ get _value() {
866
+ return this.hiddenFieldTarget.value
867
+ }
868
+
811
869
  get _hasValueButNoSelection() {
812
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
870
+ return this._value && !this._selectedOptionElement
813
871
  }
814
872
 
815
873
  get _shouldLockInSelection() {
@@ -1103,7 +1161,10 @@ Combobox.Toggle = Base => class extends Base {
1103
1161
  close() {
1104
1162
  if (this._isOpen) {
1105
1163
  this._lockInSelection();
1164
+ this._clearInvalidQuery();
1165
+
1106
1166
  this.expandedValue = false;
1167
+
1107
1168
  this._dispatchClosedEvent();
1108
1169
  }
1109
1170
  }
@@ -1173,6 +1234,8 @@ Combobox.Toggle = Base => class extends Base {
1173
1234
  this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
1174
1235
  }
1175
1236
 
1237
+ // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
1238
+ // it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
1176
1239
  _collapse() {
1177
1240
  this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1178
1241
 
@@ -1213,6 +1276,13 @@ Combobox.Toggle = Base => class extends Base {
1213
1276
  enableBodyScroll(this.dialogListboxTarget);
1214
1277
  }
1215
1278
 
1279
+ _clearInvalidQuery() {
1280
+ if (this._isUnjustifiablyBlank) {
1281
+ this._deselect();
1282
+ this._clearQuery();
1283
+ }
1284
+ }
1285
+
1216
1286
  get _isOpen() {
1217
1287
  return this.expandedValue
1218
1288
  }
@@ -1256,7 +1326,7 @@ Combobox.Validity = Base => class extends Base {
1256
1326
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1257
1327
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
1258
1328
  get _valueIsInvalid() {
1259
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1329
+ const isRequiredAndEmpty = this.comboboxTarget.required && !this._value;
1260
1330
  return isRequiredAndEmpty
1261
1331
  }
1262
1332
  };
@@ -1339,7 +1409,7 @@ class HwComboboxController extends Concerns(...concerns) {
1339
1409
 
1340
1410
  if (inputType && inputType !== "hw:lockInSelection") {
1341
1411
  if (delay) await sleep(delay);
1342
- this._commitFilter({ inputType });
1412
+ this._selectBasedOnQuery({ inputType });
1343
1413
  } else {
1344
1414
  this._preselectOption();
1345
1415
  }
@@ -41,6 +41,10 @@
41
41
  get _isAsync() {
42
42
  return this.hasAsyncSrcValue
43
43
  }
44
+
45
+ get _isSync() {
46
+ return !this._isAsync
47
+ }
44
48
  };
45
49
 
46
50
  function Concerns(Base, ...mixins) {
@@ -132,16 +136,18 @@
132
136
  }
133
137
  }
134
138
 
135
- _autocompleteWith(option, { force }) {
136
- if (!this._autocompletesInline && !force) return
139
+ _replaceFullQueryWithAutocompletedValue(option) {
140
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
141
+
142
+ this._fullQuery = autocompletedValue;
143
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
144
+ }
137
145
 
146
+ _autocompleteMissingPortion(option) {
138
147
  const typedValue = this._typedQuery;
139
148
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
140
149
 
141
- if (force) {
142
- this._fullQuery = autocompletedValue;
143
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
144
- } else if (startsWith(autocompletedValue, typedValue)) {
150
+ if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
145
151
  this._fullQuery = autocompletedValue;
146
152
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
147
153
  }
@@ -240,8 +246,13 @@
240
246
  };
241
247
 
242
248
  Combobox.Events = Base => class extends Base {
243
- _dispatchSelectionEvent({ isNew }) {
244
- dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } });
249
+ _dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
250
+ if (previousValue !== this._value) {
251
+ dispatch("hw-combobox:selection", {
252
+ target: this.element,
253
+ detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
254
+ });
255
+ }
245
256
  }
246
257
 
247
258
  _dispatchClosedEvent() {
@@ -250,7 +261,7 @@
250
261
 
251
262
  get _eventableDetails() {
252
263
  return {
253
- value: this.hiddenFieldTarget.value,
264
+ value: this._value,
254
265
  display: this._fullQuery,
255
266
  query: this._typedQuery,
256
267
  fieldName: this.hiddenFieldTarget.name,
@@ -537,20 +548,28 @@
537
548
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
538
549
 
539
550
  Combobox.Filtering = Base => class extends Base {
540
- filter(event) {
551
+ filterAndSelect(event) {
552
+ this._filter(event);
553
+
554
+ if (this._isSync) {
555
+ this._selectBasedOnQuery(event);
556
+ }
557
+ }
558
+
559
+ _initializeFiltering() {
560
+ this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
561
+ }
562
+
563
+ _filter(event) {
541
564
  if (this._isAsync) {
542
565
  this._debouncedFilterAsync(event);
543
566
  } else {
544
- this._filterSync(event);
567
+ this._filterSync();
545
568
  }
546
569
 
547
570
  this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
548
571
  }
549
572
 
550
- _initializeFiltering() {
551
- this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
552
- }
553
-
554
573
  _debouncedFilterAsync(event) {
555
574
  this._filterAsync(event);
556
575
  }
@@ -565,27 +584,14 @@
565
584
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
566
585
  }
567
586
 
568
- _filterSync(event) {
587
+ _filterSync() {
569
588
  this.open();
570
589
  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
- }
584
590
  }
585
591
 
586
592
  _clearQuery() {
587
593
  this._fullQuery = "";
588
- this.filter({ inputType: "deleteContentBackward" });
594
+ this.filterAndSelect({ inputType: "deleteContentBackward" });
589
595
  }
590
596
 
591
597
  get _isQueried() {
@@ -679,9 +685,17 @@
679
685
  };
680
686
 
681
687
  Combobox.Options = Base => class extends Base {
682
- _resetOptions() {
683
- this._deselect();
684
- this.hiddenFieldTarget.name = this.originalNameValue;
688
+ _resetOptionsSilently() {
689
+ this._resetOptions(this._deselect.bind(this));
690
+ }
691
+
692
+ _resetOptionsAndNotify() {
693
+ this._resetOptions(this._deselectAndNotify.bind(this));
694
+ }
695
+
696
+ _resetOptions(deselectionStrategy) {
697
+ this._setName(this.originalNameValue);
698
+ deselectionStrategy();
685
699
  }
686
700
 
687
701
  get _allowNew() {
@@ -709,7 +723,7 @@
709
723
  }
710
724
 
711
725
  get _isUnjustifiablyBlank() {
712
- const valueIsMissing = !this.hiddenFieldTarget.value;
726
+ const valueIsMissing = !this._value;
713
727
  const noBlankOptionSelected = !this._selectedOptionElement;
714
728
 
715
729
  return valueIsMissing && noBlankOptionSelected
@@ -718,8 +732,7 @@
718
732
 
719
733
  Combobox.Selection = Base => class extends Base {
720
734
  selectOptionOnClick(event) {
721
- this.filter(event);
722
- this._select(event.currentTarget, { forceAutocomplete: true });
735
+ this._forceSelectionAndFilter(event.currentTarget, event);
723
736
  this.close();
724
737
  }
725
738
 
@@ -729,91 +742,136 @@
729
742
  }
730
743
  }
731
744
 
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 {
745
+ _selectBasedOnQuery(event) {
746
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
747
+ this._selectNew();
748
+ } else if (isDeleteEvent(event)) {
749
+ this._deselect();
750
+ } else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
751
+ this._selectAndAutocompleteMissingPortion(this._ensurableOption);
752
+ } else if (this._isOpen && this._visibleOptionElements[0]) {
753
+ this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
754
+ } else if (this._isOpen) {
755
+ this._resetOptionsAndNotify();
740
756
  this._markInvalid();
741
- }
757
+ } else ;
742
758
  }
743
759
 
744
- _commitSelection(option, { selected }) {
745
- this._markSelected(option, { selected });
760
+ _select(option, autocompleteStrategy) {
761
+ const previousValue = this._value;
746
762
 
747
- if (selected) {
748
- this.hiddenFieldTarget.value = option.dataset.value;
749
- option.scrollIntoView({ block: "nearest" });
750
- }
763
+ this._resetOptionsSilently();
751
764
 
752
- this._dispatchSelectionEvent({ isNew: false });
765
+ autocompleteStrategy(option);
766
+
767
+ this._setValue(option.dataset.value);
768
+ this._markSelected(option);
769
+ this._markValid();
770
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
771
+
772
+ option.scrollIntoView({ block: "nearest" });
753
773
  }
754
774
 
755
- _markSelected(option, { selected }) {
756
- if (this.hasSelectedClass) {
757
- option.classList.toggle(this.selectedClass, selected);
758
- }
775
+ _selectNew() {
776
+ const previousValue = this._value;
759
777
 
760
- option.setAttribute("aria-selected", selected);
761
- this._setActiveDescendant(selected ? option.id : "");
778
+ this._resetOptionsSilently();
779
+ this._setValue(this._fullQuery);
780
+ this._setName(this.nameWhenNewValue);
781
+ this._markValid();
782
+ this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
762
783
  }
763
784
 
764
785
  _deselect() {
765
- const option = this._selectedOptionElement;
786
+ const previousValue = this._value;
766
787
 
767
- if (option) this._commitSelection(option, { selected: false });
788
+ if (this._selectedOptionElement) {
789
+ this._markNotSelected(this._selectedOptionElement);
790
+ }
768
791
 
769
- this.hiddenFieldTarget.value = null;
792
+ this._setValue(null);
770
793
  this._setActiveDescendant("");
771
794
 
772
- if (!option) this._dispatchSelectionEvent({ isNew: false });
795
+ return previousValue
773
796
  }
774
797
 
775
- _selectNew() {
776
- this._resetOptions();
777
- this.hiddenFieldTarget.value = this._fullQuery;
778
- this.hiddenFieldTarget.name = this.nameWhenNewValue;
779
- this._markValid();
798
+ _deselectAndNotify() {
799
+ const previousValue = this._deselect();
800
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
801
+ }
780
802
 
781
- this._dispatchSelectionEvent({ isNew: true });
803
+ _forceSelectionAndFilter(option, event) {
804
+ this._forceSelectionWithoutFiltering(option);
805
+ this._filter(event);
806
+ }
807
+
808
+ _forceSelectionWithoutFiltering(option) {
809
+ this._selectAndReplaceFullQuery(option);
782
810
  }
783
811
 
784
812
  _selectIndex(index) {
785
813
  const option = wrapAroundAccess(this._visibleOptionElements, index);
786
- this._select(option, { forceAutocomplete: true });
814
+ this._forceSelectionWithoutFiltering(option);
787
815
  }
788
816
 
789
817
  _preselectOption() {
790
818
  if (this._hasValueButNoSelection && this._allOptions.length < 100) {
791
819
  const option = this._allOptions.find(option => {
792
- return option.dataset.value === this.hiddenFieldTarget.value
820
+ return option.dataset.value === this._value
793
821
  });
794
822
 
795
- if (option) this._markSelected(option, { selected: true });
823
+ if (option) this._markSelected(option);
796
824
  }
797
825
  }
798
826
 
827
+ _selectAndReplaceFullQuery(option) {
828
+ this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
829
+ }
830
+
831
+ _selectAndAutocompleteMissingPortion(option) {
832
+ this._select(option, this._autocompleteMissingPortion.bind(this));
833
+ }
834
+
799
835
  _lockInSelection() {
800
836
  if (this._shouldLockInSelection) {
801
- this._select(this._ensurableOption, { forceAutocomplete: true });
802
- this.filter({ inputType: "hw:lockInSelection" });
837
+ this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" });
803
838
  }
839
+ }
804
840
 
805
- if (this._isUnjustifiablyBlank) {
806
- this._deselect();
807
- this._clearQuery();
808
- }
841
+ _markSelected(option) {
842
+ if (this.hasSelectedClass) option.classList.add(this.selectedClass);
843
+ option.setAttribute("aria-selected", true);
844
+ this._setActiveDescendant(option.id);
845
+ }
846
+
847
+ _markNotSelected(option) {
848
+ if (this.hasSelectedClass) option.classList.remove(this.selectedClass);
849
+ option.removeAttribute("aria-selected");
850
+ this._removeActiveDescendant();
809
851
  }
810
852
 
811
853
  _setActiveDescendant(id) {
812
854
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
813
855
  }
814
856
 
857
+ _removeActiveDescendant() {
858
+ this._setActiveDescendant("");
859
+ }
860
+
861
+ _setValue(value) {
862
+ this.hiddenFieldTarget.value = value;
863
+ }
864
+
865
+ _setName(value) {
866
+ this.hiddenFieldTarget.name = value;
867
+ }
868
+
869
+ get _value() {
870
+ return this.hiddenFieldTarget.value
871
+ }
872
+
815
873
  get _hasValueButNoSelection() {
816
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
874
+ return this._value && !this._selectedOptionElement
817
875
  }
818
876
 
819
877
  get _shouldLockInSelection() {
@@ -1107,7 +1165,10 @@
1107
1165
  close() {
1108
1166
  if (this._isOpen) {
1109
1167
  this._lockInSelection();
1168
+ this._clearInvalidQuery();
1169
+
1110
1170
  this.expandedValue = false;
1171
+
1111
1172
  this._dispatchClosedEvent();
1112
1173
  }
1113
1174
  }
@@ -1177,6 +1238,8 @@
1177
1238
  this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
1178
1239
  }
1179
1240
 
1241
+ // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
1242
+ // it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
1180
1243
  _collapse() {
1181
1244
  this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1182
1245
 
@@ -1217,6 +1280,13 @@
1217
1280
  enableBodyScroll(this.dialogListboxTarget);
1218
1281
  }
1219
1282
 
1283
+ _clearInvalidQuery() {
1284
+ if (this._isUnjustifiablyBlank) {
1285
+ this._deselect();
1286
+ this._clearQuery();
1287
+ }
1288
+ }
1289
+
1220
1290
  get _isOpen() {
1221
1291
  return this.expandedValue
1222
1292
  }
@@ -1260,7 +1330,7 @@
1260
1330
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1261
1331
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
1262
1332
  get _valueIsInvalid() {
1263
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1333
+ const isRequiredAndEmpty = this.comboboxTarget.required && !this._value;
1264
1334
  return isRequiredAndEmpty
1265
1335
  }
1266
1336
  };
@@ -1343,7 +1413,7 @@
1343
1413
 
1344
1414
  if (inputType && inputType !== "hw:lockInSelection") {
1345
1415
  if (delay) await sleep(delay);
1346
- this._commitFilter({ inputType });
1416
+ this._selectBasedOnQuery({ inputType });
1347
1417
  } else {
1348
1418
  this._preselectOption();
1349
1419
  }
@@ -4,4 +4,8 @@ Combobox.AsyncLoading = Base => class extends Base {
4
4
  get _isAsync() {
5
5
  return this.hasAsyncSrcValue
6
6
  }
7
+
8
+ get _isSync() {
9
+ return !this._isAsync
10
+ }
7
11
  }
@@ -8,16 +8,18 @@ Combobox.Autocomplete = Base => class extends Base {
8
8
  }
9
9
  }
10
10
 
11
- _autocompleteWith(option, { force }) {
12
- if (!this._autocompletesInline && !force) return
11
+ _replaceFullQueryWithAutocompletedValue(option) {
12
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
13
+
14
+ this._fullQuery = autocompletedValue
15
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
16
+ }
13
17
 
18
+ _autocompleteMissingPortion(option) {
14
19
  const typedValue = this._typedQuery
15
20
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
16
21
 
17
- if (force) {
18
- this._fullQuery = autocompletedValue
19
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
20
- } else if (startsWith(autocompletedValue, typedValue)) {
22
+ if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
21
23
  this._fullQuery = autocompletedValue
22
24
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length)
23
25
  }
@@ -2,8 +2,13 @@ import Combobox from "hw_combobox/models/combobox/base"
2
2
  import { dispatch } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Events = Base => class extends Base {
5
- _dispatchSelectionEvent({ isNew }) {
6
- dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } })
5
+ _dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
6
+ if (previousValue !== this._value) {
7
+ dispatch("hw-combobox:selection", {
8
+ target: this.element,
9
+ detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
10
+ })
11
+ }
7
12
  }
8
13
 
9
14
  _dispatchClosedEvent() {
@@ -12,7 +17,7 @@ Combobox.Events = Base => class extends Base {
12
17
 
13
18
  get _eventableDetails() {
14
19
  return {
15
- value: this.hiddenFieldTarget.value,
20
+ value: this._value,
16
21
  display: this._fullQuery,
17
22
  query: this._typedQuery,
18
23
  fieldName: this.hiddenFieldTarget.name,
@@ -1,23 +1,33 @@
1
1
 
2
2
  import Combobox from "hw_combobox/models/combobox/base"
3
- import { applyFilter, debounce, isDeleteEvent, unselectedPortion } from "hw_combobox/helpers"
3
+ import { applyFilter, debounce, unselectedPortion } from "hw_combobox/helpers"
4
4
  import { get } from "hw_combobox/vendor/requestjs"
5
5
 
6
6
  Combobox.Filtering = Base => class extends Base {
7
- filter(event) {
8
- if (this._isAsync) {
9
- this._debouncedFilterAsync(event)
7
+ filterAndSelect(event) {
8
+ this._filter(event)
9
+
10
+ if (this._isSync) {
11
+ this._selectBasedOnQuery(event)
10
12
  } else {
11
- this._filterSync(event)
13
+ // noop, async selection is handled by stimulus callbacks
12
14
  }
13
-
14
- this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
15
15
  }
16
16
 
17
17
  _initializeFiltering() {
18
18
  this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this))
19
19
  }
20
20
 
21
+ _filter(event) {
22
+ if (this._isAsync) {
23
+ this._debouncedFilterAsync(event)
24
+ } else {
25
+ this._filterSync()
26
+ }
27
+
28
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
29
+ }
30
+
21
31
  _debouncedFilterAsync(event) {
22
32
  this._filterAsync(event)
23
33
  }
@@ -32,27 +42,14 @@ Combobox.Filtering = Base => class extends Base {
32
42
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query })
33
43
  }
34
44
 
35
- _filterSync(event) {
45
+ _filterSync() {
36
46
  this.open()
37
47
  this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }))
38
- this._commitFilter(event)
39
- }
40
-
41
- _commitFilter(event) {
42
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
43
- this._selectNew()
44
- } else if (isDeleteEvent(event)) {
45
- this._deselect()
46
- } else if (event.inputType === "hw:lockInSelection") {
47
- this._select(this._ensurableOption)
48
- } else if (this._isOpen) {
49
- this._select(this._visibleOptionElements[0])
50
- }
51
48
  }
52
49
 
53
50
  _clearQuery() {
54
51
  this._fullQuery = ""
55
- this.filter({ inputType: "deleteContentBackward" })
52
+ this.filterAndSelect({ inputType: "deleteContentBackward" })
56
53
  }
57
54
 
58
55
  get _isQueried() {
@@ -2,9 +2,17 @@ import Combobox from "hw_combobox/models/combobox/base"
2
2
  import { visible } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Options = Base => class extends Base {
5
- _resetOptions() {
6
- this._deselect()
7
- this.hiddenFieldTarget.name = this.originalNameValue
5
+ _resetOptionsSilently() {
6
+ this._resetOptions(this._deselect.bind(this))
7
+ }
8
+
9
+ _resetOptionsAndNotify() {
10
+ this._resetOptions(this._deselectAndNotify.bind(this))
11
+ }
12
+
13
+ _resetOptions(deselectionStrategy) {
14
+ this._setName(this.originalNameValue)
15
+ deselectionStrategy()
8
16
  }
9
17
 
10
18
  get _allowNew() {
@@ -32,7 +40,7 @@ Combobox.Options = Base => class extends Base {
32
40
  }
33
41
 
34
42
  get _isUnjustifiablyBlank() {
35
- const valueIsMissing = !this.hiddenFieldTarget.value
43
+ const valueIsMissing = !this._value
36
44
  const noBlankOptionSelected = !this._selectedOptionElement
37
45
 
38
46
  return valueIsMissing && noBlankOptionSelected
@@ -1,10 +1,9 @@
1
1
  import Combobox from "hw_combobox/models/combobox/base"
2
- import { wrapAroundAccess } from "hw_combobox/helpers"
2
+ import { wrapAroundAccess, isDeleteEvent } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Selection = Base => class extends Base {
5
5
  selectOptionOnClick(event) {
6
- this.filter(event)
7
- this._select(event.currentTarget, { forceAutocomplete: true })
6
+ this._forceSelectionAndFilter(event.currentTarget, event)
8
7
  this.close()
9
8
  }
10
9
 
@@ -14,91 +13,143 @@ Combobox.Selection = Base => class extends Base {
14
13
  }
15
14
  }
16
15
 
17
- _select(option, { forceAutocomplete = false } = {}) {
18
- this._resetOptions()
19
-
20
- if (option) {
21
- this._autocompleteWith(option, { force: forceAutocomplete })
22
- this._commitSelection(option, { selected: true })
23
- this._markValid()
24
- } else {
16
+ _selectBasedOnQuery(event) {
17
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
18
+ this._selectNew()
19
+ } else if (isDeleteEvent(event)) {
20
+ this._deselect()
21
+ } else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
22
+ this._selectAndAutocompleteMissingPortion(this._ensurableOption)
23
+ } else if (this._isOpen && this._visibleOptionElements[0]) {
24
+ this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0])
25
+ } else if (this._isOpen) {
26
+ this._resetOptionsAndNotify()
25
27
  this._markInvalid()
28
+ } else {
29
+ // When selecting from an async dialog listbox: selection is forced, the listbox is filtered,
30
+ // and the dialog is closed. Filtering ends with an `endOfOptionsStream` target connected
31
+ // to the now invisible combobox, which is now closed because Turbo waits for "nextRepaint"
32
+ // before rendering turbo streams. This ultimately calls +_selectBasedOnQuery+. We do want
33
+ // to call +_selectBasedOnQuery+ in this case to account for e.g. selection of
34
+ // new options. But we will noop here if it's none of the cases checked above.
26
35
  }
27
36
  }
28
37
 
29
- _commitSelection(option, { selected }) {
30
- this._markSelected(option, { selected })
38
+ _select(option, autocompleteStrategy) {
39
+ const previousValue = this._value
31
40
 
32
- if (selected) {
33
- this.hiddenFieldTarget.value = option.dataset.value
34
- option.scrollIntoView({ block: "nearest" })
35
- }
41
+ this._resetOptionsSilently()
36
42
 
37
- this._dispatchSelectionEvent({ isNew: false })
43
+ autocompleteStrategy(option)
44
+
45
+ this._setValue(option.dataset.value)
46
+ this._markSelected(option)
47
+ this._markValid()
48
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
49
+
50
+ option.scrollIntoView({ block: "nearest" })
38
51
  }
39
52
 
40
- _markSelected(option, { selected }) {
41
- if (this.hasSelectedClass) {
42
- option.classList.toggle(this.selectedClass, selected)
43
- }
53
+ _selectNew() {
54
+ const previousValue = this._value
44
55
 
45
- option.setAttribute("aria-selected", selected)
46
- this._setActiveDescendant(selected ? option.id : "")
56
+ this._resetOptionsSilently()
57
+ this._setValue(this._fullQuery)
58
+ this._setName(this.nameWhenNewValue)
59
+ this._markValid()
60
+ this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue })
47
61
  }
48
62
 
49
63
  _deselect() {
50
- const option = this._selectedOptionElement
64
+ const previousValue = this._value
51
65
 
52
- if (option) this._commitSelection(option, { selected: false })
66
+ if (this._selectedOptionElement) {
67
+ this._markNotSelected(this._selectedOptionElement)
68
+ }
53
69
 
54
- this.hiddenFieldTarget.value = null
70
+ this._setValue(null)
55
71
  this._setActiveDescendant("")
56
72
 
57
- if (!option) this._dispatchSelectionEvent({ isNew: false })
73
+ return previousValue
58
74
  }
59
75
 
60
- _selectNew() {
61
- this._resetOptions()
62
- this.hiddenFieldTarget.value = this._fullQuery
63
- this.hiddenFieldTarget.name = this.nameWhenNewValue
64
- this._markValid()
76
+ _deselectAndNotify() {
77
+ const previousValue = this._deselect()
78
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
79
+ }
80
+
81
+ _forceSelectionAndFilter(option, event) {
82
+ this._forceSelectionWithoutFiltering(option)
83
+ this._filter(event)
84
+ }
65
85
 
66
- this._dispatchSelectionEvent({ isNew: true })
86
+ _forceSelectionWithoutFiltering(option) {
87
+ this._selectAndReplaceFullQuery(option)
67
88
  }
68
89
 
69
90
  _selectIndex(index) {
70
91
  const option = wrapAroundAccess(this._visibleOptionElements, index)
71
- this._select(option, { forceAutocomplete: true })
92
+ this._forceSelectionWithoutFiltering(option)
72
93
  }
73
94
 
74
95
  _preselectOption() {
75
96
  if (this._hasValueButNoSelection && this._allOptions.length < 100) {
76
97
  const option = this._allOptions.find(option => {
77
- return option.dataset.value === this.hiddenFieldTarget.value
98
+ return option.dataset.value === this._value
78
99
  })
79
100
 
80
- if (option) this._markSelected(option, { selected: true })
101
+ if (option) this._markSelected(option)
81
102
  }
82
103
  }
83
104
 
105
+ _selectAndReplaceFullQuery(option) {
106
+ this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this))
107
+ }
108
+
109
+ _selectAndAutocompleteMissingPortion(option) {
110
+ this._select(option, this._autocompleteMissingPortion.bind(this))
111
+ }
112
+
84
113
  _lockInSelection() {
85
114
  if (this._shouldLockInSelection) {
86
- this._select(this._ensurableOption, { forceAutocomplete: true })
87
- this.filter({ inputType: "hw:lockInSelection" })
115
+ this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" })
88
116
  }
117
+ }
89
118
 
90
- if (this._isUnjustifiablyBlank) {
91
- this._deselect()
92
- this._clearQuery()
93
- }
119
+ _markSelected(option) {
120
+ if (this.hasSelectedClass) option.classList.add(this.selectedClass)
121
+ option.setAttribute("aria-selected", true)
122
+ this._setActiveDescendant(option.id)
123
+ }
124
+
125
+ _markNotSelected(option) {
126
+ if (this.hasSelectedClass) option.classList.remove(this.selectedClass)
127
+ option.removeAttribute("aria-selected")
128
+ this._removeActiveDescendant()
94
129
  }
95
130
 
96
131
  _setActiveDescendant(id) {
97
132
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id))
98
133
  }
99
134
 
135
+ _removeActiveDescendant() {
136
+ this._setActiveDescendant("")
137
+ }
138
+
139
+ _setValue(value) {
140
+ this.hiddenFieldTarget.value = value
141
+ }
142
+
143
+ _setName(value) {
144
+ this.hiddenFieldTarget.name = value
145
+ }
146
+
147
+ get _value() {
148
+ return this.hiddenFieldTarget.value
149
+ }
150
+
100
151
  get _hasValueButNoSelection() {
101
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
152
+ return this._value && !this._selectedOptionElement
102
153
  }
103
154
 
104
155
  get _shouldLockInSelection() {
@@ -9,7 +9,10 @@ Combobox.Toggle = Base => class extends Base {
9
9
  close() {
10
10
  if (this._isOpen) {
11
11
  this._lockInSelection()
12
+ this._clearInvalidQuery()
13
+
12
14
  this.expandedValue = false
15
+
13
16
  this._dispatchClosedEvent()
14
17
  }
15
18
  }
@@ -79,6 +82,8 @@ Combobox.Toggle = Base => class extends Base {
79
82
  this._actingCombobox.setAttribute("aria-expanded", true) // needs to happen after setting acting combobox
80
83
  }
81
84
 
85
+ // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
86
+ // it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
82
87
  _collapse() {
83
88
  this._actingCombobox.setAttribute("aria-expanded", false) // needs to happen before resetting acting combobox
84
89
 
@@ -119,6 +124,13 @@ Combobox.Toggle = Base => class extends Base {
119
124
  enableBodyScroll(this.dialogListboxTarget)
120
125
  }
121
126
 
127
+ _clearInvalidQuery() {
128
+ if (this._isUnjustifiablyBlank) {
129
+ this._deselect()
130
+ this._clearQuery()
131
+ }
132
+ }
133
+
122
134
  get _isOpen() {
123
135
  return this.expandedValue
124
136
  }
@@ -34,7 +34,7 @@ Combobox.Validity = Base => class extends Base {
34
34
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
35
35
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
36
36
  get _valueIsInvalid() {
37
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value
37
+ const isRequiredAndEmpty = this.comboboxTarget.required && !this._value
38
38
  return isRequiredAndEmpty
39
39
  }
40
40
  }
@@ -266,7 +266,7 @@ class HotwireCombobox::Component
266
266
  combobox_attrs.fetch(:data, {}).merge \
267
267
  action: "
268
268
  focus->hw-combobox#open
269
- input->hw-combobox#filter
269
+ input->hw-combobox#filterAndSelect
270
270
  keydown->hw-combobox#navigate
271
271
  click@window->hw-combobox#closeOnClickOutside
272
272
  focusin@window->hw-combobox#closeOnFocusOutside
@@ -316,7 +316,7 @@ class HotwireCombobox::Component
316
316
  def dialog_input_data
317
317
  {
318
318
  action: "
319
- input->hw-combobox#filter
319
+ input->hw-combobox#filterAndSelect
320
320
  keydown->hw-combobox#navigate
321
321
  click@window->hw-combobox#closeOnClickOutside".squish,
322
322
  hw_combobox_target: "dialogCombobox"
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.42"
2
+ VERSION = "0.1.43"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_combobox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.42
4
+ version: 0.1.43
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Farias
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-14 00:00:00.000000000 Z
11
+ date: 2024-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails