hotwire_combobox 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/README.md +19 -15
  4. data/app/assets/config/hw_combobox_manifest.js +1 -1
  5. data/app/assets/javascripts/controllers/hw_combobox_controller.js +34 -18
  6. data/app/assets/javascripts/hotwire_combobox.esm.js +147 -72
  7. data/app/assets/javascripts/hw_combobox/helpers.js +9 -0
  8. data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +14 -4
  9. data/app/assets/javascripts/hw_combobox/models/combobox/callbacks.js +46 -0
  10. data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -4
  11. data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +19 -8
  12. data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +1 -2
  13. data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +6 -3
  14. data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +2 -2
  15. data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +4 -12
  16. data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +13 -18
  17. data/app/assets/javascripts/hw_combobox/models/combobox.js +1 -0
  18. data/app/assets/stylesheets/hotwire_combobox.css +13 -3
  19. data/app/presenters/hotwire_combobox/component/announced.rb +5 -0
  20. data/app/presenters/hotwire_combobox/component/associations.rb +18 -0
  21. data/app/presenters/hotwire_combobox/component/async.rb +6 -0
  22. data/app/presenters/hotwire_combobox/component/customizable.rb +8 -20
  23. data/app/presenters/hotwire_combobox/component/freetext.rb +26 -0
  24. data/app/presenters/hotwire_combobox/component/markup/dialog.rb +57 -0
  25. data/app/presenters/hotwire_combobox/component/markup/fieldset.rb +46 -0
  26. data/app/presenters/hotwire_combobox/component/markup/form.rb +6 -0
  27. data/app/presenters/hotwire_combobox/component/markup/handle.rb +7 -0
  28. data/app/presenters/hotwire_combobox/component/markup/hidden_field.rb +28 -0
  29. data/app/presenters/hotwire_combobox/component/markup/input.rb +44 -0
  30. data/app/presenters/hotwire_combobox/component/markup/label.rb +5 -0
  31. data/app/presenters/hotwire_combobox/component/markup/listbox.rb +14 -0
  32. data/app/presenters/hotwire_combobox/component/markup/wrapper.rb +7 -0
  33. data/app/presenters/hotwire_combobox/component/multiselect.rb +6 -0
  34. data/app/presenters/hotwire_combobox/component/paginated.rb +18 -0
  35. data/app/presenters/hotwire_combobox/component.rb +32 -398
  36. data/app/presenters/hotwire_combobox/listbox/group.rb +3 -15
  37. data/app/presenters/hotwire_combobox/listbox/item.rb +5 -12
  38. data/app/presenters/hotwire_combobox/listbox/option.rb +3 -19
  39. data/app/views/hotwire_combobox/_component.html.erb +28 -6
  40. data/app/views/hotwire_combobox/_pagination.html.erb +3 -3
  41. data/config/hw_importmap.rb +1 -1
  42. data/lib/hotwire_combobox/helper.rb +24 -65
  43. data/lib/hotwire_combobox/platform.rb +15 -0
  44. data/lib/hotwire_combobox/version.rb +1 -1
  45. data/lib/hotwire_combobox.rb +1 -0
  46. metadata +34 -11
  47. data/app/assets/javascripts/hotwire_combobox.umd.js +0 -1769
  48. data/app/views/hotwire_combobox/combobox/_dialog.html.erb +0 -9
  49. data/app/views/hotwire_combobox/combobox/_hidden_field.html.erb +0 -4
  50. data/app/views/hotwire_combobox/combobox/_input.html.erb +0 -2
  51. data/app/views/hotwire_combobox/combobox/_paginated_listbox.html.erb +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fb0eaf3c6593f518473d24ffdb931dc2519adc97c74eb4539055920c3ffd5ef9
4
- data.tar.gz: 888c826e85dc6e77b601c89fc4a573a3f45706e51203aadc7edc14b431a50245
3
+ metadata.gz: 01df7da888bb6651172b141161fcf58610b9c1e1e28e8f20a3bee7d880a9f6cb
4
+ data.tar.gz: 3ae59ccce1a2782b49759d2797244d8b8084996a8f115b46ac32f10fc22eab39
5
5
  SHA512:
6
- metadata.gz: 5c2725f7a89aff33a4fa649e37f03f363c28fab51ca930337593897068c5c60732568f8fb6b43e8c7f9cdc9309fdea9dd55298baed2774c50dc0e6041c59b989
7
- data.tar.gz: b63c3b6031f3d74190115f5f01152085491125bc5ee0a721c3c1cb0be493993f3dec83b8132424bd471b93ff36f6b248f7f27d0abb34a3821959b6b9a2900fe7
6
+ metadata.gz: 11e979427daf0c338006be0ab69732fcd4ca7f4860319b54e003730341419b1845069638acab2adaae1c45ecc961cc5216c30791e567cb0abb1dd6fc326ad794
7
+ data.tar.gz: 9f05dafd9e46f000641a86f11d9d223df4264d4f53b131f6f9be347bdb8356e4800eae22719d99c4b20bb665ce437efac27941ada324e725087801cd6fa5414e
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2024 Jose Farias
1
+ Copyright 2025 Jose Farias
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -24,7 +24,7 @@ Finally, configure your assets:
24
24
 
25
25
  ### Configuring JS
26
26
 
27
- Before continuing, you should know whether your app is using importmaps or JS bundling in your asset pipeline.
27
+ Before continuing, you should know whether your app is using importmaps or JS bundling.
28
28
 
29
29
  #### Importmaps
30
30
 
@@ -35,28 +35,30 @@ In `app/javascript/controllers/index.js` you should have one of the following:
35
35
  Either,
36
36
 
37
37
  ```js
38
- import { application } from "controllers/application" // or equivalent
38
+ import { application } from "controllers/application"
39
39
  import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
40
-
41
40
  eagerLoadControllersFrom("controllers", application)
42
41
  ```
43
42
 
44
43
  Or,
45
44
 
46
45
  ```js
47
- import { application } from "controllers/application" // or equivalent
46
+ import { application } from "controllers/application"
48
47
  import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading"
49
-
50
48
  lazyLoadControllersFrom("controllers", application)
51
49
  ```
52
50
 
53
- Or,
51
+ Alternatively, modify `app/javascript/controllers/application.js` as follows:
54
52
 
55
53
  ```js
56
- import { application } from "controllers/application" // or equivalent
54
+ import { Application } from "@hotwired/stimulus"
55
+ const application = Application.start()
57
56
 
57
+ // Add the following two lines:
58
58
  import HwComboboxController from "controllers/hw_combobox_controller"
59
59
  application.register("hw-combobox", HwComboboxController)
60
+
61
+ export { application }
60
62
  ```
61
63
 
62
64
  #### JS bundling (esbuild, rollup, etc)
@@ -71,13 +73,17 @@ yarn add @josefarias/hotwire_combobox
71
73
  npm install @josefarias/hotwire_combobox
72
74
  ```
73
75
 
74
- Then, register the library's stimulus controller in `app/javascript/controllers/index.js` as follows:
76
+ Then, register the library's stimulus controller in `app/javascript/controllers/application.js` as follows:
75
77
 
76
78
  ```js
77
- import { application } from "./application" // or equivalent
79
+ import { Application } from "@hotwired/stimulus"
80
+ const application = Application.start()
78
81
 
82
+ // Add the following two lines:
79
83
  import HwComboboxController from "@josefarias/hotwire_combobox"
80
84
  application.register("hw-combobox", HwComboboxController)
85
+
86
+ export { application }
81
87
  ```
82
88
 
83
89
  > [!WARNING]
@@ -85,7 +91,7 @@ application.register("hw-combobox", HwComboboxController)
85
91
 
86
92
  ### Configuring CSS
87
93
 
88
- This library comes with optional default styles. Follow the instructions below to include them in your app.
94
+ This library comes with customizable default styles. Follow the instructions below to include them in your app.
89
95
 
90
96
  Read the [docs section](#Docs) for instructions on styling the combobox yourself.
91
97
 
@@ -118,11 +124,9 @@ This gem follows the [APG combobox pattern guidelines](https://www.w3.org/WAI/AR
118
124
 
119
125
  These are the exceptions:
120
126
 
121
- 1. Users cannot manipulate the combobox while it's closed. As long as the combobox is focused, the listbox is shown.
122
- 2. The escape key closes the listbox and blurs the combobox. It does not clear the combobox.
123
- 3. The listbox has wrap-around selection. That is, pressing `Up Arrow` when the user is on the first option will select the last option. And pressing `Down Arrow` when on the last option will select the first option. In paginated comboboxes, the first and last options refer to the currently available options. More options may be loaded after navigating to the last currently available option.
124
- 4. It is possible to have an unlabled combobox, as that responsibility is delegated to the implementing user.
125
- 5. There are currently [no APG guidelines](https://github.com/w3c/aria-practices/issues/1512) for a multiselect combobox. We've introduced some mechanisms to make the experience accessible, like announcing multi-selections via a live region. But we'd welcome feedback on how to make it better until official guidelines are available.
127
+ 1. The listbox has wrap-around selection. That is, pressing `Up Arrow` when the user is on the first option will select the last option. And pressing `Down Arrow` when on the last option will select the first option. In paginated comboboxes, the first and last options refer to the currently available options. More options may be loaded after navigating to the last currently available option.
128
+ 2. It is possible to have an unlabeled combobox, as that responsibility is delegated to the implementing user.
129
+ 3. There are currently [no APG guidelines](https://github.com/w3c/aria-practices/issues/1512) for a multiselect combobox. We've introduced some mechanisms to make the experience accessible, like announcing multi-selections via a live region. But we'd welcome feedback on how to make it better until official guidelines are available.
126
130
 
127
131
  It should be noted none of the maintainers use assistive technologies in their daily lives. If you do, and you feel these exceptions are detrimental to your ability to use the component, or if you find an undocumented exception, please [open a GitHub issue](https://github.com/josefarias/hotwire_combobox/issues). We'll get it sorted.
128
132
 
@@ -1,2 +1,2 @@
1
1
  //= link_directory ../stylesheets .css
2
- //= link_tree ../javascripts .js
2
+ //= link hotwire_combobox.esm.js
@@ -1,5 +1,6 @@
1
1
  import Combobox from "hw_combobox/models/combobox"
2
2
  import { Concerns, sleep } from "hw_combobox/helpers"
3
+ import { nextRepaint } from "hw_combobox/helpers"
3
4
  import { Controller } from "@hotwired/stimulus"
4
5
 
5
6
  window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0 // ms, for testing purposes
@@ -10,6 +11,7 @@ const concerns = [
10
11
  Combobox.Announcements,
11
12
  Combobox.AsyncLoading,
12
13
  Combobox.Autocomplete,
14
+ Combobox.Callbacks,
13
15
  Combobox.Dialog,
14
16
  Combobox.Events,
15
17
  Combobox.Filtering,
@@ -24,20 +26,13 @@ const concerns = [
24
26
  ]
25
27
 
26
28
  export default class HwComboboxController extends Concerns(...concerns) {
27
- static classes = [
28
- "invalid",
29
- "selected"
30
- ]
31
-
29
+ static classes = [ "invalid", "selected" ]
32
30
  static targets = [
33
31
  "announcer",
34
32
  "combobox",
35
33
  "chipDismisser",
36
34
  "closer",
37
- "dialog",
38
- "dialogCombobox",
39
- "dialogFocusTrap",
40
- "dialogListbox",
35
+ "dialog", "dialogCombobox", "dialogFocusTrap", "dialogListbox",
41
36
  "endOfOptionsStream",
42
37
  "handle",
43
38
  "hiddenField",
@@ -61,6 +56,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
61
56
  initialize() {
62
57
  this._initializeActors()
63
58
  this._initializeFiltering()
59
+ this._initializeCallbacks()
64
60
  }
65
61
 
66
62
  connect() {
@@ -87,23 +83,43 @@ export default class HwComboboxController extends Concerns(...concerns) {
87
83
  }
88
84
 
89
85
  async endOfOptionsStreamTargetConnected(element) {
90
- const inputType = element.dataset.inputType
91
- const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY
86
+ if (element.dataset.callbackId) {
87
+ this._runCallback(element)
88
+ } else {
89
+ this._preselectSingle()
90
+ }
91
+ }
92
92
 
93
- this._resetMultiselectionMarks()
93
+ async _runCallback(element) {
94
+ const callbackId = element.dataset.callbackId
95
+
96
+ if (this._callbackAttemptsExceeded(callbackId)) {
97
+ return this._dequeueCallback(callbackId)
98
+ } else {
99
+ this._recordCallbackAttempt(callbackId)
100
+ }
101
+
102
+ if (this._isNextCallback(callbackId)) {
103
+ const inputType = element.dataset.inputType
104
+ const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY
94
105
 
95
- if (inputType === "hw:multiselectSync") {
96
- this.openByFocusing()
97
- } else if (inputType && inputType !== "hw:lockInSelection") {
98
106
  if (delay) await sleep(delay)
99
- this._selectOnQuery(inputType)
107
+ this._dequeueCallback(callbackId)
108
+ this._resetMultiselectionMarks()
109
+
110
+ if (inputType === "hw:multiselectSync") {
111
+ this.open()
112
+ } else if (inputType !== "hw:lockInSelection") {
113
+ this._selectOnQuery(inputType)
114
+ }
100
115
  } else {
101
- this._preselectSingle()
116
+ await nextRepaint()
117
+ this._runCallback(element)
102
118
  }
103
119
  }
104
120
 
105
121
  closerTargetConnected() {
106
- this._closeAndBlur("hw:asyncCloser")
122
+ this.close("hw:asyncCloser")
107
123
  }
108
124
 
109
125
  // Use +_printStack+ for debugging purposes
@@ -1,5 +1,5 @@
1
1
  /*!
2
- HotwireCombobox 0.3.1
2
+ HotwireCombobox 0.4.0
3
3
  */
4
4
  import { Controller } from '@hotwired/stimulus';
5
5
 
@@ -150,6 +150,15 @@ function nextEventLoopTick() {
150
150
  return new Promise((resolve) => setTimeout(() => resolve(), 0))
151
151
  }
152
152
 
153
+ function randomUUID() {
154
+ const uuidPattern = "10000000-1000-4000-8000-100000000000";
155
+
156
+ return uuidPattern.replace(/[018]/g, (match) => {
157
+ const randomByte = crypto.getRandomValues(new Uint8Array(1))[0];
158
+ return (match ^ (randomByte & 15) >> (match / 4)).toString(16)
159
+ })
160
+ }
161
+
153
162
  Combobox.Autocomplete = Base => class extends Base {
154
163
  _connectListAutocomplete() {
155
164
  if (!this._autocompletesList) {
@@ -157,23 +166,33 @@ Combobox.Autocomplete = Base => class extends Base {
157
166
  }
158
167
  }
159
168
 
160
- _replaceFullQueryWithAutocompletedValue(option) {
169
+ _hardAutocomplete(option) {
170
+ const typedValue = this._typedQuery;
161
171
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
162
172
 
163
173
  this._fullQuery = autocompletedValue;
164
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
174
+
175
+ if (this._isAutocompletableWith(typedValue, autocompletedValue)) {
176
+ this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
177
+ } else {
178
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
179
+ }
165
180
  }
166
181
 
167
- _autocompleteMissingPortion(option) {
182
+ _softAutocomplete(option) {
168
183
  const typedValue = this._typedQuery;
169
184
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
170
185
 
171
- if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
186
+ if (this._isAutocompletableWith(typedValue, autocompletedValue)) {
172
187
  this._fullQuery = autocompletedValue;
173
188
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
174
189
  }
175
190
  }
176
191
 
192
+ _isAutocompletableWith(typedValue, autocompletedValue) {
193
+ return this._autocompletesInline && startsWith(autocompletedValue, typedValue)
194
+ }
195
+
177
196
  // +visuallyHideListbox+ hides the listbox from the user,
178
197
  // but makes it still searchable by JS.
179
198
  _visuallyHideListbox() {
@@ -204,6 +223,50 @@ Combobox.Autocomplete = Base => class extends Base {
204
223
  }
205
224
  };
206
225
 
226
+ const MAX_CALLBACK_ATTEMPTS = 3;
227
+
228
+ Combobox.Callbacks = Base => class extends Base {
229
+ _initializeCallbacks() {
230
+ this.callbackQueue = [];
231
+ this.callbackExecutionAttempts = {};
232
+ }
233
+
234
+ _enqueueCallback() {
235
+ const callbackId = randomUUID();
236
+ this.callbackQueue.push(callbackId);
237
+ return callbackId
238
+ }
239
+
240
+ _isNextCallback(callbackId) {
241
+ return this._nextCallback === callbackId
242
+ }
243
+
244
+ _callbackAttemptsExceeded(callbackId) {
245
+ return this._callbackAttempts(callbackId) > MAX_CALLBACK_ATTEMPTS
246
+ }
247
+
248
+ _callbackAttempts(callbackId) {
249
+ return this.callbackExecutionAttempts[callbackId] || 0
250
+ }
251
+
252
+ _recordCallbackAttempt(callbackId) {
253
+ this.callbackExecutionAttempts[callbackId] = this._callbackAttempts(callbackId) + 1;
254
+ }
255
+
256
+ _dequeueCallback(callbackId) {
257
+ this.callbackQueue = this.callbackQueue.filter(id => id !== callbackId);
258
+ this._forgetCallbackExecutionAttempts(callbackId);
259
+ }
260
+
261
+ _forgetCallbackExecutionAttempts(callbackId) {
262
+ delete this.callbackExecutionAttempts[callbackId];
263
+ }
264
+
265
+ get _nextCallback() {
266
+ return this.callbackQueue[0]
267
+ }
268
+ };
269
+
207
270
  Combobox.Dialog = Base => class extends Base {
208
271
  rerouteListboxStreamToDialog({ detail: { newStream } }) {
209
272
  if (newStream.target == this.listboxTarget.id && this._dialogIsOpen) {
@@ -243,10 +306,7 @@ Combobox.Dialog = Base => class extends Base {
243
306
 
244
307
  _resizeDialog = () => {
245
308
  if (window.visualViewport) {
246
- this.dialogTarget.style.setProperty(
247
- "--hw-visual-viewport-height",
248
- `${window.visualViewport.height}px`
249
- );
309
+ this.dialogTarget.style.setProperty("--hw-visual-viewport-height", `${window.visualViewport.height}px`);
250
310
  }
251
311
  }
252
312
 
@@ -584,6 +644,15 @@ async function post(url, options) {
584
644
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
585
645
 
586
646
  Combobox.Filtering = Base => class extends Base {
647
+ prepareToFilter({ key }) {
648
+ const intendsToFilter = key.match(/^[a-zA-Z0-9]$|^ArrowDown$/);
649
+
650
+ if (this._isClosed && intendsToFilter) {
651
+ this.open(); // `.open()` sets the appropriate state so the combobox knows it’s open.
652
+ this._expand(); // `.open()` will call `._expand()` via stimulus callbacks, but we’re calling it inline so it happens immediately.
653
+ }
654
+ }
655
+
587
656
  filterAndSelect({ inputType }) {
588
657
  this._filter(inputType);
589
658
 
@@ -592,6 +661,12 @@ Combobox.Filtering = Base => class extends Base {
592
661
  }
593
662
  }
594
663
 
664
+ clear(event) {
665
+ this._clearQuery();
666
+ this.chipDismisserTargets.forEach(el => el.click());
667
+ if (event && !event.defaultPrevented) event.target.focus();
668
+ }
669
+
595
670
  _initializeFiltering() {
596
671
  this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
597
672
  }
@@ -614,19 +689,15 @@ Combobox.Filtering = Base => class extends Base {
614
689
  const query = {
615
690
  q: this._fullQuery,
616
691
  input_type: inputType,
617
- for_id: this.element.dataset.asyncId
692
+ for_id: this.element.dataset.asyncId,
693
+ callback_id: this._enqueueCallback()
618
694
  };
619
695
 
620
696
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
621
697
  }
622
698
 
623
699
  _filterSync() {
624
- this._allFilterableOptionElements.forEach(
625
- applyFilter(
626
- this._fullQuery,
627
- { matching: this.filterableAttributeValue }
628
- )
629
- );
700
+ this._allFilterableOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
630
701
  }
631
702
 
632
703
  _clearQuery() {
@@ -635,7 +706,7 @@ Combobox.Filtering = Base => class extends Base {
635
706
  }
636
707
 
637
708
  _markQueried() {
638
- this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
709
+ this._forAllComboboxes(el => el.toggleAttribute("data-queried", this._isQueried));
639
710
  }
640
711
 
641
712
  get _isQueried() {
@@ -708,8 +779,7 @@ Combobox.FormField = Base => class extends Base {
708
779
 
709
780
  get _hasEmptyFieldValue() {
710
781
  if (this._isMultiselect) {
711
- return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
712
- this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
782
+ return this.hiddenFieldTarget.dataset.valueForMultiselect == "" || this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
713
783
  } else {
714
784
  return this.hiddenFieldTarget.value === ""
715
785
  }
@@ -751,7 +821,7 @@ Combobox.Multiselect = Base => class extends Base {
751
821
  currentTarget.closest("[data-hw-combobox-chip]").remove();
752
822
 
753
823
  if (!this._isSmallViewport) {
754
- this.openByFocusing();
824
+ this.open();
755
825
  }
756
826
 
757
827
  this._announceToScreenReader(display, "removed");
@@ -776,7 +846,7 @@ Combobox.Multiselect = Base => class extends Base {
776
846
  cancel(event);
777
847
  },
778
848
  Escape: (event) => {
779
- this.openByFocusing();
849
+ this.open();
780
850
  cancel(event);
781
851
  }
782
852
  }
@@ -793,13 +863,16 @@ Combobox.Multiselect = Base => class extends Base {
793
863
 
794
864
  this._beforeClearingMultiselectQuery(async (display, value) => {
795
865
  this._fullQuery = "";
866
+
796
867
  this._filter("hw:multiselectSync");
797
868
  this._requestChips(value);
798
869
  this._addToFieldValue(value);
870
+
799
871
  if (shouldReopen) {
800
872
  await nextRepaint();
801
- this.openByFocusing();
873
+ this.open();
802
874
  }
875
+
803
876
  this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.");
804
877
  });
805
878
  }
@@ -915,11 +988,11 @@ Combobox.Navigation = Base => class extends Base {
915
988
  cancel(event);
916
989
  },
917
990
  Enter: (event) => {
918
- this._closeAndBlur("hw:keyHandler:enter");
991
+ this.close("hw:keyHandler:enter");
919
992
  cancel(event);
920
993
  },
921
994
  Escape: (event) => {
922
- this._closeAndBlur("hw:keyHandler:escape");
995
+ this._isOpen ? this.close("hw:keyHandler:escape") : this._clearQuery();
923
996
  cancel(event);
924
997
  },
925
998
  Backspace: (event) => {
@@ -1028,7 +1101,7 @@ Combobox.Options = Base => class extends Base {
1028
1101
  Combobox.Selection = Base => class extends Base {
1029
1102
  selectOnClick({ currentTarget, inputType }) {
1030
1103
  this._forceSelectionAndFilter(currentTarget, inputType);
1031
- this._closeAndBlur("hw:optionRoleClick");
1104
+ this.close("hw:optionRoleClick");
1032
1105
  }
1033
1106
 
1034
1107
  _connectSelection() {
@@ -1044,9 +1117,9 @@ Combobox.Selection = Base => class extends Base {
1044
1117
  } else if (isDeleteEvent({ inputType: inputType })) {
1045
1118
  this._deselect();
1046
1119
  } else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
1047
- this._selectAndAutocompleteMissingPortion(this._ensurableOption);
1120
+ this._select(this._ensurableOption, this._softAutocomplete.bind(this));
1048
1121
  } else if (this._isOpen && this._visibleOptionElements[0]) {
1049
- this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
1122
+ this._select(this._visibleOptionElements[0], this._softAutocomplete.bind(this));
1050
1123
  } else if (this._isOpen) {
1051
1124
  this._resetOptionsAndNotify();
1052
1125
  this._markInvalid();
@@ -1115,21 +1188,13 @@ Combobox.Selection = Base => class extends Base {
1115
1188
  }
1116
1189
  }
1117
1190
 
1118
- _selectAndAutocompleteMissingPortion(option) {
1119
- this._select(option, this._autocompleteMissingPortion.bind(this));
1120
- }
1121
-
1122
- _selectAndAutocompleteFullQuery(option) {
1123
- this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
1124
- }
1125
-
1126
1191
  _forceSelectionAndFilter(option, inputType) {
1127
1192
  this._forceSelectionWithoutFiltering(option);
1128
1193
  this._filter(inputType);
1129
1194
  }
1130
1195
 
1131
1196
  _forceSelectionWithoutFiltering(option) {
1132
- this._selectAndAutocompleteFullQuery(option);
1197
+ this._select(option, this._hardAutocomplete.bind(this));
1133
1198
  }
1134
1199
 
1135
1200
  _lockInSelection() {
@@ -1458,10 +1523,6 @@ Combobox.Toggle = Base => class extends Base {
1458
1523
  this.expandedValue = true;
1459
1524
  }
1460
1525
 
1461
- openByFocusing() {
1462
- this._actingCombobox.focus();
1463
- }
1464
-
1465
1526
  close(inputType) {
1466
1527
  if (this._isOpen) {
1467
1528
  const shouldReopen = this._isMultiselect &&
@@ -1476,9 +1537,8 @@ Combobox.Toggle = Base => class extends Base {
1476
1537
 
1477
1538
  this.expandedValue = false;
1478
1539
 
1479
- this._dispatchSelectionEvent();
1480
-
1481
1540
  if (inputType != "hw:keyHandler:escape") {
1541
+ this._dispatchSelectionEvent();
1482
1542
  this._createChip(shouldReopen);
1483
1543
  }
1484
1544
 
@@ -1490,43 +1550,38 @@ Combobox.Toggle = Base => class extends Base {
1490
1550
 
1491
1551
  toggle() {
1492
1552
  if (this.expandedValue) {
1493
- this._closeAndBlur("hw:toggle");
1553
+ this.close("hw:toggle");
1494
1554
  } else {
1495
- this.openByFocusing();
1555
+ this.open();
1496
1556
  }
1497
1557
  }
1498
1558
 
1499
1559
  closeOnClickOutside(event) {
1500
1560
  const target = event.target;
1501
1561
 
1502
- if (!this._isOpen) return
1562
+ if (this._isClosed) return
1503
1563
  if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
1504
1564
  if (this._withinElementBounds(event)) return
1505
1565
 
1506
- this._closeAndBlur("hw:clickOutside");
1566
+ this.close("hw:clickOutside");
1507
1567
  }
1508
1568
 
1509
1569
  closeOnFocusOutside({ target }) {
1510
- if (!this._isOpen) return
1570
+ if (this._isClosed) return
1511
1571
  if (this.element.contains(target)) return
1512
1572
 
1513
- this._closeAndBlur("hw:focusOutside");
1573
+ this.close("hw:focusOutside");
1514
1574
  }
1515
1575
 
1516
1576
  clearOrToggleOnHandleClick() {
1517
1577
  if (this._isQueried) {
1518
1578
  this._clearQuery();
1519
- this._actingCombobox.focus();
1579
+ this.open();
1520
1580
  } else {
1521
1581
  this.toggle();
1522
1582
  }
1523
1583
  }
1524
1584
 
1525
- _closeAndBlur(inputType) {
1526
- this.close(inputType);
1527
- this._actingCombobox.blur();
1528
- }
1529
-
1530
1585
  // Some browser extensions like 1Password overlay elements on top of the combobox.
1531
1586
  // Hovering over these elements emits a click event for some reason.
1532
1587
  // These events don't contain any telling information, so we use `_withinElementBounds`
@@ -1573,6 +1628,7 @@ Combobox.Toggle = Base => class extends Base {
1573
1628
  this._preventFocusingComboboxAfterClosingDialog();
1574
1629
  this._preventBodyScroll();
1575
1630
  this.dialogTarget.showModal();
1631
+ this._resizeDialog();
1576
1632
  }
1577
1633
 
1578
1634
  _openInline() {
@@ -1608,6 +1664,10 @@ Combobox.Toggle = Base => class extends Base {
1608
1664
  get _isOpen() {
1609
1665
  return this.expandedValue
1610
1666
  }
1667
+
1668
+ get _isClosed() {
1669
+ return !this._isOpen
1670
+ }
1611
1671
  };
1612
1672
 
1613
1673
  Combobox.Validity = Base => class extends Base {
@@ -1657,6 +1717,7 @@ const concerns = [
1657
1717
  Combobox.Announcements,
1658
1718
  Combobox.AsyncLoading,
1659
1719
  Combobox.Autocomplete,
1720
+ Combobox.Callbacks,
1660
1721
  Combobox.Dialog,
1661
1722
  Combobox.Events,
1662
1723
  Combobox.Filtering,
@@ -1671,20 +1732,13 @@ const concerns = [
1671
1732
  ];
1672
1733
 
1673
1734
  class HwComboboxController extends Concerns(...concerns) {
1674
- static classes = [
1675
- "invalid",
1676
- "selected"
1677
- ]
1678
-
1735
+ static classes = [ "invalid", "selected" ]
1679
1736
  static targets = [
1680
1737
  "announcer",
1681
1738
  "combobox",
1682
1739
  "chipDismisser",
1683
1740
  "closer",
1684
- "dialog",
1685
- "dialogCombobox",
1686
- "dialogFocusTrap",
1687
- "dialogListbox",
1741
+ "dialog", "dialogCombobox", "dialogFocusTrap", "dialogListbox",
1688
1742
  "endOfOptionsStream",
1689
1743
  "handle",
1690
1744
  "hiddenField",
@@ -1708,6 +1762,7 @@ class HwComboboxController extends Concerns(...concerns) {
1708
1762
  initialize() {
1709
1763
  this._initializeActors();
1710
1764
  this._initializeFiltering();
1765
+ this._initializeCallbacks();
1711
1766
  }
1712
1767
 
1713
1768
  connect() {
@@ -1734,23 +1789,43 @@ class HwComboboxController extends Concerns(...concerns) {
1734
1789
  }
1735
1790
 
1736
1791
  async endOfOptionsStreamTargetConnected(element) {
1737
- const inputType = element.dataset.inputType;
1738
- const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
1792
+ if (element.dataset.callbackId) {
1793
+ this._runCallback(element);
1794
+ } else {
1795
+ this._preselectSingle();
1796
+ }
1797
+ }
1798
+
1799
+ async _runCallback(element) {
1800
+ const callbackId = element.dataset.callbackId;
1739
1801
 
1740
- this._resetMultiselectionMarks();
1802
+ if (this._callbackAttemptsExceeded(callbackId)) {
1803
+ return this._dequeueCallback(callbackId)
1804
+ } else {
1805
+ this._recordCallbackAttempt(callbackId);
1806
+ }
1807
+
1808
+ if (this._isNextCallback(callbackId)) {
1809
+ const inputType = element.dataset.inputType;
1810
+ const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY;
1741
1811
 
1742
- if (inputType === "hw:multiselectSync") {
1743
- this.openByFocusing();
1744
- } else if (inputType && inputType !== "hw:lockInSelection") {
1745
1812
  if (delay) await sleep(delay);
1746
- this._selectOnQuery(inputType);
1813
+ this._dequeueCallback(callbackId);
1814
+ this._resetMultiselectionMarks();
1815
+
1816
+ if (inputType === "hw:multiselectSync") {
1817
+ this.open();
1818
+ } else if (inputType !== "hw:lockInSelection") {
1819
+ this._selectOnQuery(inputType);
1820
+ }
1747
1821
  } else {
1748
- this._preselectSingle();
1822
+ await nextRepaint();
1823
+ this._runCallback(element);
1749
1824
  }
1750
1825
  }
1751
1826
 
1752
1827
  closerTargetConnected() {
1753
- this._closeAndBlur("hw:asyncCloser");
1828
+ this.close("hw:asyncCloser");
1754
1829
  }
1755
1830
 
1756
1831
  // Use +_printStack+ for debugging purposes