hotwire_combobox 0.1.41 → 0.1.43

Sign up to get free protection for your applications and to get access to all the features.
@@ -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