hotwire_combobox 0.1.37 → 0.1.38

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,1254 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ const Combobox = {};
4
+
5
+ Combobox.Actors = Base => class extends Base {
6
+ _initializeActors() {
7
+ this._actingListbox = this.listboxTarget;
8
+ this._actingCombobox = this.comboboxTarget;
9
+ }
10
+
11
+ _forAllComboboxes(callback) {
12
+ this._allComboboxes.forEach(callback);
13
+ }
14
+
15
+ get _actingListbox() {
16
+ return this.actingListbox
17
+ }
18
+
19
+ set _actingListbox(listbox) {
20
+ this.actingListbox = listbox;
21
+ }
22
+
23
+ get _actingCombobox() {
24
+ return this.actingCombobox
25
+ }
26
+
27
+ set _actingCombobox(combobox) {
28
+ this.actingCombobox = combobox;
29
+ }
30
+
31
+ get _allComboboxes() {
32
+ return [ this.comboboxTarget, this.dialogComboboxTarget ]
33
+ }
34
+ };
35
+
36
+ Combobox.AsyncLoading = Base => class extends Base {
37
+ get _isAsync() {
38
+ return this.hasAsyncSrcValue
39
+ }
40
+ };
41
+
42
+ function Concerns(Base, ...mixins) {
43
+ return mixins.reduce((accumulator, current) => current(accumulator), Base)
44
+ }
45
+
46
+ function visible(target) {
47
+ return !(target.hidden || target.closest("[hidden]"))
48
+ }
49
+
50
+ function wrapAroundAccess(array, index) {
51
+ const first = 0;
52
+ const last = array.length - 1;
53
+
54
+ if (index < first) return array[last]
55
+ if (index > last) return array[first]
56
+ return array[index]
57
+ }
58
+
59
+ function applyFilter(query, { matching }) {
60
+ return (target) => {
61
+ if (query) {
62
+ const value = target.getAttribute(matching) || "";
63
+ const match = value.toLowerCase().includes(query.toLowerCase());
64
+
65
+ target.hidden = !match;
66
+ } else {
67
+ target.hidden = false;
68
+ }
69
+ }
70
+ }
71
+
72
+ function cancel(event) {
73
+ event.stopPropagation();
74
+ event.preventDefault();
75
+ }
76
+
77
+ function startsWith(string, substring) {
78
+ return string.toLowerCase().startsWith(substring.toLowerCase())
79
+ }
80
+
81
+ function debounce(fn, delay = 150) {
82
+ let timeoutId = null;
83
+
84
+ return (...args) => {
85
+ const callback = () => fn.apply(this, args);
86
+ clearTimeout(timeoutId);
87
+ timeoutId = setTimeout(callback, delay);
88
+ }
89
+ }
90
+
91
+ function isDeleteEvent(event) {
92
+ return event.inputType === "deleteContentBackward" || event.inputType === "deleteWordBackward"
93
+ }
94
+
95
+ function sleep(ms) {
96
+ return new Promise(resolve => setTimeout(resolve, ms))
97
+ }
98
+
99
+ function unselectedPortion(element) {
100
+ if (element.selectionStart === element.selectionEnd) {
101
+ return element.value
102
+ } else {
103
+ return element.value.substring(0, element.selectionStart)
104
+ }
105
+ }
106
+
107
+ Combobox.Autocomplete = Base => class extends Base {
108
+ _connectListAutocomplete() {
109
+ if (!this._autocompletesList) {
110
+ this._visuallyHideListbox();
111
+ }
112
+ }
113
+
114
+ _autocompleteWith(option, { force }) {
115
+ if (!this._autocompletesInline && !force) return
116
+
117
+ const typedValue = this._typedQuery;
118
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
119
+
120
+ if (force) {
121
+ this._fullQuery = autocompletedValue;
122
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
123
+ } else if (startsWith(autocompletedValue, typedValue)) {
124
+ this._fullQuery = autocompletedValue;
125
+ this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
126
+ }
127
+ }
128
+
129
+ // +visuallyHideListbox+ hides the listbox from the user,
130
+ // but makes it still searchable by JS.
131
+ _visuallyHideListbox() {
132
+ this.listboxTarget.style.display = "none";
133
+ }
134
+
135
+ get _isExactAutocompleteMatch() {
136
+ return this._immediatelyAutocompletableValue === this._fullQuery
137
+ }
138
+
139
+ // All `_isExactAutocompleteMatch` matches are `_isPartialAutocompleteMatch` matches
140
+ // but not all `_isPartialAutocompleteMatch` matches are `_isExactAutocompleteMatch` matches.
141
+ get _isPartialAutocompleteMatch() {
142
+ return !!this._immediatelyAutocompletableValue &&
143
+ startsWith(this._immediatelyAutocompletableValue, this._fullQuery)
144
+ }
145
+
146
+ get _autocompletesList() {
147
+ return this.autocompleteValue === "both" || this.autocompleteValue === "list"
148
+ }
149
+
150
+ get _autocompletesInline() {
151
+ return this.autocompleteValue === "both" || this.autocompleteValue === "inline"
152
+ }
153
+
154
+ get _immediatelyAutocompletableValue() {
155
+ return this._ensurableOption?.getAttribute(this.autocompletableAttributeValue)
156
+ }
157
+ };
158
+
159
+ Combobox.Dialog = Base => class extends Base {
160
+ _connectDialog() {
161
+ if (window.visualViewport) {
162
+ window.visualViewport.addEventListener("resize", this._resizeDialog);
163
+ }
164
+ }
165
+
166
+ _disconnectDialog() {
167
+ if (window.visualViewport) {
168
+ window.visualViewport.removeEventListener("resize", this._resizeDialog);
169
+ }
170
+ }
171
+
172
+ _moveArtifactsToDialog() {
173
+ this.dialogComboboxTarget.value = this._fullQuery;
174
+
175
+ this._actingCombobox = this.dialogComboboxTarget;
176
+ this._actingListbox = this.dialogListboxTarget;
177
+
178
+ this.dialogListboxTarget.append(...this.listboxTarget.children);
179
+ }
180
+
181
+ _moveArtifactsInline() {
182
+ this.comboboxTarget.value = this._fullQuery;
183
+
184
+ this._actingCombobox = this.comboboxTarget;
185
+ this._actingListbox = this.listboxTarget;
186
+
187
+ this.listboxTarget.append(...this.dialogListboxTarget.children);
188
+ }
189
+
190
+ _resizeDialog = () => {
191
+ if (window.visualViewport) {
192
+ this.dialogTarget.style.setProperty(
193
+ "--hw-visual-viewport-height",
194
+ `${window.visualViewport.height}px`
195
+ );
196
+ }
197
+ }
198
+
199
+ // After closing a dialog, focus returns to the last focused element.
200
+ // +preventFocusingComboboxAfterClosingDialog+ focuses a placeholder element before opening
201
+ // the dialog, so that the combobox is not focused again after closing, which would reopen.
202
+ _preventFocusingComboboxAfterClosingDialog() {
203
+ this.dialogFocusTrapTarget.focus();
204
+ }
205
+
206
+ get _smallViewport() {
207
+ return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
208
+ }
209
+ };
210
+
211
+ class FetchResponse {
212
+ constructor(response) {
213
+ this.response = response;
214
+ }
215
+ get statusCode() {
216
+ return this.response.status;
217
+ }
218
+ get redirected() {
219
+ return this.response.redirected;
220
+ }
221
+ get ok() {
222
+ return this.response.ok;
223
+ }
224
+ get unauthenticated() {
225
+ return this.statusCode === 401;
226
+ }
227
+ get unprocessableEntity() {
228
+ return this.statusCode === 422;
229
+ }
230
+ get authenticationURL() {
231
+ return this.response.headers.get("WWW-Authenticate");
232
+ }
233
+ get contentType() {
234
+ const contentType = this.response.headers.get("Content-Type") || "";
235
+ return contentType.replace(/;.*$/, "");
236
+ }
237
+ get headers() {
238
+ return this.response.headers;
239
+ }
240
+ get html() {
241
+ if (this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)) {
242
+ return this.text;
243
+ }
244
+ return Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`));
245
+ }
246
+ get json() {
247
+ if (this.contentType.match(/^application\/.*json$/)) {
248
+ return this.responseJson || (this.responseJson = this.response.json());
249
+ }
250
+ return Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`));
251
+ }
252
+ get text() {
253
+ return this.responseText || (this.responseText = this.response.text());
254
+ }
255
+ get isTurboStream() {
256
+ return this.contentType.match(/^text\/vnd\.turbo-stream\.html/);
257
+ }
258
+ async renderTurboStream() {
259
+ if (this.isTurboStream) {
260
+ if (window.Turbo) {
261
+ await window.Turbo.renderStreamMessage(await this.text);
262
+ } else {
263
+ console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js");
264
+ }
265
+ } else {
266
+ return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));
267
+ }
268
+ }
269
+ }
270
+
271
+ class RequestInterceptor {
272
+ static register(interceptor) {
273
+ this.interceptor = interceptor;
274
+ }
275
+ static get() {
276
+ return this.interceptor;
277
+ }
278
+ static reset() {
279
+ this.interceptor = undefined;
280
+ }
281
+ }
282
+
283
+ function getCookie(name) {
284
+ const cookies = document.cookie ? document.cookie.split("; ") : [];
285
+ const prefix = `${encodeURIComponent(name)}=`;
286
+ const cookie = cookies.find((cookie => cookie.startsWith(prefix)));
287
+ if (cookie) {
288
+ const value = cookie.split("=").slice(1).join("=");
289
+ if (value) {
290
+ return decodeURIComponent(value);
291
+ }
292
+ }
293
+ }
294
+
295
+ function compact(object) {
296
+ const result = {};
297
+ for (const key in object) {
298
+ const value = object[key];
299
+ if (value !== undefined) {
300
+ result[key] = value;
301
+ }
302
+ }
303
+ return result;
304
+ }
305
+
306
+ function metaContent(name) {
307
+ const element = document.head.querySelector(`meta[name="${name}"]`);
308
+ return element && element.content;
309
+ }
310
+
311
+ function stringEntriesFromFormData(formData) {
312
+ return [ ...formData ].reduce(((entries, [name, value]) => entries.concat(typeof value === "string" ? [ [ name, value ] ] : [])), []);
313
+ }
314
+
315
+ function mergeEntries(searchParams, entries) {
316
+ for (const [name, value] of entries) {
317
+ if (value instanceof window.File) continue;
318
+ if (searchParams.has(name) && !name.includes("[]")) {
319
+ searchParams.delete(name);
320
+ searchParams.set(name, value);
321
+ } else {
322
+ searchParams.append(name, value);
323
+ }
324
+ }
325
+ }
326
+
327
+ class FetchRequest {
328
+ constructor(method, url, options = {}) {
329
+ this.method = method;
330
+ this.options = options;
331
+ this.originalUrl = url.toString();
332
+ }
333
+ async perform() {
334
+ try {
335
+ const requestInterceptor = RequestInterceptor.get();
336
+ if (requestInterceptor) {
337
+ await requestInterceptor(this);
338
+ }
339
+ } catch (error) {
340
+ console.error(error);
341
+ }
342
+ const response = new FetchResponse(await window.fetch(this.url, this.fetchOptions));
343
+ if (response.unauthenticated && response.authenticationURL) {
344
+ return Promise.reject(window.location.href = response.authenticationURL);
345
+ }
346
+ const responseStatusIsTurboStreamable = response.ok || response.unprocessableEntity;
347
+ if (responseStatusIsTurboStreamable && response.isTurboStream) {
348
+ await response.renderTurboStream();
349
+ }
350
+ return response;
351
+ }
352
+ addHeader(key, value) {
353
+ const headers = this.additionalHeaders;
354
+ headers[key] = value;
355
+ this.options.headers = headers;
356
+ }
357
+ sameHostname() {
358
+ if (!this.originalUrl.startsWith("http:")) {
359
+ return true;
360
+ }
361
+ try {
362
+ return new URL(this.originalUrl).hostname === window.location.hostname;
363
+ } catch (_) {
364
+ return true;
365
+ }
366
+ }
367
+ get fetchOptions() {
368
+ return {
369
+ method: this.method.toUpperCase(),
370
+ headers: this.headers,
371
+ body: this.formattedBody,
372
+ signal: this.signal,
373
+ credentials: this.credentials,
374
+ redirect: this.redirect
375
+ };
376
+ }
377
+ get headers() {
378
+ const baseHeaders = {
379
+ "X-Requested-With": "XMLHttpRequest",
380
+ "Content-Type": this.contentType,
381
+ Accept: this.accept
382
+ };
383
+ if (this.sameHostname()) {
384
+ baseHeaders["X-CSRF-Token"] = this.csrfToken;
385
+ }
386
+ return compact(Object.assign(baseHeaders, this.additionalHeaders));
387
+ }
388
+ get csrfToken() {
389
+ return getCookie(metaContent("csrf-param")) || metaContent("csrf-token");
390
+ }
391
+ get contentType() {
392
+ if (this.options.contentType) {
393
+ return this.options.contentType;
394
+ } else if (this.body == null || this.body instanceof window.FormData) {
395
+ return undefined;
396
+ } else if (this.body instanceof window.File) {
397
+ return this.body.type;
398
+ }
399
+ return "application/json";
400
+ }
401
+ get accept() {
402
+ switch (this.responseKind) {
403
+ case "html":
404
+ return "text/html, application/xhtml+xml";
405
+
406
+ case "turbo-stream":
407
+ return "text/vnd.turbo-stream.html, text/html, application/xhtml+xml";
408
+
409
+ case "json":
410
+ return "application/json, application/vnd.api+json";
411
+
412
+ default:
413
+ return "*/*";
414
+ }
415
+ }
416
+ get body() {
417
+ return this.options.body;
418
+ }
419
+ get query() {
420
+ const originalQuery = (this.originalUrl.split("?")[1] || "").split("#")[0];
421
+ const params = new URLSearchParams(originalQuery);
422
+ let requestQuery = this.options.query;
423
+ if (requestQuery instanceof window.FormData) {
424
+ requestQuery = stringEntriesFromFormData(requestQuery);
425
+ } else if (requestQuery instanceof window.URLSearchParams) {
426
+ requestQuery = requestQuery.entries();
427
+ } else {
428
+ requestQuery = Object.entries(requestQuery || {});
429
+ }
430
+ mergeEntries(params, requestQuery);
431
+ const query = params.toString();
432
+ return query.length > 0 ? `?${query}` : "";
433
+ }
434
+ get url() {
435
+ return this.originalUrl.split("?")[0].split("#")[0] + this.query;
436
+ }
437
+ get responseKind() {
438
+ return this.options.responseKind || "html";
439
+ }
440
+ get signal() {
441
+ return this.options.signal;
442
+ }
443
+ get redirect() {
444
+ return this.options.redirect || "follow";
445
+ }
446
+ get credentials() {
447
+ return this.options.credentials || "same-origin";
448
+ }
449
+ get additionalHeaders() {
450
+ return this.options.headers || {};
451
+ }
452
+ get formattedBody() {
453
+ const bodyIsAString = Object.prototype.toString.call(this.body) === "[object String]";
454
+ const contentTypeIsJson = this.headers["Content-Type"] === "application/json";
455
+ if (contentTypeIsJson && !bodyIsAString) {
456
+ return JSON.stringify(this.body);
457
+ }
458
+ return this.body;
459
+ }
460
+ }
461
+
462
+ async function get(url, options) {
463
+ const request = new FetchRequest("get", url, options);
464
+ return request.perform();
465
+ }
466
+
467
+ // Copyright (c) 2021 Marcelo Lauxen
468
+
469
+ // Permission is hereby granted, free of charge, to any person obtaining
470
+ // a copy of this software and associated documentation files (the
471
+ // "Software"), to deal in the Software without restriction, including
472
+ // without limitation the rights to use, copy, modify, merge, publish,
473
+ // distribute, sublicense, and/or sell copies of the Software, and to
474
+ // permit persons to whom the Software is furnished to do so, subject to
475
+ // the following conditions:
476
+
477
+ // The above copyright notice and this permission notice shall be
478
+ // included in all copies or substantial portions of the Software.
479
+
480
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
481
+ // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
482
+ // MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
483
+ // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
484
+ // LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
485
+ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
486
+ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
487
+
488
+ Combobox.Filtering = Base => class extends Base {
489
+ filter(event) {
490
+ if (this._isAsync) {
491
+ this._debouncedFilterAsync(event);
492
+ } else {
493
+ this._filterSync(event);
494
+ }
495
+ }
496
+
497
+ _initializeFiltering() {
498
+ this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
499
+ }
500
+
501
+ _debouncedFilterAsync(event) {
502
+ this._filterAsync(event);
503
+ }
504
+
505
+ async _filterAsync(event) {
506
+ const query = {
507
+ q: this._fullQuery,
508
+ input_type: event.inputType,
509
+ for_id: this.element.dataset.asyncId
510
+ };
511
+
512
+ await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
513
+ }
514
+
515
+ _filterSync(event) {
516
+ this.open();
517
+ this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
518
+ this._commitFilter(event);
519
+ }
520
+
521
+ _commitFilter(event) {
522
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
523
+ this._selectNew();
524
+ } else if (isDeleteEvent(event)) {
525
+ this._deselect();
526
+ } else {
527
+ this._select(this._visibleOptionElements[0]);
528
+ }
529
+ }
530
+
531
+ get _isQueried() {
532
+ return this._fullQuery.length > 0
533
+ }
534
+
535
+ get _fullQuery() {
536
+ return this._actingCombobox.value
537
+ }
538
+
539
+ set _fullQuery(value) {
540
+ this._actingCombobox.value = value;
541
+ }
542
+
543
+ get _typedQuery() {
544
+ return unselectedPortion(this._actingCombobox)
545
+ }
546
+ };
547
+
548
+ Combobox.Navigation = Base => class extends Base {
549
+ navigate(event) {
550
+ if (this._autocompletesList) {
551
+ this._keyHandlers[event.key]?.call(this, event);
552
+ }
553
+ }
554
+
555
+ _keyHandlers = {
556
+ ArrowUp: (event) => {
557
+ this._selectIndex(this._selectedOptionIndex - 1);
558
+ cancel(event);
559
+ },
560
+ ArrowDown: (event) => {
561
+ this._selectIndex(this._selectedOptionIndex + 1);
562
+ cancel(event);
563
+ },
564
+ Home: (event) => {
565
+ this._selectIndex(0);
566
+ cancel(event);
567
+ },
568
+ End: (event) => {
569
+ this._selectIndex(this._visibleOptionElements.length - 1);
570
+ cancel(event);
571
+ },
572
+ Enter: (event) => {
573
+ this.close();
574
+ this._actingCombobox.blur();
575
+ cancel(event);
576
+ },
577
+ Escape: (event) => {
578
+ this.close();
579
+ this._actingCombobox.blur();
580
+ cancel(event);
581
+ }
582
+ }
583
+ };
584
+
585
+ Combobox.NewOptions = Base => class extends Base {
586
+ _shouldTreatAsNewOptionForFiltering(queryIsBeingRefined) {
587
+ if (queryIsBeingRefined) {
588
+ return this._isNewOptionWithNoPotentialMatches
589
+ } else {
590
+ return this._isNewOptionWithPotentialMatches
591
+ }
592
+ }
593
+
594
+ // If the user is going to keep refining the query, we can't be sure whether
595
+ // the option will end up being new or not unless there are no potential matches.
596
+ // +_isNewOptionWithNoPotentialMatches+ allows us to make our best guess
597
+ // while the state of the combobox is still in flux.
598
+ //
599
+ // It's okay for the combobox to say it's not new even if it will be eventually,
600
+ // as only the final state matters for submission purposes. This method exists
601
+ // as a best effort to keep the state accurate as often as we can.
602
+ //
603
+ // Note that the first visible option is automatically selected as you type.
604
+ // So if there's a partial match, it's not a new option at this point.
605
+ //
606
+ // The final state is locked-in upon closing the combobox via `_isNewOptionWithPotentialMatches`.
607
+ get _isNewOptionWithNoPotentialMatches() {
608
+ return this._isNewOptionWithPotentialMatches && !this._isPartialAutocompleteMatch
609
+ }
610
+
611
+ // If the query is finalized, we don't care that there are potential matches
612
+ // because new options can be substrings of existing options.
613
+ //
614
+ // We can't use `_isNewOptionWithNoPotentialMatches` because that would
615
+ // rule out new options that are partial matches.
616
+ get _isNewOptionWithPotentialMatches() {
617
+ return this._isQueried && this._allowNew && !this._isExactAutocompleteMatch
618
+ }
619
+ };
620
+
621
+ Combobox.Options = Base => class extends Base {
622
+ _resetOptions() {
623
+ this._deselect();
624
+ this.hiddenFieldTarget.name = this.originalNameValue;
625
+ }
626
+
627
+ get _allowNew() {
628
+ return !!this.nameWhenNewValue
629
+ }
630
+
631
+ get _allOptions() {
632
+ return Array.from(this._allOptionElements)
633
+ }
634
+
635
+ get _allOptionElements() {
636
+ return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
637
+ }
638
+
639
+ get _visibleOptionElements() {
640
+ return [ ...this._allOptionElements ].filter(visible)
641
+ }
642
+
643
+ get _selectedOptionElement() {
644
+ return this._actingListbox.querySelector("[role=option][aria-selected=true]")
645
+ }
646
+
647
+ get _selectedOptionIndex() {
648
+ return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement)
649
+ }
650
+ };
651
+
652
+ Combobox.Selection = Base => class extends Base {
653
+ selectOption(event) {
654
+ this._select(event.currentTarget);
655
+ this.filter(event);
656
+ this.close();
657
+ }
658
+
659
+ _connectSelection() {
660
+ if (this.hasPrefilledDisplayValue) {
661
+ this._fullQuery = this.prefilledDisplayValue;
662
+ }
663
+ }
664
+
665
+ _select(option, { forceAutocomplete = false } = {}) {
666
+ this._resetOptions();
667
+
668
+ if (option) {
669
+ this._markValid();
670
+ this._autocompleteWith(option, { force: forceAutocomplete });
671
+ this._commitSelection(option, { selected: true });
672
+ } else {
673
+ this._markInvalid();
674
+ }
675
+ }
676
+
677
+ _commitSelection(option, { selected }) {
678
+ this._markSelected(option, { selected });
679
+
680
+ if (selected) {
681
+ this.hiddenFieldTarget.value = option.dataset.value;
682
+ option.scrollIntoView({ block: "nearest" });
683
+ }
684
+ }
685
+
686
+ _markSelected(option, { selected }) {
687
+ if (this.hasSelectedClass) {
688
+ option.classList.toggle(this.selectedClass, selected);
689
+ }
690
+
691
+ option.setAttribute("aria-selected", selected);
692
+ this._setActiveDescendant(selected ? option.id : "");
693
+ }
694
+
695
+ _deselect() {
696
+ const option = this._selectedOptionElement;
697
+ if (option) this._commitSelection(option, { selected: false });
698
+ this.hiddenFieldTarget.value = null;
699
+ this._setActiveDescendant("");
700
+ }
701
+
702
+ _selectNew() {
703
+ this._resetOptions();
704
+ this.hiddenFieldTarget.value = this._fullQuery;
705
+ this.hiddenFieldTarget.name = this.nameWhenNewValue;
706
+ }
707
+
708
+ _selectIndex(index) {
709
+ const option = wrapAroundAccess(this._visibleOptionElements, index);
710
+ this._select(option, { forceAutocomplete: true });
711
+ }
712
+
713
+ _preselectOption() {
714
+ if (this._hasValueButNoSelection && this._allOptions.length < 100) {
715
+ const option = this._allOptions.find(option => {
716
+ return option.dataset.value === this.hiddenFieldTarget.value
717
+ });
718
+
719
+ if (option) this._markSelected(option, { selected: true });
720
+ }
721
+ }
722
+
723
+ _lockInSelection() {
724
+ if (this._shouldLockInSelection) {
725
+ this._select(this._ensurableOption, { forceAutocomplete: true });
726
+ this.filter({ inputType: "hw:lockInSelection" });
727
+ }
728
+ }
729
+
730
+ _setActiveDescendant(id) {
731
+ this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
732
+ }
733
+
734
+ get _hasValueButNoSelection() {
735
+ return this.hiddenFieldTarget.value && !this._selectedOptionElement
736
+ }
737
+
738
+ get _shouldLockInSelection() {
739
+ return this._isQueried && !!this._ensurableOption && !this._isNewOptionWithPotentialMatches
740
+ }
741
+
742
+ get _ensurableOption() {
743
+ return this._selectedOptionElement || this._visibleOptionElements[0]
744
+ }
745
+ };
746
+
747
+ function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } }
748
+
749
+ // Older browsers don't support event options, feature detect it.
750
+
751
+ // Adopted and modified solution from Bohdan Didukh (2017)
752
+ // https://stackoverflow.com/questions/41594997/ios-10-safari-prevent-scrolling-behind-a-fixed-overlay-and-maintain-scroll-posi
753
+
754
+ var hasPassiveEvents = false;
755
+ if (typeof window !== 'undefined') {
756
+ var passiveTestOptions = {
757
+ get passive() {
758
+ hasPassiveEvents = true;
759
+ return undefined;
760
+ }
761
+ };
762
+ window.addEventListener('testPassive', null, passiveTestOptions);
763
+ window.removeEventListener('testPassive', null, passiveTestOptions);
764
+ }
765
+
766
+ var isIosDevice = typeof window !== 'undefined' && window.navigator && window.navigator.platform && (/iP(ad|hone|od)/.test(window.navigator.platform) || window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1);
767
+
768
+
769
+ var locks = [];
770
+ var documentListenerAdded = false;
771
+ var initialClientY = -1;
772
+ var previousBodyOverflowSetting = void 0;
773
+ var previousBodyPosition = void 0;
774
+ var previousBodyPaddingRight = void 0;
775
+
776
+ // returns true if `el` should be allowed to receive touchmove events.
777
+ var allowTouchMove = function allowTouchMove(el) {
778
+ return locks.some(function (lock) {
779
+ if (lock.options.allowTouchMove && lock.options.allowTouchMove(el)) {
780
+ return true;
781
+ }
782
+
783
+ return false;
784
+ });
785
+ };
786
+
787
+ var preventDefault = function preventDefault(rawEvent) {
788
+ var e = rawEvent || window.event;
789
+
790
+ // For the case whereby consumers adds a touchmove event listener to document.
791
+ // Recall that we do document.addEventListener('touchmove', preventDefault, { passive: false })
792
+ // in disableBodyScroll - so if we provide this opportunity to allowTouchMove, then
793
+ // the touchmove event on document will break.
794
+ if (allowTouchMove(e.target)) {
795
+ return true;
796
+ }
797
+
798
+ // Do not prevent if the event has more than one touch (usually meaning this is a multi touch gesture like pinch to zoom).
799
+ if (e.touches.length > 1) return true;
800
+
801
+ if (e.preventDefault) e.preventDefault();
802
+
803
+ return false;
804
+ };
805
+
806
+ var setOverflowHidden = function setOverflowHidden(options) {
807
+ // If previousBodyPaddingRight is already set, don't set it again.
808
+ if (previousBodyPaddingRight === undefined) {
809
+ var _reserveScrollBarGap = !!options && options.reserveScrollBarGap === true;
810
+ var scrollBarGap = window.innerWidth - document.documentElement.clientWidth;
811
+
812
+ if (_reserveScrollBarGap && scrollBarGap > 0) {
813
+ var computedBodyPaddingRight = parseInt(window.getComputedStyle(document.body).getPropertyValue('padding-right'), 10);
814
+ previousBodyPaddingRight = document.body.style.paddingRight;
815
+ document.body.style.paddingRight = computedBodyPaddingRight + scrollBarGap + 'px';
816
+ }
817
+ }
818
+
819
+ // If previousBodyOverflowSetting is already set, don't set it again.
820
+ if (previousBodyOverflowSetting === undefined) {
821
+ previousBodyOverflowSetting = document.body.style.overflow;
822
+ document.body.style.overflow = 'hidden';
823
+ }
824
+ };
825
+
826
+ var restoreOverflowSetting = function restoreOverflowSetting() {
827
+ if (previousBodyPaddingRight !== undefined) {
828
+ document.body.style.paddingRight = previousBodyPaddingRight;
829
+
830
+ // Restore previousBodyPaddingRight to undefined so setOverflowHidden knows it
831
+ // can be set again.
832
+ previousBodyPaddingRight = undefined;
833
+ }
834
+
835
+ if (previousBodyOverflowSetting !== undefined) {
836
+ document.body.style.overflow = previousBodyOverflowSetting;
837
+
838
+ // Restore previousBodyOverflowSetting to undefined
839
+ // so setOverflowHidden knows it can be set again.
840
+ previousBodyOverflowSetting = undefined;
841
+ }
842
+ };
843
+
844
+ var setPositionFixed = function setPositionFixed() {
845
+ return window.requestAnimationFrame(function () {
846
+ // If previousBodyPosition is already set, don't set it again.
847
+ if (previousBodyPosition === undefined) {
848
+ previousBodyPosition = {
849
+ position: document.body.style.position,
850
+ top: document.body.style.top,
851
+ left: document.body.style.left
852
+ };
853
+
854
+ // Update the dom inside an animation frame
855
+ var _window = window,
856
+ scrollY = _window.scrollY,
857
+ scrollX = _window.scrollX,
858
+ innerHeight = _window.innerHeight;
859
+
860
+ document.body.style.position = 'fixed';
861
+ document.body.style.top = -scrollY + 'px';
862
+ document.body.style.left = -scrollX + 'px';
863
+
864
+ setTimeout(function () {
865
+ return window.requestAnimationFrame(function () {
866
+ // Attempt to check if the bottom bar appeared due to the position change
867
+ var bottomBarHeight = innerHeight - window.innerHeight;
868
+ if (bottomBarHeight && scrollY >= innerHeight) {
869
+ // Move the content further up so that the bottom bar doesn't hide it
870
+ document.body.style.top = -(scrollY + bottomBarHeight);
871
+ }
872
+ });
873
+ }, 300);
874
+ }
875
+ });
876
+ };
877
+
878
+ var restorePositionSetting = function restorePositionSetting() {
879
+ if (previousBodyPosition !== undefined) {
880
+ // Convert the position from "px" to Int
881
+ var y = -parseInt(document.body.style.top, 10);
882
+ var x = -parseInt(document.body.style.left, 10);
883
+
884
+ // Restore styles
885
+ document.body.style.position = previousBodyPosition.position;
886
+ document.body.style.top = previousBodyPosition.top;
887
+ document.body.style.left = previousBodyPosition.left;
888
+
889
+ // Restore scroll
890
+ window.scrollTo(x, y);
891
+
892
+ previousBodyPosition = undefined;
893
+ }
894
+ };
895
+
896
+ // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions
897
+ var isTargetElementTotallyScrolled = function isTargetElementTotallyScrolled(targetElement) {
898
+ return targetElement ? targetElement.scrollHeight - targetElement.scrollTop <= targetElement.clientHeight : false;
899
+ };
900
+
901
+ var handleScroll = function handleScroll(event, targetElement) {
902
+ var clientY = event.targetTouches[0].clientY - initialClientY;
903
+
904
+ if (allowTouchMove(event.target)) {
905
+ return false;
906
+ }
907
+
908
+ if (targetElement && targetElement.scrollTop === 0 && clientY > 0) {
909
+ // element is at the top of its scroll.
910
+ return preventDefault(event);
911
+ }
912
+
913
+ if (isTargetElementTotallyScrolled(targetElement) && clientY < 0) {
914
+ // element is at the bottom of its scroll.
915
+ return preventDefault(event);
916
+ }
917
+
918
+ event.stopPropagation();
919
+ return true;
920
+ };
921
+
922
+ var disableBodyScroll = function disableBodyScroll(targetElement, options) {
923
+ // targetElement must be provided
924
+ if (!targetElement) {
925
+ // eslint-disable-next-line no-console
926
+ console.error('disableBodyScroll unsuccessful - targetElement must be provided when calling disableBodyScroll on IOS devices.');
927
+ return;
928
+ }
929
+
930
+ // disableBodyScroll must not have been called on this targetElement before
931
+ if (locks.some(function (lock) {
932
+ return lock.targetElement === targetElement;
933
+ })) {
934
+ return;
935
+ }
936
+
937
+ var lock = {
938
+ targetElement: targetElement,
939
+ options: options || {}
940
+ };
941
+
942
+ locks = [].concat(_toConsumableArray(locks), [lock]);
943
+
944
+ if (isIosDevice) {
945
+ setPositionFixed();
946
+ } else {
947
+ setOverflowHidden(options);
948
+ }
949
+
950
+ if (isIosDevice) {
951
+ targetElement.ontouchstart = function (event) {
952
+ if (event.targetTouches.length === 1) {
953
+ // detect single touch.
954
+ initialClientY = event.targetTouches[0].clientY;
955
+ }
956
+ };
957
+ targetElement.ontouchmove = function (event) {
958
+ if (event.targetTouches.length === 1) {
959
+ // detect single touch.
960
+ handleScroll(event, targetElement);
961
+ }
962
+ };
963
+
964
+ if (!documentListenerAdded) {
965
+ document.addEventListener('touchmove', preventDefault, hasPassiveEvents ? { passive: false } : undefined);
966
+ documentListenerAdded = true;
967
+ }
968
+ }
969
+ };
970
+
971
+ var enableBodyScroll = function enableBodyScroll(targetElement) {
972
+ if (!targetElement) {
973
+ // eslint-disable-next-line no-console
974
+ console.error('enableBodyScroll unsuccessful - targetElement must be provided when calling enableBodyScroll on IOS devices.');
975
+ return;
976
+ }
977
+
978
+ locks = locks.filter(function (lock) {
979
+ return lock.targetElement !== targetElement;
980
+ });
981
+
982
+ if (isIosDevice) {
983
+ targetElement.ontouchstart = null;
984
+ targetElement.ontouchmove = null;
985
+
986
+ if (documentListenerAdded && locks.length === 0) {
987
+ document.removeEventListener('touchmove', preventDefault, hasPassiveEvents ? { passive: false } : undefined);
988
+ documentListenerAdded = false;
989
+ }
990
+ }
991
+
992
+ if (isIosDevice) {
993
+ restorePositionSetting();
994
+ } else {
995
+ restoreOverflowSetting();
996
+ }
997
+ };
998
+
999
+ // MIT License
1000
+
1001
+ // Copyright (c) 2018 Will Po
1002
+
1003
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
1004
+ // of this software and associated documentation files (the "Software"), to deal
1005
+ // in the Software without restriction, including without limitation the rights
1006
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
1007
+ // copies of the Software, and to permit persons to whom the Software is
1008
+ // furnished to do so, subject to the following conditions:
1009
+
1010
+ // The above copyright notice and this permission notice shall be included in all
1011
+ // copies or substantial portions of the Software.
1012
+
1013
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1014
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1015
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1016
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1017
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
1018
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1019
+ // SOFTWARE.
1020
+
1021
+ Combobox.Toggle = Base => class extends Base {
1022
+ open() {
1023
+ this.expandedValue = true;
1024
+ }
1025
+
1026
+ close() {
1027
+ if (this._isOpen) {
1028
+ this._lockInSelection();
1029
+ this.expandedValue = false;
1030
+ }
1031
+ }
1032
+
1033
+ toggle() {
1034
+ if (this.expandedValue) {
1035
+ this.close();
1036
+ } else {
1037
+ this._openByFocusing();
1038
+ }
1039
+ }
1040
+
1041
+ closeOnClickOutside(event) {
1042
+ const target = event.target;
1043
+
1044
+ if (!this._isOpen) return
1045
+ if (this.element.contains(target) && !this._isDialogDismisser(target)) return
1046
+ if (this._withinElementBounds(event)) return
1047
+
1048
+ this.close();
1049
+ }
1050
+
1051
+ closeOnFocusOutside({ target }) {
1052
+ if (!this._isOpen) return
1053
+ if (this.element.contains(target)) return
1054
+
1055
+ this.close();
1056
+ }
1057
+
1058
+ // Some browser extensions like 1Password overlay elements on top of the combobox.
1059
+ // Hovering over these elements emits a click event for some reason.
1060
+ // These events don't contain any telling information, so we use `_withinElementBounds`
1061
+ // as an alternative to check whether the click is legitimate.
1062
+ _withinElementBounds(event) {
1063
+ const { left, right, top, bottom } = this.element.getBoundingClientRect();
1064
+ const { clientX, clientY } = event;
1065
+
1066
+ return clientX >= left && clientX <= right && clientY >= top && clientY <= bottom
1067
+ }
1068
+
1069
+ _openByFocusing() {
1070
+ this._actingCombobox.focus();
1071
+ }
1072
+
1073
+ _isDialogDismisser(target) {
1074
+ return target.closest("dialog") && target.role != "combobox"
1075
+ }
1076
+
1077
+ _expand() {
1078
+ if (this._preselectOnExpansion) this._preselectOption();
1079
+
1080
+ if (this._autocompletesList && this._smallViewport) {
1081
+ this._openInDialog();
1082
+ } else {
1083
+ this._openInline();
1084
+ }
1085
+
1086
+ this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
1087
+ }
1088
+
1089
+ _collapse() {
1090
+ this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1091
+
1092
+ if (this.dialogTarget.open) {
1093
+ this._closeInDialog();
1094
+ } else {
1095
+ this._closeInline();
1096
+ }
1097
+ }
1098
+
1099
+ _openInDialog() {
1100
+ this._moveArtifactsToDialog();
1101
+ this._preventFocusingComboboxAfterClosingDialog();
1102
+ this._preventBodyScroll();
1103
+ this.dialogTarget.showModal();
1104
+ }
1105
+
1106
+ _openInline() {
1107
+ this.listboxTarget.hidden = false;
1108
+ }
1109
+
1110
+ _closeInDialog() {
1111
+ this._moveArtifactsInline();
1112
+ this.dialogTarget.close();
1113
+ this._restoreBodyScroll();
1114
+ this._actingCombobox.scrollIntoView({ block: "center" });
1115
+ }
1116
+
1117
+ _closeInline() {
1118
+ this.listboxTarget.hidden = true;
1119
+ }
1120
+
1121
+ _preventBodyScroll() {
1122
+ disableBodyScroll(this.dialogListboxTarget);
1123
+ }
1124
+
1125
+ _restoreBodyScroll() {
1126
+ enableBodyScroll(this.dialogListboxTarget);
1127
+ }
1128
+
1129
+ get _isOpen() {
1130
+ return this.expandedValue
1131
+ }
1132
+
1133
+ get _preselectOnExpansion() {
1134
+ return !this._isAsync // async comboboxes preselect based on callbacks
1135
+ }
1136
+ };
1137
+
1138
+ Combobox.Validity = Base => class extends Base {
1139
+ _markValid() {
1140
+ if (this._valueIsInvalid) return
1141
+
1142
+ if (this.hasInvalidClass) {
1143
+ this.comboboxTarget.classList.remove(this.invalidClass);
1144
+ }
1145
+
1146
+ this.comboboxTarget.removeAttribute("aria-invalid");
1147
+ this.comboboxTarget.removeAttribute("aria-errormessage");
1148
+ }
1149
+
1150
+ _markInvalid() {
1151
+ if (this._valueIsValid) return
1152
+
1153
+ if (this.hasInvalidClass) {
1154
+ this.comboboxTarget.classList.add(this.invalidClass);
1155
+ }
1156
+
1157
+ this.comboboxTarget.setAttribute("aria-invalid", true);
1158
+ this.comboboxTarget.setAttribute("aria-errormessage", `Please select a valid option for ${this.comboboxTarget.name}`);
1159
+ }
1160
+
1161
+ get _valueIsValid() {
1162
+ return !this._valueIsInvalid
1163
+ }
1164
+
1165
+ get _valueIsInvalid() {
1166
+ const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1167
+ return isRequiredAndEmpty
1168
+ }
1169
+ };
1170
+
1171
+ window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0; // ms, for testing purposes
1172
+
1173
+ const concerns = [
1174
+ Controller,
1175
+ Combobox.Actors,
1176
+ Combobox.AsyncLoading,
1177
+ Combobox.Autocomplete,
1178
+ Combobox.Dialog,
1179
+ Combobox.Filtering,
1180
+ Combobox.Navigation,
1181
+ Combobox.NewOptions,
1182
+ Combobox.Options,
1183
+ Combobox.Selection,
1184
+ Combobox.Toggle,
1185
+ Combobox.Validity
1186
+ ];
1187
+
1188
+ class HwComboboxController extends Concerns(...concerns) {
1189
+ static classes = [
1190
+ "invalid",
1191
+ "selected"
1192
+ ]
1193
+
1194
+ static targets = [
1195
+ "combobox",
1196
+ "dialog",
1197
+ "dialogCombobox",
1198
+ "dialogFocusTrap",
1199
+ "dialogListbox",
1200
+ "endOfOptionsStream",
1201
+ "handle",
1202
+ "hiddenField",
1203
+ "listbox"
1204
+ ]
1205
+
1206
+ static values = {
1207
+ asyncSrc: String,
1208
+ autocompletableAttribute: String,
1209
+ autocomplete: String,
1210
+ expanded: Boolean,
1211
+ filterableAttribute: String,
1212
+ nameWhenNew: String,
1213
+ originalName: String,
1214
+ prefilledDisplay: String,
1215
+ smallViewportMaxWidth: String
1216
+ }
1217
+
1218
+ initialize() {
1219
+ this._initializeActors();
1220
+ this._initializeFiltering();
1221
+ }
1222
+
1223
+ connect() {
1224
+ this._connectSelection();
1225
+ this._connectListAutocomplete();
1226
+ this._connectDialog();
1227
+ }
1228
+
1229
+ disconnect() {
1230
+ this._disconnectDialog();
1231
+ }
1232
+
1233
+ expandedValueChanged() {
1234
+ if (this.expandedValue) {
1235
+ this._expand();
1236
+ } else {
1237
+ this._collapse();
1238
+ }
1239
+ }
1240
+
1241
+ async endOfOptionsStreamTargetConnected(element) {
1242
+ const inputType = element.dataset.inputType;
1243
+ const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
1244
+
1245
+ if (inputType && inputType !== "hw:lockInSelection") {
1246
+ if (delay) await sleep(delay);
1247
+ this._commitFilter({ inputType });
1248
+ } else {
1249
+ this._preselectOption();
1250
+ }
1251
+ }
1252
+ }
1253
+
1254
+ export { HwComboboxController as default };