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