hotwire_combobox 0.1.36 → 0.1.38

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