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
|
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 };
|