hotwire_combobox 0.1.42 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +25 -3
- data/app/assets/javascripts/hotwire_combobox.esm.js +531 -127
- data/app/assets/javascripts/hotwire_combobox.umd.js +531 -127
- data/app/assets/javascripts/hw_combobox/helpers.js +16 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/announcements.js +7 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/async_loading.js +4 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +8 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +33 -28
- data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +74 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +160 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +15 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/options.js +29 -9
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +103 -51
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +45 -16
- data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
- data/app/assets/javascripts/hw_combobox/models/combobox.js +3 -0
- data/app/assets/stylesheets/hotwire_combobox.css +84 -18
- data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
- data/app/presenters/hotwire_combobox/component.rb +95 -28
- data/app/presenters/hotwire_combobox/listbox/group.rb +45 -0
- data/app/presenters/hotwire_combobox/listbox/item.rb +104 -0
- data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
- data/app/views/hotwire_combobox/_component.html.erb +1 -0
- data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
- data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
- data/lib/hotwire_combobox/helper.rb +111 -86
- data/lib/hotwire_combobox/version.rb +1 -1
- 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
|
-
|
136
|
-
|
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 (
|
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
|
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
|
-
|
244
|
-
|
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
|
-
|
248
|
-
dispatch("hw-combobox:
|
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.
|
299
|
+
value: this._incomingFieldValueString,
|
254
300
|
display: this._fullQuery,
|
255
301
|
query: this._typedQuery,
|
256
|
-
fieldName: this.
|
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
|
-
|
541
|
-
|
542
|
-
this._debouncedFilterAsync(event);
|
543
|
-
} else {
|
544
|
-
this._filterSync(event);
|
545
|
-
}
|
586
|
+
filterAndSelect({ inputType }) {
|
587
|
+
this._filter(inputType);
|
546
588
|
|
547
|
-
|
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
|
-
|
555
|
-
this.
|
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(
|
612
|
+
async _filterAsync(inputType) {
|
559
613
|
const query = {
|
560
614
|
q: this._fullQuery,
|
561
|
-
input_type:
|
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(
|
569
|
-
this.
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
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.
|
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.
|
890
|
+
this._navigationKeyHandlers[event.key]?.call(this, event);
|
612
891
|
}
|
613
892
|
}
|
614
893
|
|
615
|
-
|
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.
|
634
|
-
this._actingCombobox.blur();
|
917
|
+
this._closeAndBlur("hw:keyHandler:enter");
|
635
918
|
cancel(event);
|
636
919
|
},
|
637
920
|
Escape: (event) => {
|
638
|
-
this.
|
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
|
-
|
683
|
-
this._deselect();
|
684
|
-
|
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.
|
996
|
+
return Array.from(this._allFilterableOptionElements)
|
693
997
|
}
|
694
998
|
|
695
|
-
get
|
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.
|
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 =
|
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
|
-
|
721
|
-
this.
|
722
|
-
this.
|
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
|
-
|
733
|
-
this.
|
734
|
-
|
735
|
-
if (
|
736
|
-
this.
|
737
|
-
|
738
|
-
this.
|
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
|
-
|
745
|
-
|
1055
|
+
_select(option, autocompleteStrategy) {
|
1056
|
+
const previousValue = this._fieldValueString;
|
746
1057
|
|
747
|
-
|
748
|
-
|
749
|
-
|
750
|
-
}
|
1058
|
+
this._resetOptionsSilently();
|
1059
|
+
|
1060
|
+
autocompleteStrategy(option);
|
751
1061
|
|
752
|
-
this.
|
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
|
-
|
756
|
-
|
757
|
-
option.classList.toggle(this.selectedClass, selected);
|
758
|
-
}
|
1070
|
+
_selectNew() {
|
1071
|
+
const previousValue = this._fieldValueString;
|
759
1072
|
|
760
|
-
|
761
|
-
this.
|
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
|
1081
|
+
const previousValue = this._fieldValueString;
|
766
1082
|
|
767
|
-
if (
|
1083
|
+
if (this._selectedOptionElement) {
|
1084
|
+
this._markNotSelected(this._selectedOptionElement);
|
1085
|
+
}
|
768
1086
|
|
769
|
-
this.
|
1087
|
+
this._fieldValue = "";
|
770
1088
|
this._setActiveDescendant("");
|
771
1089
|
|
772
|
-
|
1090
|
+
return previousValue
|
773
1091
|
}
|
774
1092
|
|
775
|
-
|
776
|
-
this.
|
777
|
-
this.
|
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.
|
1100
|
+
this._forceSelectionWithoutFiltering(option);
|
787
1101
|
}
|
788
1102
|
|
789
|
-
|
790
|
-
if (this._hasValueButNoSelection && this._allOptions.length < 100) {
|
791
|
-
const option = this.
|
792
|
-
|
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
|
-
|
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.
|
802
|
-
this.filter({ inputType: "hw:lockInSelection" });
|
1136
|
+
this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection");
|
803
1137
|
}
|
1138
|
+
}
|
804
1139
|
|
805
|
-
|
806
|
-
|
807
|
-
|
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.
|
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
|
-
|
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
|
-
|
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.
|
1491
|
+
this._closeAndBlur("hw:toggle");
|
1118
1492
|
} else {
|
1119
|
-
this.
|
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.
|
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.
|
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.
|
1544
|
+
if (this._isSync) {
|
1545
|
+
this._preselectSingle();
|
1546
|
+
}
|
1170
1547
|
|
1171
|
-
if (this._autocompletesList && this.
|
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
|
-
|
1221
|
-
|
1599
|
+
_clearInvalidQuery() {
|
1600
|
+
if (this._isUnjustifiablyBlank) {
|
1601
|
+
this._deselect();
|
1602
|
+
this._clearQuery();
|
1603
|
+
}
|
1222
1604
|
}
|
1223
1605
|
|
1224
|
-
get
|
1225
|
-
return
|
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 &&
|
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
|
-
|
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.
|
1740
|
+
this._selectOnQuery(inputType);
|
1347
1741
|
} else {
|
1348
|
-
this.
|
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;
|