hotwire_combobox 0.1.42 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
import { Controller } from '@hotwired/stimulus';
|
2
5
|
|
3
6
|
const Combobox = {};
|
@@ -33,10 +36,20 @@ Combobox.Actors = Base => class extends Base {
|
|
33
36
|
}
|
34
37
|
};
|
35
38
|
|
39
|
+
Combobox.Announcements = Base => class extends Base {
|
40
|
+
_announceToScreenReader(display, action) {
|
41
|
+
this.announcerTarget.innerText = `${display} ${action}`;
|
42
|
+
}
|
43
|
+
};
|
44
|
+
|
36
45
|
Combobox.AsyncLoading = Base => class extends Base {
|
37
46
|
get _isAsync() {
|
38
47
|
return this.hasAsyncSrcValue
|
39
48
|
}
|
49
|
+
|
50
|
+
get _isSync() {
|
51
|
+
return !this._isAsync
|
52
|
+
}
|
40
53
|
};
|
41
54
|
|
42
55
|
function Concerns(Base, ...mixins) {
|
@@ -121,6 +134,22 @@ function dispatch(eventName, { target, cancelable, detail } = {}) {
|
|
121
134
|
return event
|
122
135
|
}
|
123
136
|
|
137
|
+
function nextRepaint() {
|
138
|
+
if (document.visibilityState === "hidden") {
|
139
|
+
return nextEventLoopTick()
|
140
|
+
} else {
|
141
|
+
return nextAnimationFrame()
|
142
|
+
}
|
143
|
+
}
|
144
|
+
|
145
|
+
function nextAnimationFrame() {
|
146
|
+
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
|
147
|
+
}
|
148
|
+
|
149
|
+
function nextEventLoopTick() {
|
150
|
+
return new Promise((resolve) => setTimeout(() => resolve(), 0))
|
151
|
+
}
|
152
|
+
|
124
153
|
Combobox.Autocomplete = Base => class extends Base {
|
125
154
|
_connectListAutocomplete() {
|
126
155
|
if (!this._autocompletesList) {
|
@@ -128,16 +157,18 @@ Combobox.Autocomplete = Base => class extends Base {
|
|
128
157
|
}
|
129
158
|
}
|
130
159
|
|
131
|
-
|
132
|
-
|
160
|
+
_replaceFullQueryWithAutocompletedValue(option) {
|
161
|
+
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
|
162
|
+
|
163
|
+
this._fullQuery = autocompletedValue;
|
164
|
+
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
|
165
|
+
}
|
133
166
|
|
167
|
+
_autocompleteMissingPortion(option) {
|
134
168
|
const typedValue = this._typedQuery;
|
135
169
|
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
|
136
170
|
|
137
|
-
if (
|
138
|
-
this._fullQuery = autocompletedValue;
|
139
|
-
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
|
140
|
-
} else if (startsWith(autocompletedValue, typedValue)) {
|
171
|
+
if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
|
141
172
|
this._fullQuery = autocompletedValue;
|
142
173
|
this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
|
143
174
|
}
|
@@ -226,7 +257,7 @@ Combobox.Dialog = Base => class extends Base {
|
|
226
257
|
this.dialogFocusTrapTarget.focus();
|
227
258
|
}
|
228
259
|
|
229
|
-
get
|
260
|
+
get _isSmallViewport() {
|
230
261
|
return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
|
231
262
|
}
|
232
263
|
|
@@ -236,20 +267,35 @@ Combobox.Dialog = Base => class extends Base {
|
|
236
267
|
};
|
237
268
|
|
238
269
|
Combobox.Events = Base => class extends Base {
|
239
|
-
|
240
|
-
|
270
|
+
_dispatchPreselectionEvent({ isNewAndAllowed, previousValue }) {
|
271
|
+
if (previousValue === this._incomingFieldValueString) return
|
272
|
+
|
273
|
+
dispatch("hw-combobox:preselection", {
|
274
|
+
target: this.element,
|
275
|
+
detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
|
276
|
+
});
|
241
277
|
}
|
242
278
|
|
243
|
-
|
244
|
-
dispatch("hw-combobox:
|
279
|
+
_dispatchSelectionEvent() {
|
280
|
+
dispatch("hw-combobox:selection", {
|
281
|
+
target: this.element,
|
282
|
+
detail: this._eventableDetails
|
283
|
+
});
|
284
|
+
}
|
285
|
+
|
286
|
+
_dispatchRemovalEvent({ removedDisplay, removedValue }) {
|
287
|
+
dispatch("hw-combobox:removal", {
|
288
|
+
target: this.element,
|
289
|
+
detail: { ...this._eventableDetails, removedDisplay, removedValue }
|
290
|
+
});
|
245
291
|
}
|
246
292
|
|
247
293
|
get _eventableDetails() {
|
248
294
|
return {
|
249
|
-
value: this.
|
295
|
+
value: this._incomingFieldValueString,
|
250
296
|
display: this._fullQuery,
|
251
297
|
query: this._typedQuery,
|
252
|
-
fieldName: this.
|
298
|
+
fieldName: this._fieldName,
|
253
299
|
isValid: this._valueIsValid
|
254
300
|
}
|
255
301
|
}
|
@@ -533,55 +579,58 @@ async function get(url, options) {
|
|
533
579
|
// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
534
580
|
|
535
581
|
Combobox.Filtering = Base => class extends Base {
|
536
|
-
|
537
|
-
|
538
|
-
this._debouncedFilterAsync(event);
|
539
|
-
} else {
|
540
|
-
this._filterSync(event);
|
541
|
-
}
|
582
|
+
filterAndSelect({ inputType }) {
|
583
|
+
this._filter(inputType);
|
542
584
|
|
543
|
-
|
585
|
+
if (this._isSync) {
|
586
|
+
this._selectOnQuery(inputType);
|
587
|
+
}
|
544
588
|
}
|
545
589
|
|
546
590
|
_initializeFiltering() {
|
547
591
|
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
|
548
592
|
}
|
549
593
|
|
550
|
-
|
551
|
-
this.
|
594
|
+
_filter(inputType) {
|
595
|
+
if (this._isAsync) {
|
596
|
+
this._debouncedFilterAsync(inputType);
|
597
|
+
} else {
|
598
|
+
this._filterSync();
|
599
|
+
}
|
600
|
+
|
601
|
+
this._markQueried();
|
602
|
+
}
|
603
|
+
|
604
|
+
_debouncedFilterAsync(inputType) {
|
605
|
+
this._filterAsync(inputType);
|
552
606
|
}
|
553
607
|
|
554
|
-
async _filterAsync(
|
608
|
+
async _filterAsync(inputType) {
|
555
609
|
const query = {
|
556
610
|
q: this._fullQuery,
|
557
|
-
input_type:
|
611
|
+
input_type: inputType,
|
558
612
|
for_id: this.element.dataset.asyncId
|
559
613
|
};
|
560
614
|
|
561
615
|
await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
|
562
616
|
}
|
563
617
|
|
564
|
-
_filterSync(
|
565
|
-
this.
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
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
|
-
}
|
618
|
+
_filterSync() {
|
619
|
+
this._allFilterableOptionElements.forEach(
|
620
|
+
applyFilter(
|
621
|
+
this._fullQuery,
|
622
|
+
{ matching: this.filterableAttributeValue }
|
623
|
+
)
|
624
|
+
);
|
580
625
|
}
|
581
626
|
|
582
627
|
_clearQuery() {
|
583
628
|
this._fullQuery = "";
|
584
|
-
this.
|
629
|
+
this.filterAndSelect({ inputType: "deleteContentBackward" });
|
630
|
+
}
|
631
|
+
|
632
|
+
_markQueried() {
|
633
|
+
this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
|
585
634
|
}
|
586
635
|
|
587
636
|
get _isQueried() {
|
@@ -601,20 +650,255 @@ Combobox.Filtering = Base => class extends Base {
|
|
601
650
|
}
|
602
651
|
};
|
603
652
|
|
653
|
+
Combobox.FormField = Base => class extends Base {
|
654
|
+
get _fieldValue() {
|
655
|
+
if (this._isMultiselect) {
|
656
|
+
const currentValue = this.hiddenFieldTarget.value;
|
657
|
+
const arrayFromValue = currentValue ? currentValue.split(",") : [];
|
658
|
+
|
659
|
+
return new Set(arrayFromValue)
|
660
|
+
} else {
|
661
|
+
return this.hiddenFieldTarget.value
|
662
|
+
}
|
663
|
+
}
|
664
|
+
|
665
|
+
get _fieldValueString() {
|
666
|
+
if (this._isMultiselect) {
|
667
|
+
return this._fieldValueArray.join(",")
|
668
|
+
} else {
|
669
|
+
return this.hiddenFieldTarget.value
|
670
|
+
}
|
671
|
+
}
|
672
|
+
|
673
|
+
get _incomingFieldValueString() {
|
674
|
+
if (this._isMultiselect) {
|
675
|
+
const array = this._fieldValueArray;
|
676
|
+
|
677
|
+
if (this.hiddenFieldTarget.dataset.valueForMultiselect) {
|
678
|
+
array.push(this.hiddenFieldTarget.dataset.valueForMultiselect);
|
679
|
+
}
|
680
|
+
|
681
|
+
return array.join(",")
|
682
|
+
} else {
|
683
|
+
return this.hiddenFieldTarget.value
|
684
|
+
}
|
685
|
+
}
|
686
|
+
|
687
|
+
get _fieldValueArray() {
|
688
|
+
if (this._isMultiselect) {
|
689
|
+
return Array.from(this._fieldValue)
|
690
|
+
} else {
|
691
|
+
return [ this.hiddenFieldTarget.value ]
|
692
|
+
}
|
693
|
+
}
|
694
|
+
|
695
|
+
set _fieldValue(value) {
|
696
|
+
if (this._isMultiselect) {
|
697
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect = value?.replace(/,/g, "");
|
698
|
+
this.hiddenFieldTarget.dataset.displayForMultiselect = this._fullQuery;
|
699
|
+
} else {
|
700
|
+
this.hiddenFieldTarget.value = value;
|
701
|
+
}
|
702
|
+
}
|
703
|
+
|
704
|
+
get _hasEmptyFieldValue() {
|
705
|
+
if (this._isMultiselect) {
|
706
|
+
return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
|
707
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
|
708
|
+
} else {
|
709
|
+
return this.hiddenFieldTarget.value === ""
|
710
|
+
}
|
711
|
+
}
|
712
|
+
|
713
|
+
get _hasFieldValue() {
|
714
|
+
return !this._hasEmptyFieldValue
|
715
|
+
}
|
716
|
+
|
717
|
+
get _fieldName() {
|
718
|
+
return this.hiddenFieldTarget.name
|
719
|
+
}
|
720
|
+
|
721
|
+
set _fieldName(value) {
|
722
|
+
this.hiddenFieldTarget.name = value;
|
723
|
+
}
|
724
|
+
};
|
725
|
+
|
726
|
+
Combobox.Multiselect = Base => class extends Base {
|
727
|
+
navigateChip(event) {
|
728
|
+
this._chipKeyHandlers[event.key]?.call(this, event);
|
729
|
+
}
|
730
|
+
|
731
|
+
removeChip({ currentTarget, params }) {
|
732
|
+
let display;
|
733
|
+
const option = this._optionElementWithValue(params.value);
|
734
|
+
|
735
|
+
if (option) {
|
736
|
+
display = option.getAttribute(this.autocompletableAttributeValue);
|
737
|
+
this._markNotSelected(option);
|
738
|
+
this._markNotMultiselected(option);
|
739
|
+
} else {
|
740
|
+
display = params.value; // for new options
|
741
|
+
}
|
742
|
+
|
743
|
+
this._removeFromFieldValue(params.value);
|
744
|
+
this._filter("hw:multiselectSync");
|
745
|
+
|
746
|
+
currentTarget.closest("[data-hw-combobox-chip]").remove();
|
747
|
+
|
748
|
+
if (!this._isSmallViewport) {
|
749
|
+
this.openByFocusing();
|
750
|
+
}
|
751
|
+
|
752
|
+
this._announceToScreenReader(display, "removed");
|
753
|
+
this._dispatchRemovalEvent({ removedDisplay: display, removedValue: params.value });
|
754
|
+
}
|
755
|
+
|
756
|
+
hideChipsForCache() {
|
757
|
+
this.element.querySelectorAll("[data-hw-combobox-chip]").forEach(chip => chip.hidden = true);
|
758
|
+
}
|
759
|
+
|
760
|
+
_chipKeyHandlers = {
|
761
|
+
Backspace: (event) => {
|
762
|
+
this.removeChip(event);
|
763
|
+
cancel(event);
|
764
|
+
},
|
765
|
+
Enter: (event) => {
|
766
|
+
this.removeChip(event);
|
767
|
+
cancel(event);
|
768
|
+
},
|
769
|
+
Space: (event) => {
|
770
|
+
this.removeChip(event);
|
771
|
+
cancel(event);
|
772
|
+
},
|
773
|
+
Escape: (event) => {
|
774
|
+
this.openByFocusing();
|
775
|
+
cancel(event);
|
776
|
+
}
|
777
|
+
}
|
778
|
+
|
779
|
+
_initializeMultiselect() {
|
780
|
+
if (!this._isMultiPreselected) {
|
781
|
+
this._preselectMultiple();
|
782
|
+
this._markMultiPreselected();
|
783
|
+
}
|
784
|
+
}
|
785
|
+
|
786
|
+
async _createChip(shouldReopen) {
|
787
|
+
if (!this._isMultiselect) return
|
788
|
+
|
789
|
+
this._beforeClearingMultiselectQuery(async (display, value) => {
|
790
|
+
this._fullQuery = "";
|
791
|
+
this._filter("hw:multiselectSync");
|
792
|
+
this._requestChips(value);
|
793
|
+
this._addToFieldValue(value);
|
794
|
+
if (shouldReopen) {
|
795
|
+
await nextRepaint();
|
796
|
+
this.openByFocusing();
|
797
|
+
}
|
798
|
+
this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.");
|
799
|
+
});
|
800
|
+
}
|
801
|
+
|
802
|
+
async _requestChips(values) {
|
803
|
+
await get(this.selectionChipSrcValue, {
|
804
|
+
responseKind: "turbo-stream",
|
805
|
+
query: {
|
806
|
+
for_id: this.element.dataset.asyncId,
|
807
|
+
combobox_values: values
|
808
|
+
}
|
809
|
+
});
|
810
|
+
}
|
811
|
+
|
812
|
+
_beforeClearingMultiselectQuery(callback) {
|
813
|
+
const display = this.hiddenFieldTarget.dataset.displayForMultiselect;
|
814
|
+
const value = this.hiddenFieldTarget.dataset.valueForMultiselect;
|
815
|
+
|
816
|
+
if (value && !this._fieldValue.has(value)) {
|
817
|
+
callback(display, value);
|
818
|
+
}
|
819
|
+
|
820
|
+
this.hiddenFieldTarget.dataset.displayForMultiselect = "";
|
821
|
+
this.hiddenFieldTarget.dataset.valueForMultiselect = "";
|
822
|
+
}
|
823
|
+
|
824
|
+
_resetMultiselectionMarks() {
|
825
|
+
if (!this._isMultiselect) return
|
826
|
+
|
827
|
+
this._fieldValueArray.forEach(value => {
|
828
|
+
const option = this._optionElementWithValue(value);
|
829
|
+
|
830
|
+
if (option) {
|
831
|
+
option.setAttribute("data-multiselected", "");
|
832
|
+
option.hidden = true;
|
833
|
+
}
|
834
|
+
});
|
835
|
+
}
|
836
|
+
|
837
|
+
_markNotMultiselected(option) {
|
838
|
+
if (!this._isMultiselect) return
|
839
|
+
|
840
|
+
option.removeAttribute("data-multiselected");
|
841
|
+
option.hidden = false;
|
842
|
+
}
|
843
|
+
|
844
|
+
_addToFieldValue(value) {
|
845
|
+
const newValue = this._fieldValue;
|
846
|
+
|
847
|
+
newValue.add(String(value));
|
848
|
+
this.hiddenFieldTarget.value = Array.from(newValue).join(",");
|
849
|
+
|
850
|
+
if (this._isSync) this._resetMultiselectionMarks();
|
851
|
+
}
|
852
|
+
|
853
|
+
_removeFromFieldValue(value) {
|
854
|
+
const newValue = this._fieldValue;
|
855
|
+
|
856
|
+
newValue.delete(String(value));
|
857
|
+
this.hiddenFieldTarget.value = Array.from(newValue).join(",");
|
858
|
+
|
859
|
+
if (this._isSync) this._resetMultiselectionMarks();
|
860
|
+
}
|
861
|
+
|
862
|
+
_focusLastChipDismisser() {
|
863
|
+
this.chipDismisserTargets[this.chipDismisserTargets.length - 1]?.focus();
|
864
|
+
}
|
865
|
+
|
866
|
+
_markMultiPreselected() {
|
867
|
+
this.element.dataset.multiPreselected = "";
|
868
|
+
}
|
869
|
+
|
870
|
+
get _isMultiselect() {
|
871
|
+
return this.hasSelectionChipSrcValue
|
872
|
+
}
|
873
|
+
|
874
|
+
get _isSingleSelect() {
|
875
|
+
return !this._isMultiselect
|
876
|
+
}
|
877
|
+
|
878
|
+
get _isMultiPreselected() {
|
879
|
+
return this.element.hasAttribute("data-multi-preselected")
|
880
|
+
}
|
881
|
+
};
|
882
|
+
|
604
883
|
Combobox.Navigation = Base => class extends Base {
|
605
884
|
navigate(event) {
|
606
885
|
if (this._autocompletesList) {
|
607
|
-
this.
|
886
|
+
this._navigationKeyHandlers[event.key]?.call(this, event);
|
608
887
|
}
|
609
888
|
}
|
610
889
|
|
611
|
-
|
890
|
+
_navigationKeyHandlers = {
|
612
891
|
ArrowUp: (event) => {
|
613
892
|
this._selectIndex(this._selectedOptionIndex - 1);
|
614
893
|
cancel(event);
|
615
894
|
},
|
616
895
|
ArrowDown: (event) => {
|
617
896
|
this._selectIndex(this._selectedOptionIndex + 1);
|
897
|
+
|
898
|
+
if (this._selectedOptionIndex === 0) {
|
899
|
+
this._actingListbox.scrollTop = 0;
|
900
|
+
}
|
901
|
+
|
618
902
|
cancel(event);
|
619
903
|
},
|
620
904
|
Home: (event) => {
|
@@ -626,14 +910,18 @@ Combobox.Navigation = Base => class extends Base {
|
|
626
910
|
cancel(event);
|
627
911
|
},
|
628
912
|
Enter: (event) => {
|
629
|
-
this.
|
630
|
-
this._actingCombobox.blur();
|
913
|
+
this._closeAndBlur("hw:keyHandler:enter");
|
631
914
|
cancel(event);
|
632
915
|
},
|
633
916
|
Escape: (event) => {
|
634
|
-
this.
|
635
|
-
this._actingCombobox.blur();
|
917
|
+
this._closeAndBlur("hw:keyHandler:escape");
|
636
918
|
cancel(event);
|
919
|
+
},
|
920
|
+
Backspace: (event) => {
|
921
|
+
if (this._isMultiselect && !this._fullQuery) {
|
922
|
+
this._focusLastChipDismisser();
|
923
|
+
cancel(event);
|
924
|
+
}
|
637
925
|
}
|
638
926
|
}
|
639
927
|
};
|
@@ -675,9 +963,25 @@ Combobox.NewOptions = Base => class extends Base {
|
|
675
963
|
};
|
676
964
|
|
677
965
|
Combobox.Options = Base => class extends Base {
|
678
|
-
|
679
|
-
this._deselect();
|
680
|
-
|
966
|
+
_resetOptionsSilently() {
|
967
|
+
this._resetOptions(this._deselect.bind(this));
|
968
|
+
}
|
969
|
+
|
970
|
+
_resetOptionsAndNotify() {
|
971
|
+
this._resetOptions(this._deselectAndNotify.bind(this));
|
972
|
+
}
|
973
|
+
|
974
|
+
_resetOptions(deselectionStrategy) {
|
975
|
+
this._fieldName = this.originalNameValue;
|
976
|
+
deselectionStrategy();
|
977
|
+
}
|
978
|
+
|
979
|
+
_optionElementWithValue(value) {
|
980
|
+
return this._actingListbox.querySelector(`[${this.filterableAttributeValue}][data-value='${value}']`)
|
981
|
+
}
|
982
|
+
|
983
|
+
_displayForOptionElement(element) {
|
984
|
+
return element.getAttribute(this.autocompletableAttributeValue)
|
681
985
|
}
|
682
986
|
|
683
987
|
get _allowNew() {
|
@@ -685,19 +989,23 @@ Combobox.Options = Base => class extends Base {
|
|
685
989
|
}
|
686
990
|
|
687
991
|
get _allOptions() {
|
688
|
-
return Array.from(this.
|
992
|
+
return Array.from(this._allFilterableOptionElements)
|
689
993
|
}
|
690
994
|
|
691
|
-
get
|
692
|
-
return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
|
995
|
+
get _allFilterableOptionElements() {
|
996
|
+
return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`)
|
693
997
|
}
|
694
998
|
|
695
999
|
get _visibleOptionElements() {
|
696
|
-
return [ ...this.
|
1000
|
+
return [ ...this._allFilterableOptionElements ].filter(visible)
|
697
1001
|
}
|
698
1002
|
|
699
1003
|
get _selectedOptionElement() {
|
700
|
-
return this._actingListbox.querySelector("[role=option][aria-selected=true]")
|
1004
|
+
return this._actingListbox.querySelector("[role=option][aria-selected=true]:not([data-multiselected])")
|
1005
|
+
}
|
1006
|
+
|
1007
|
+
get _multiselectedOptionElements() {
|
1008
|
+
return this._actingListbox.querySelectorAll("[role=option][data-multiselected]")
|
701
1009
|
}
|
702
1010
|
|
703
1011
|
get _selectedOptionIndex() {
|
@@ -705,7 +1013,7 @@ Combobox.Options = Base => class extends Base {
|
|
705
1013
|
}
|
706
1014
|
|
707
1015
|
get _isUnjustifiablyBlank() {
|
708
|
-
const valueIsMissing =
|
1016
|
+
const valueIsMissing = this._hasEmptyFieldValue;
|
709
1017
|
const noBlankOptionSelected = !this._selectedOptionElement;
|
710
1018
|
|
711
1019
|
return valueIsMissing && noBlankOptionSelected
|
@@ -713,103 +1021,148 @@ Combobox.Options = Base => class extends Base {
|
|
713
1021
|
};
|
714
1022
|
|
715
1023
|
Combobox.Selection = Base => class extends Base {
|
716
|
-
|
717
|
-
this.
|
718
|
-
this.
|
719
|
-
this.close();
|
1024
|
+
selectOnClick({ currentTarget, inputType }) {
|
1025
|
+
this._forceSelectionAndFilter(currentTarget, inputType);
|
1026
|
+
this._closeAndBlur("hw:optionRoleClick");
|
720
1027
|
}
|
721
1028
|
|
722
1029
|
_connectSelection() {
|
723
1030
|
if (this.hasPrefilledDisplayValue) {
|
724
1031
|
this._fullQuery = this.prefilledDisplayValue;
|
1032
|
+
this._markQueried();
|
725
1033
|
}
|
726
1034
|
}
|
727
1035
|
|
728
|
-
|
729
|
-
this.
|
730
|
-
|
731
|
-
if (
|
732
|
-
this.
|
733
|
-
|
734
|
-
this.
|
735
|
-
} else {
|
1036
|
+
_selectOnQuery(inputType) {
|
1037
|
+
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent({ inputType: inputType }))) {
|
1038
|
+
this._selectNew();
|
1039
|
+
} else if (isDeleteEvent({ inputType: inputType })) {
|
1040
|
+
this._deselect();
|
1041
|
+
} else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
|
1042
|
+
this._selectAndAutocompleteMissingPortion(this._ensurableOption);
|
1043
|
+
} else if (this._isOpen && this._visibleOptionElements[0]) {
|
1044
|
+
this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
|
1045
|
+
} else if (this._isOpen) {
|
1046
|
+
this._resetOptionsAndNotify();
|
736
1047
|
this._markInvalid();
|
737
|
-
}
|
1048
|
+
} else ;
|
738
1049
|
}
|
739
1050
|
|
740
|
-
|
741
|
-
|
1051
|
+
_select(option, autocompleteStrategy) {
|
1052
|
+
const previousValue = this._fieldValueString;
|
742
1053
|
|
743
|
-
|
744
|
-
|
745
|
-
|
746
|
-
}
|
1054
|
+
this._resetOptionsSilently();
|
1055
|
+
|
1056
|
+
autocompleteStrategy(option);
|
747
1057
|
|
748
|
-
this.
|
1058
|
+
this._fieldValue = option.dataset.value;
|
1059
|
+
this._markSelected(option);
|
1060
|
+
this._markValid();
|
1061
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
|
1062
|
+
|
1063
|
+
option.scrollIntoView({ block: "nearest" });
|
749
1064
|
}
|
750
1065
|
|
751
|
-
|
752
|
-
|
753
|
-
option.classList.toggle(this.selectedClass, selected);
|
754
|
-
}
|
1066
|
+
_selectNew() {
|
1067
|
+
const previousValue = this._fieldValueString;
|
755
1068
|
|
756
|
-
|
757
|
-
this.
|
1069
|
+
this._resetOptionsSilently();
|
1070
|
+
this._fieldValue = this._fullQuery;
|
1071
|
+
this._fieldName = this.nameWhenNewValue;
|
1072
|
+
this._markValid();
|
1073
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
|
758
1074
|
}
|
759
1075
|
|
760
1076
|
_deselect() {
|
761
|
-
const
|
1077
|
+
const previousValue = this._fieldValueString;
|
762
1078
|
|
763
|
-
if (
|
1079
|
+
if (this._selectedOptionElement) {
|
1080
|
+
this._markNotSelected(this._selectedOptionElement);
|
1081
|
+
}
|
764
1082
|
|
765
|
-
this.
|
1083
|
+
this._fieldValue = "";
|
766
1084
|
this._setActiveDescendant("");
|
767
1085
|
|
768
|
-
|
1086
|
+
return previousValue
|
769
1087
|
}
|
770
1088
|
|
771
|
-
|
772
|
-
this.
|
773
|
-
this.
|
774
|
-
this.hiddenFieldTarget.name = this.nameWhenNewValue;
|
775
|
-
this._markValid();
|
776
|
-
|
777
|
-
this._dispatchSelectionEvent({ isNew: true });
|
1089
|
+
_deselectAndNotify() {
|
1090
|
+
const previousValue = this._deselect();
|
1091
|
+
this._dispatchPreselectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
|
778
1092
|
}
|
779
1093
|
|
780
1094
|
_selectIndex(index) {
|
781
1095
|
const option = wrapAroundAccess(this._visibleOptionElements, index);
|
782
|
-
this.
|
1096
|
+
this._forceSelectionWithoutFiltering(option);
|
783
1097
|
}
|
784
1098
|
|
785
|
-
|
786
|
-
if (this._hasValueButNoSelection && this._allOptions.length < 100) {
|
787
|
-
const option = this.
|
788
|
-
|
789
|
-
|
1099
|
+
_preselectSingle() {
|
1100
|
+
if (this._isSingleSelect && this._hasValueButNoSelection && this._allOptions.length < 100) {
|
1101
|
+
const option = this._optionElementWithValue(this._fieldValue);
|
1102
|
+
if (option) this._markSelected(option);
|
1103
|
+
}
|
1104
|
+
}
|
790
1105
|
|
791
|
-
|
1106
|
+
_preselectMultiple() {
|
1107
|
+
if (this._isMultiselect && this._hasValueButNoSelection) {
|
1108
|
+
this._requestChips(this._fieldValueString);
|
1109
|
+
this._resetMultiselectionMarks();
|
792
1110
|
}
|
793
1111
|
}
|
794
1112
|
|
1113
|
+
_selectAndAutocompleteMissingPortion(option) {
|
1114
|
+
this._select(option, this._autocompleteMissingPortion.bind(this));
|
1115
|
+
}
|
1116
|
+
|
1117
|
+
_selectAndAutocompleteFullQuery(option) {
|
1118
|
+
this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
|
1119
|
+
}
|
1120
|
+
|
1121
|
+
_forceSelectionAndFilter(option, inputType) {
|
1122
|
+
this._forceSelectionWithoutFiltering(option);
|
1123
|
+
this._filter(inputType);
|
1124
|
+
}
|
1125
|
+
|
1126
|
+
_forceSelectionWithoutFiltering(option) {
|
1127
|
+
this._selectAndAutocompleteFullQuery(option);
|
1128
|
+
}
|
1129
|
+
|
795
1130
|
_lockInSelection() {
|
796
1131
|
if (this._shouldLockInSelection) {
|
797
|
-
this.
|
798
|
-
this.filter({ inputType: "hw:lockInSelection" });
|
1132
|
+
this._forceSelectionAndFilter(this._ensurableOption, "hw:lockInSelection");
|
799
1133
|
}
|
1134
|
+
}
|
800
1135
|
|
801
|
-
|
802
|
-
|
803
|
-
|
804
|
-
|
1136
|
+
_markSelected(option) {
|
1137
|
+
if (this.hasSelectedClass) option.classList.add(this.selectedClass);
|
1138
|
+
option.setAttribute("aria-selected", true);
|
1139
|
+
this._setActiveDescendant(option.id);
|
1140
|
+
}
|
1141
|
+
|
1142
|
+
_markNotSelected(option) {
|
1143
|
+
if (this.hasSelectedClass) option.classList.remove(this.selectedClass);
|
1144
|
+
option.removeAttribute("aria-selected");
|
1145
|
+
this._removeActiveDescendant();
|
805
1146
|
}
|
806
1147
|
|
807
1148
|
_setActiveDescendant(id) {
|
808
1149
|
this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
|
809
1150
|
}
|
810
1151
|
|
1152
|
+
_removeActiveDescendant() {
|
1153
|
+
this._setActiveDescendant("");
|
1154
|
+
}
|
1155
|
+
|
811
1156
|
get _hasValueButNoSelection() {
|
812
|
-
return this.
|
1157
|
+
return this._hasFieldValue && !this._hasSelection
|
1158
|
+
}
|
1159
|
+
|
1160
|
+
get _hasSelection() {
|
1161
|
+
if (this._isSingleSelect) {
|
1162
|
+
this._selectedOptionElement;
|
1163
|
+
} else {
|
1164
|
+
this._multiselectedOptionElements.length > 0;
|
1165
|
+
}
|
813
1166
|
}
|
814
1167
|
|
815
1168
|
get _shouldLockInSelection() {
|
@@ -1100,19 +1453,40 @@ Combobox.Toggle = Base => class extends Base {
|
|
1100
1453
|
this.expandedValue = true;
|
1101
1454
|
}
|
1102
1455
|
|
1103
|
-
|
1456
|
+
openByFocusing() {
|
1457
|
+
this._actingCombobox.focus();
|
1458
|
+
}
|
1459
|
+
|
1460
|
+
close(inputType) {
|
1104
1461
|
if (this._isOpen) {
|
1462
|
+
const shouldReopen = this._isMultiselect &&
|
1463
|
+
this._isSync &&
|
1464
|
+
!this._isSmallViewport &&
|
1465
|
+
inputType != "hw:clickOutside" &&
|
1466
|
+
inputType != "hw:focusOutside";
|
1467
|
+
|
1105
1468
|
this._lockInSelection();
|
1469
|
+
this._clearInvalidQuery();
|
1470
|
+
|
1106
1471
|
this.expandedValue = false;
|
1107
|
-
|
1472
|
+
|
1473
|
+
this._dispatchSelectionEvent();
|
1474
|
+
|
1475
|
+
if (inputType != "hw:keyHandler:escape") {
|
1476
|
+
this._createChip(shouldReopen);
|
1477
|
+
}
|
1478
|
+
|
1479
|
+
if (this._isSingleSelect && this._selectedOptionElement) {
|
1480
|
+
this._announceToScreenReader(this._displayForOptionElement(this._selectedOptionElement), "selected");
|
1481
|
+
}
|
1108
1482
|
}
|
1109
1483
|
}
|
1110
1484
|
|
1111
1485
|
toggle() {
|
1112
1486
|
if (this.expandedValue) {
|
1113
|
-
this.
|
1487
|
+
this._closeAndBlur("hw:toggle");
|
1114
1488
|
} else {
|
1115
|
-
this.
|
1489
|
+
this.openByFocusing();
|
1116
1490
|
}
|
1117
1491
|
}
|
1118
1492
|
|
@@ -1123,14 +1497,14 @@ Combobox.Toggle = Base => class extends Base {
|
|
1123
1497
|
if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
|
1124
1498
|
if (this._withinElementBounds(event)) return
|
1125
1499
|
|
1126
|
-
this.
|
1500
|
+
this._closeAndBlur("hw:clickOutside");
|
1127
1501
|
}
|
1128
1502
|
|
1129
1503
|
closeOnFocusOutside({ target }) {
|
1130
1504
|
if (!this._isOpen) return
|
1131
1505
|
if (this.element.contains(target)) return
|
1132
1506
|
|
1133
|
-
this.
|
1507
|
+
this._closeAndBlur("hw:focusOutside");
|
1134
1508
|
}
|
1135
1509
|
|
1136
1510
|
clearOrToggleOnHandleClick() {
|
@@ -1142,6 +1516,11 @@ Combobox.Toggle = Base => class extends Base {
|
|
1142
1516
|
}
|
1143
1517
|
}
|
1144
1518
|
|
1519
|
+
_closeAndBlur(inputType) {
|
1520
|
+
this.close(inputType);
|
1521
|
+
this._actingCombobox.blur();
|
1522
|
+
}
|
1523
|
+
|
1145
1524
|
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
1146
1525
|
// Hovering over these elements emits a click event for some reason.
|
1147
1526
|
// These events don't contain any telling information, so we use `_withinElementBounds`
|
@@ -1153,18 +1532,16 @@ Combobox.Toggle = Base => class extends Base {
|
|
1153
1532
|
return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
|
1154
1533
|
}
|
1155
1534
|
|
1156
|
-
_openByFocusing() {
|
1157
|
-
this._actingCombobox.focus();
|
1158
|
-
}
|
1159
|
-
|
1160
1535
|
_isDialogDismisser(target) {
|
1161
1536
|
return target.closest("dialog") && target.role != "combobox"
|
1162
1537
|
}
|
1163
1538
|
|
1164
1539
|
_expand() {
|
1165
|
-
if (this.
|
1540
|
+
if (this._isSync) {
|
1541
|
+
this._preselectSingle();
|
1542
|
+
}
|
1166
1543
|
|
1167
|
-
if (this._autocompletesList && this.
|
1544
|
+
if (this._autocompletesList && this._isSmallViewport) {
|
1168
1545
|
this._openInDialog();
|
1169
1546
|
} else {
|
1170
1547
|
this._openInline();
|
@@ -1173,6 +1550,8 @@ Combobox.Toggle = Base => class extends Base {
|
|
1173
1550
|
this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
|
1174
1551
|
}
|
1175
1552
|
|
1553
|
+
// +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
|
1554
|
+
// it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
|
1176
1555
|
_collapse() {
|
1177
1556
|
this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
|
1178
1557
|
|
@@ -1213,12 +1592,15 @@ Combobox.Toggle = Base => class extends Base {
|
|
1213
1592
|
enableBodyScroll(this.dialogListboxTarget);
|
1214
1593
|
}
|
1215
1594
|
|
1216
|
-
|
1217
|
-
|
1595
|
+
_clearInvalidQuery() {
|
1596
|
+
if (this._isUnjustifiablyBlank) {
|
1597
|
+
this._deselect();
|
1598
|
+
this._clearQuery();
|
1599
|
+
}
|
1218
1600
|
}
|
1219
1601
|
|
1220
|
-
get
|
1221
|
-
return
|
1602
|
+
get _isOpen() {
|
1603
|
+
return this.expandedValue
|
1222
1604
|
}
|
1223
1605
|
};
|
1224
1606
|
|
@@ -1256,7 +1638,7 @@ Combobox.Validity = Base => class extends Base {
|
|
1256
1638
|
// +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
|
1257
1639
|
// because the `required` attribute is only forwarded to the `comboboxTarget` element
|
1258
1640
|
get _valueIsInvalid() {
|
1259
|
-
const isRequiredAndEmpty = this.comboboxTarget.required &&
|
1641
|
+
const isRequiredAndEmpty = this.comboboxTarget.required && this._hasEmptyFieldValue;
|
1260
1642
|
return isRequiredAndEmpty
|
1261
1643
|
}
|
1262
1644
|
};
|
@@ -1266,11 +1648,14 @@ window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0; // ms, for testing purposes
|
|
1266
1648
|
const concerns = [
|
1267
1649
|
Controller,
|
1268
1650
|
Combobox.Actors,
|
1651
|
+
Combobox.Announcements,
|
1269
1652
|
Combobox.AsyncLoading,
|
1270
1653
|
Combobox.Autocomplete,
|
1271
1654
|
Combobox.Dialog,
|
1272
1655
|
Combobox.Events,
|
1273
1656
|
Combobox.Filtering,
|
1657
|
+
Combobox.FormField,
|
1658
|
+
Combobox.Multiselect,
|
1274
1659
|
Combobox.Navigation,
|
1275
1660
|
Combobox.NewOptions,
|
1276
1661
|
Combobox.Options,
|
@@ -1286,7 +1671,10 @@ class HwComboboxController extends Concerns(...concerns) {
|
|
1286
1671
|
]
|
1287
1672
|
|
1288
1673
|
static targets = [
|
1674
|
+
"announcer",
|
1289
1675
|
"combobox",
|
1676
|
+
"chipDismisser",
|
1677
|
+
"closer",
|
1290
1678
|
"dialog",
|
1291
1679
|
"dialogCombobox",
|
1292
1680
|
"dialogFocusTrap",
|
@@ -1307,12 +1695,14 @@ class HwComboboxController extends Concerns(...concerns) {
|
|
1307
1695
|
nameWhenNew: String,
|
1308
1696
|
originalName: String,
|
1309
1697
|
prefilledDisplay: String,
|
1698
|
+
selectionChipSrc: String,
|
1310
1699
|
smallViewportMaxWidth: String
|
1311
1700
|
}
|
1312
1701
|
|
1313
1702
|
initialize() {
|
1314
1703
|
this._initializeActors();
|
1315
1704
|
this._initializeFiltering();
|
1705
|
+
this._initializeMultiselect();
|
1316
1706
|
}
|
1317
1707
|
|
1318
1708
|
connect() {
|
@@ -1337,13 +1727,27 @@ class HwComboboxController extends Concerns(...concerns) {
|
|
1337
1727
|
const inputType = element.dataset.inputType;
|
1338
1728
|
const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
|
1339
1729
|
|
1340
|
-
|
1730
|
+
this._resetMultiselectionMarks();
|
1731
|
+
|
1732
|
+
if (inputType === "hw:multiselectSync") {
|
1733
|
+
this.openByFocusing();
|
1734
|
+
} else if (inputType && inputType !== "hw:lockInSelection") {
|
1341
1735
|
if (delay) await sleep(delay);
|
1342
|
-
this.
|
1736
|
+
this._selectOnQuery(inputType);
|
1343
1737
|
} else {
|
1344
|
-
this.
|
1738
|
+
this._preselectSingle();
|
1345
1739
|
}
|
1346
1740
|
}
|
1741
|
+
|
1742
|
+
closerTargetConnected() {
|
1743
|
+
this._closeAndBlur("hw:asyncCloser");
|
1744
|
+
}
|
1745
|
+
|
1746
|
+
// Use +_printStack+ for debugging purposes
|
1747
|
+
_printStack() {
|
1748
|
+
const err = new Error();
|
1749
|
+
console.log(err.stack || err.stacktrace);
|
1750
|
+
}
|
1347
1751
|
}
|
1348
1752
|
|
1349
1753
|
export { HwComboboxController as default };
|