hotwire_combobox 0.1.37 → 0.1.39

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