hotwire_combobox 0.1.41 → 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.
@@ -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,17 +246,27 @@
240
246
  };
241
247
 
242
248
  Combobox.Events = Base => class extends Base {
243
- _dispatchSelectionEvent({ isNew }) {
244
- const detail = {
245
- value: this.hiddenFieldTarget.value,
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
+ }
256
+ }
257
+
258
+ _dispatchClosedEvent() {
259
+ dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
260
+ }
261
+
262
+ get _eventableDetails() {
263
+ return {
264
+ value: this._value,
246
265
  display: this._fullQuery,
247
266
  query: this._typedQuery,
248
267
  fieldName: this.hiddenFieldTarget.name,
249
- isValid: this._valueIsValid,
250
- isNew: isNew
251
- };
252
-
253
- dispatch("hw-combobox:selection", { target: this.element, detail });
268
+ isValid: this._valueIsValid
269
+ }
254
270
  }
255
271
  };
256
272
 
@@ -532,20 +548,28 @@
532
548
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
533
549
 
534
550
  Combobox.Filtering = Base => class extends Base {
535
- 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) {
536
564
  if (this._isAsync) {
537
565
  this._debouncedFilterAsync(event);
538
566
  } else {
539
- this._filterSync(event);
567
+ this._filterSync();
540
568
  }
541
569
 
542
570
  this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
543
571
  }
544
572
 
545
- _initializeFiltering() {
546
- this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
547
- }
548
-
549
573
  _debouncedFilterAsync(event) {
550
574
  this._filterAsync(event);
551
575
  }
@@ -560,27 +584,14 @@
560
584
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
561
585
  }
562
586
 
563
- _filterSync(event) {
587
+ _filterSync() {
564
588
  this.open();
565
589
  this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
566
- this._commitFilter(event);
567
- }
568
-
569
- _commitFilter(event) {
570
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
571
- this._selectNew();
572
- } else if (isDeleteEvent(event)) {
573
- this._deselect();
574
- } else if (event.inputType === "hw:lockInSelection") {
575
- this._select(this._ensurableOption);
576
- } else if (this._isOpen) {
577
- this._select(this._visibleOptionElements[0]);
578
- }
579
590
  }
580
591
 
581
592
  _clearQuery() {
582
593
  this._fullQuery = "";
583
- this.filter({ inputType: "deleteContentBackward" });
594
+ this.filterAndSelect({ inputType: "deleteContentBackward" });
584
595
  }
585
596
 
586
597
  get _isQueried() {
@@ -674,9 +685,17 @@
674
685
  };
675
686
 
676
687
  Combobox.Options = Base => class extends Base {
677
- _resetOptions() {
678
- this._deselect();
679
- 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();
680
699
  }
681
700
 
682
701
  get _allowNew() {
@@ -704,7 +723,7 @@
704
723
  }
705
724
 
706
725
  get _isUnjustifiablyBlank() {
707
- const valueIsMissing = !this.hiddenFieldTarget.value;
726
+ const valueIsMissing = !this._value;
708
727
  const noBlankOptionSelected = !this._selectedOptionElement;
709
728
 
710
729
  return valueIsMissing && noBlankOptionSelected
@@ -713,8 +732,7 @@
713
732
 
714
733
  Combobox.Selection = Base => class extends Base {
715
734
  selectOptionOnClick(event) {
716
- this.filter(event);
717
- this._select(event.currentTarget, { forceAutocomplete: true });
735
+ this._forceSelectionAndFilter(event.currentTarget, event);
718
736
  this.close();
719
737
  }
720
738
 
@@ -724,91 +742,136 @@
724
742
  }
725
743
  }
726
744
 
727
- _select(option, { forceAutocomplete = false } = {}) {
728
- this._resetOptions();
729
-
730
- if (option) {
731
- this._autocompleteWith(option, { force: forceAutocomplete });
732
- this._commitSelection(option, { selected: true });
733
- this._markValid();
734
- } 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();
735
756
  this._markInvalid();
736
- }
757
+ } else ;
737
758
  }
738
759
 
739
- _commitSelection(option, { selected }) {
740
- this._markSelected(option, { selected });
760
+ _select(option, autocompleteStrategy) {
761
+ const previousValue = this._value;
741
762
 
742
- if (selected) {
743
- this.hiddenFieldTarget.value = option.dataset.value;
744
- option.scrollIntoView({ block: "nearest" });
745
- }
763
+ this._resetOptionsSilently();
764
+
765
+ autocompleteStrategy(option);
746
766
 
747
- this._dispatchSelectionEvent({ isNew: false });
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" });
748
773
  }
749
774
 
750
- _markSelected(option, { selected }) {
751
- if (this.hasSelectedClass) {
752
- option.classList.toggle(this.selectedClass, selected);
753
- }
775
+ _selectNew() {
776
+ const previousValue = this._value;
754
777
 
755
- option.setAttribute("aria-selected", selected);
756
- 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 });
757
783
  }
758
784
 
759
785
  _deselect() {
760
- const option = this._selectedOptionElement;
786
+ const previousValue = this._value;
761
787
 
762
- if (option) this._commitSelection(option, { selected: false });
788
+ if (this._selectedOptionElement) {
789
+ this._markNotSelected(this._selectedOptionElement);
790
+ }
763
791
 
764
- this.hiddenFieldTarget.value = null;
792
+ this._setValue(null);
765
793
  this._setActiveDescendant("");
766
794
 
767
- if (!option) this._dispatchSelectionEvent({ isNew: false });
795
+ return previousValue
768
796
  }
769
797
 
770
- _selectNew() {
771
- this._resetOptions();
772
- this.hiddenFieldTarget.value = this._fullQuery;
773
- this.hiddenFieldTarget.name = this.nameWhenNewValue;
774
- this._markValid();
798
+ _deselectAndNotify() {
799
+ const previousValue = this._deselect();
800
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
801
+ }
775
802
 
776
- 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);
777
810
  }
778
811
 
779
812
  _selectIndex(index) {
780
813
  const option = wrapAroundAccess(this._visibleOptionElements, index);
781
- this._select(option, { forceAutocomplete: true });
814
+ this._forceSelectionWithoutFiltering(option);
782
815
  }
783
816
 
784
817
  _preselectOption() {
785
818
  if (this._hasValueButNoSelection && this._allOptions.length < 100) {
786
819
  const option = this._allOptions.find(option => {
787
- return option.dataset.value === this.hiddenFieldTarget.value
820
+ return option.dataset.value === this._value
788
821
  });
789
822
 
790
- if (option) this._markSelected(option, { selected: true });
823
+ if (option) this._markSelected(option);
791
824
  }
792
825
  }
793
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
+
794
835
  _lockInSelection() {
795
836
  if (this._shouldLockInSelection) {
796
- this._select(this._ensurableOption, { forceAutocomplete: true });
797
- this.filter({ inputType: "hw:lockInSelection" });
837
+ this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" });
798
838
  }
839
+ }
799
840
 
800
- if (this._isUnjustifiablyBlank) {
801
- this._deselect();
802
- this._clearQuery();
803
- }
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();
804
851
  }
805
852
 
806
853
  _setActiveDescendant(id) {
807
854
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
808
855
  }
809
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
+
810
873
  get _hasValueButNoSelection() {
811
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
874
+ return this._value && !this._selectedOptionElement
812
875
  }
813
876
 
814
877
  get _shouldLockInSelection() {
@@ -1102,7 +1165,11 @@
1102
1165
  close() {
1103
1166
  if (this._isOpen) {
1104
1167
  this._lockInSelection();
1168
+ this._clearInvalidQuery();
1169
+
1105
1170
  this.expandedValue = false;
1171
+
1172
+ this._dispatchClosedEvent();
1106
1173
  }
1107
1174
  }
1108
1175
 
@@ -1171,6 +1238,8 @@
1171
1238
  this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
1172
1239
  }
1173
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.
1174
1243
  _collapse() {
1175
1244
  this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1176
1245
 
@@ -1211,6 +1280,13 @@
1211
1280
  enableBodyScroll(this.dialogListboxTarget);
1212
1281
  }
1213
1282
 
1283
+ _clearInvalidQuery() {
1284
+ if (this._isUnjustifiablyBlank) {
1285
+ this._deselect();
1286
+ this._clearQuery();
1287
+ }
1288
+ }
1289
+
1214
1290
  get _isOpen() {
1215
1291
  return this.expandedValue
1216
1292
  }
@@ -1254,7 +1330,7 @@
1254
1330
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1255
1331
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
1256
1332
  get _valueIsInvalid() {
1257
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1333
+ const isRequiredAndEmpty = this.comboboxTarget.required && !this._value;
1258
1334
  return isRequiredAndEmpty
1259
1335
  }
1260
1336
  };
@@ -1337,7 +1413,7 @@
1337
1413
 
1338
1414
  if (inputType && inputType !== "hw:lockInSelection") {
1339
1415
  if (delay) await sleep(delay);
1340
- this._commitFilter({ inputType });
1416
+ this._selectBasedOnQuery({ inputType });
1341
1417
  } else {
1342
1418
  this._preselectOption();
1343
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,16 +2,26 @@ 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
- const detail = {
7
- value: this.hiddenFieldTarget.value,
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
+ }
12
+ }
13
+
14
+ _dispatchClosedEvent() {
15
+ dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails })
16
+ }
17
+
18
+ get _eventableDetails() {
19
+ return {
20
+ value: this._value,
8
21
  display: this._fullQuery,
9
22
  query: this._typedQuery,
10
23
  fieldName: this.hiddenFieldTarget.name,
11
- isValid: this._valueIsValid,
12
- isNew: isNew
24
+ isValid: this._valueIsValid
13
25
  }
14
-
15
- dispatch("hw-combobox:selection", { target: this.element, detail })
16
26
  }
17
27
  }
@@ -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