hotwire_combobox 0.3.2 → 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 (50) 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 +5 -13
  6. data/app/assets/javascripts/hotwire_combobox.esm.js +67 -66
  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 +2 -1
  10. data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -4
  11. data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +10 -6
  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/stylesheets/hotwire_combobox.css +12 -2
  18. data/app/presenters/hotwire_combobox/component/announced.rb +5 -0
  19. data/app/presenters/hotwire_combobox/component/associations.rb +18 -0
  20. data/app/presenters/hotwire_combobox/component/async.rb +6 -0
  21. data/app/presenters/hotwire_combobox/component/customizable.rb +8 -20
  22. data/app/presenters/hotwire_combobox/component/freetext.rb +26 -0
  23. data/app/presenters/hotwire_combobox/component/markup/dialog.rb +57 -0
  24. data/app/presenters/hotwire_combobox/component/markup/fieldset.rb +46 -0
  25. data/app/presenters/hotwire_combobox/component/markup/form.rb +6 -0
  26. data/app/presenters/hotwire_combobox/component/markup/handle.rb +7 -0
  27. data/app/presenters/hotwire_combobox/component/markup/hidden_field.rb +28 -0
  28. data/app/presenters/hotwire_combobox/component/markup/input.rb +44 -0
  29. data/app/presenters/hotwire_combobox/component/markup/label.rb +5 -0
  30. data/app/presenters/hotwire_combobox/component/markup/listbox.rb +14 -0
  31. data/app/presenters/hotwire_combobox/component/markup/wrapper.rb +7 -0
  32. data/app/presenters/hotwire_combobox/component/multiselect.rb +6 -0
  33. data/app/presenters/hotwire_combobox/component/paginated.rb +18 -0
  34. data/app/presenters/hotwire_combobox/component.rb +32 -398
  35. data/app/presenters/hotwire_combobox/listbox/group.rb +3 -15
  36. data/app/presenters/hotwire_combobox/listbox/item.rb +5 -12
  37. data/app/presenters/hotwire_combobox/listbox/option.rb +3 -20
  38. data/app/views/hotwire_combobox/_component.html.erb +28 -6
  39. data/app/views/hotwire_combobox/_pagination.html.erb +2 -2
  40. data/config/hw_importmap.rb +1 -1
  41. data/lib/hotwire_combobox/helper.rb +24 -65
  42. data/lib/hotwire_combobox/platform.rb +15 -0
  43. data/lib/hotwire_combobox/version.rb +1 -1
  44. data/lib/hotwire_combobox.rb +1 -0
  45. metadata +33 -11
  46. data/app/assets/javascripts/hotwire_combobox.umd.js +0 -1843
  47. data/app/views/hotwire_combobox/combobox/_dialog.html.erb +0 -9
  48. data/app/views/hotwire_combobox/combobox/_hidden_field.html.erb +0 -4
  49. data/app/views/hotwire_combobox/combobox/_input.html.erb +0 -2
  50. 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: 5bdfc61a78393aa3f25828d5a09b0146038b1e4c74aeeb1189eb3d9cb3da1bc4
4
- data.tar.gz: be98cd6da831d77f7401127bfc6409cd93a07dbe13d57b4198427ca0d662e146
3
+ metadata.gz: 01df7da888bb6651172b141161fcf58610b9c1e1e28e8f20a3bee7d880a9f6cb
4
+ data.tar.gz: 3ae59ccce1a2782b49759d2797244d8b8084996a8f115b46ac32f10fc22eab39
5
5
  SHA512:
6
- metadata.gz: 9a6556e4c39ae6b6ae721e1e0fc587c5c1224365ca5e1feffdf32750b1a69085e3ec531ea8eafc3857cbc841d12c2a361321a295bbf90197ce3054547fe1abed
7
- data.tar.gz: e4d81183deceede7b786abe9732d12c1ce76c2399ae8e44d4f3c4ebfa6822fef041b661358ea0ce254200985dd3643a056b50fa992e7ad94f3e5d6522ceaa7f4
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
@@ -26,20 +26,13 @@ const concerns = [
26
26
  ]
27
27
 
28
28
  export default class HwComboboxController extends Concerns(...concerns) {
29
- static classes = [
30
- "invalid",
31
- "selected"
32
- ]
33
-
29
+ static classes = [ "invalid", "selected" ]
34
30
  static targets = [
35
31
  "announcer",
36
32
  "combobox",
37
33
  "chipDismisser",
38
34
  "closer",
39
- "dialog",
40
- "dialogCombobox",
41
- "dialogFocusTrap",
42
- "dialogListbox",
35
+ "dialog", "dialogCombobox", "dialogFocusTrap", "dialogListbox",
43
36
  "endOfOptionsStream",
44
37
  "handle",
45
38
  "hiddenField",
@@ -101,8 +94,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
101
94
  const callbackId = element.dataset.callbackId
102
95
 
103
96
  if (this._callbackAttemptsExceeded(callbackId)) {
104
- this._dequeueCallback(callbackId)
105
- return
97
+ return this._dequeueCallback(callbackId)
106
98
  } else {
107
99
  this._recordCallbackAttempt(callbackId)
108
100
  }
@@ -116,7 +108,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
116
108
  this._resetMultiselectionMarks()
117
109
 
118
110
  if (inputType === "hw:multiselectSync") {
119
- this.openByFocusing()
111
+ this.open()
120
112
  } else if (inputType !== "hw:lockInSelection") {
121
113
  this._selectOnQuery(inputType)
122
114
  }
@@ -127,7 +119,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
127
119
  }
128
120
 
129
121
  closerTargetConnected() {
130
- this._closeAndBlur("hw:asyncCloser")
122
+ this.close("hw:asyncCloser")
131
123
  }
132
124
 
133
125
  // Use +_printStack+ for debugging purposes
@@ -1,5 +1,5 @@
1
1
  /*!
2
- HotwireCombobox 0.3.2
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() {
@@ -213,7 +232,7 @@ Combobox.Callbacks = Base => class extends Base {
213
232
  }
214
233
 
215
234
  _enqueueCallback() {
216
- const callbackId = crypto.randomUUID();
235
+ const callbackId = randomUUID();
217
236
  this.callbackQueue.push(callbackId);
218
237
  return callbackId
219
238
  }
@@ -287,10 +306,7 @@ Combobox.Dialog = Base => class extends Base {
287
306
 
288
307
  _resizeDialog = () => {
289
308
  if (window.visualViewport) {
290
- this.dialogTarget.style.setProperty(
291
- "--hw-visual-viewport-height",
292
- `${window.visualViewport.height}px`
293
- );
309
+ this.dialogTarget.style.setProperty("--hw-visual-viewport-height", `${window.visualViewport.height}px`);
294
310
  }
295
311
  }
296
312
 
@@ -628,6 +644,15 @@ async function post(url, options) {
628
644
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
629
645
 
630
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
+
631
656
  filterAndSelect({ inputType }) {
632
657
  this._filter(inputType);
633
658
 
@@ -672,12 +697,7 @@ Combobox.Filtering = Base => class extends Base {
672
697
  }
673
698
 
674
699
  _filterSync() {
675
- this._allFilterableOptionElements.forEach(
676
- applyFilter(
677
- this._fullQuery,
678
- { matching: this.filterableAttributeValue }
679
- )
680
- );
700
+ this._allFilterableOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
681
701
  }
682
702
 
683
703
  _clearQuery() {
@@ -759,8 +779,7 @@ Combobox.FormField = Base => class extends Base {
759
779
 
760
780
  get _hasEmptyFieldValue() {
761
781
  if (this._isMultiselect) {
762
- return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
763
- this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
782
+ return this.hiddenFieldTarget.dataset.valueForMultiselect == "" || this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
764
783
  } else {
765
784
  return this.hiddenFieldTarget.value === ""
766
785
  }
@@ -802,7 +821,7 @@ Combobox.Multiselect = Base => class extends Base {
802
821
  currentTarget.closest("[data-hw-combobox-chip]").remove();
803
822
 
804
823
  if (!this._isSmallViewport) {
805
- this.openByFocusing();
824
+ this.open();
806
825
  }
807
826
 
808
827
  this._announceToScreenReader(display, "removed");
@@ -827,7 +846,7 @@ Combobox.Multiselect = Base => class extends Base {
827
846
  cancel(event);
828
847
  },
829
848
  Escape: (event) => {
830
- this.openByFocusing();
849
+ this.open();
831
850
  cancel(event);
832
851
  }
833
852
  }
@@ -844,13 +863,16 @@ Combobox.Multiselect = Base => class extends Base {
844
863
 
845
864
  this._beforeClearingMultiselectQuery(async (display, value) => {
846
865
  this._fullQuery = "";
866
+
847
867
  this._filter("hw:multiselectSync");
848
868
  this._requestChips(value);
849
869
  this._addToFieldValue(value);
870
+
850
871
  if (shouldReopen) {
851
872
  await nextRepaint();
852
- this.openByFocusing();
873
+ this.open();
853
874
  }
875
+
854
876
  this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.");
855
877
  });
856
878
  }
@@ -966,11 +988,11 @@ Combobox.Navigation = Base => class extends Base {
966
988
  cancel(event);
967
989
  },
968
990
  Enter: (event) => {
969
- this._closeAndBlur("hw:keyHandler:enter");
991
+ this.close("hw:keyHandler:enter");
970
992
  cancel(event);
971
993
  },
972
994
  Escape: (event) => {
973
- this._closeAndBlur("hw:keyHandler:escape");
995
+ this._isOpen ? this.close("hw:keyHandler:escape") : this._clearQuery();
974
996
  cancel(event);
975
997
  },
976
998
  Backspace: (event) => {
@@ -1079,7 +1101,7 @@ Combobox.Options = Base => class extends Base {
1079
1101
  Combobox.Selection = Base => class extends Base {
1080
1102
  selectOnClick({ currentTarget, inputType }) {
1081
1103
  this._forceSelectionAndFilter(currentTarget, inputType);
1082
- this._closeAndBlur("hw:optionRoleClick");
1104
+ this.close("hw:optionRoleClick");
1083
1105
  }
1084
1106
 
1085
1107
  _connectSelection() {
@@ -1095,9 +1117,9 @@ Combobox.Selection = Base => class extends Base {
1095
1117
  } else if (isDeleteEvent({ inputType: inputType })) {
1096
1118
  this._deselect();
1097
1119
  } else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
1098
- this._selectAndAutocompleteMissingPortion(this._ensurableOption);
1120
+ this._select(this._ensurableOption, this._softAutocomplete.bind(this));
1099
1121
  } else if (this._isOpen && this._visibleOptionElements[0]) {
1100
- this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
1122
+ this._select(this._visibleOptionElements[0], this._softAutocomplete.bind(this));
1101
1123
  } else if (this._isOpen) {
1102
1124
  this._resetOptionsAndNotify();
1103
1125
  this._markInvalid();
@@ -1166,21 +1188,13 @@ Combobox.Selection = Base => class extends Base {
1166
1188
  }
1167
1189
  }
1168
1190
 
1169
- _selectAndAutocompleteMissingPortion(option) {
1170
- this._select(option, this._autocompleteMissingPortion.bind(this));
1171
- }
1172
-
1173
- _selectAndAutocompleteFullQuery(option) {
1174
- this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
1175
- }
1176
-
1177
1191
  _forceSelectionAndFilter(option, inputType) {
1178
1192
  this._forceSelectionWithoutFiltering(option);
1179
1193
  this._filter(inputType);
1180
1194
  }
1181
1195
 
1182
1196
  _forceSelectionWithoutFiltering(option) {
1183
- this._selectAndAutocompleteFullQuery(option);
1197
+ this._select(option, this._hardAutocomplete.bind(this));
1184
1198
  }
1185
1199
 
1186
1200
  _lockInSelection() {
@@ -1509,10 +1523,6 @@ Combobox.Toggle = Base => class extends Base {
1509
1523
  this.expandedValue = true;
1510
1524
  }
1511
1525
 
1512
- openByFocusing() {
1513
- this._actingCombobox.focus();
1514
- }
1515
-
1516
1526
  close(inputType) {
1517
1527
  if (this._isOpen) {
1518
1528
  const shouldReopen = this._isMultiselect &&
@@ -1527,9 +1537,8 @@ Combobox.Toggle = Base => class extends Base {
1527
1537
 
1528
1538
  this.expandedValue = false;
1529
1539
 
1530
- this._dispatchSelectionEvent();
1531
-
1532
1540
  if (inputType != "hw:keyHandler:escape") {
1541
+ this._dispatchSelectionEvent();
1533
1542
  this._createChip(shouldReopen);
1534
1543
  }
1535
1544
 
@@ -1541,43 +1550,38 @@ Combobox.Toggle = Base => class extends Base {
1541
1550
 
1542
1551
  toggle() {
1543
1552
  if (this.expandedValue) {
1544
- this._closeAndBlur("hw:toggle");
1553
+ this.close("hw:toggle");
1545
1554
  } else {
1546
- this.openByFocusing();
1555
+ this.open();
1547
1556
  }
1548
1557
  }
1549
1558
 
1550
1559
  closeOnClickOutside(event) {
1551
1560
  const target = event.target;
1552
1561
 
1553
- if (!this._isOpen) return
1562
+ if (this._isClosed) return
1554
1563
  if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
1555
1564
  if (this._withinElementBounds(event)) return
1556
1565
 
1557
- this._closeAndBlur("hw:clickOutside");
1566
+ this.close("hw:clickOutside");
1558
1567
  }
1559
1568
 
1560
1569
  closeOnFocusOutside({ target }) {
1561
- if (!this._isOpen) return
1570
+ if (this._isClosed) return
1562
1571
  if (this.element.contains(target)) return
1563
1572
 
1564
- this._closeAndBlur("hw:focusOutside");
1573
+ this.close("hw:focusOutside");
1565
1574
  }
1566
1575
 
1567
1576
  clearOrToggleOnHandleClick() {
1568
1577
  if (this._isQueried) {
1569
1578
  this._clearQuery();
1570
- this._actingCombobox.focus();
1579
+ this.open();
1571
1580
  } else {
1572
1581
  this.toggle();
1573
1582
  }
1574
1583
  }
1575
1584
 
1576
- _closeAndBlur(inputType) {
1577
- this.close(inputType);
1578
- this._actingCombobox.blur();
1579
- }
1580
-
1581
1585
  // Some browser extensions like 1Password overlay elements on top of the combobox.
1582
1586
  // Hovering over these elements emits a click event for some reason.
1583
1587
  // These events don't contain any telling information, so we use `_withinElementBounds`
@@ -1624,6 +1628,7 @@ Combobox.Toggle = Base => class extends Base {
1624
1628
  this._preventFocusingComboboxAfterClosingDialog();
1625
1629
  this._preventBodyScroll();
1626
1630
  this.dialogTarget.showModal();
1631
+ this._resizeDialog();
1627
1632
  }
1628
1633
 
1629
1634
  _openInline() {
@@ -1659,6 +1664,10 @@ Combobox.Toggle = Base => class extends Base {
1659
1664
  get _isOpen() {
1660
1665
  return this.expandedValue
1661
1666
  }
1667
+
1668
+ get _isClosed() {
1669
+ return !this._isOpen
1670
+ }
1662
1671
  };
1663
1672
 
1664
1673
  Combobox.Validity = Base => class extends Base {
@@ -1723,20 +1732,13 @@ const concerns = [
1723
1732
  ];
1724
1733
 
1725
1734
  class HwComboboxController extends Concerns(...concerns) {
1726
- static classes = [
1727
- "invalid",
1728
- "selected"
1729
- ]
1730
-
1735
+ static classes = [ "invalid", "selected" ]
1731
1736
  static targets = [
1732
1737
  "announcer",
1733
1738
  "combobox",
1734
1739
  "chipDismisser",
1735
1740
  "closer",
1736
- "dialog",
1737
- "dialogCombobox",
1738
- "dialogFocusTrap",
1739
- "dialogListbox",
1741
+ "dialog", "dialogCombobox", "dialogFocusTrap", "dialogListbox",
1740
1742
  "endOfOptionsStream",
1741
1743
  "handle",
1742
1744
  "hiddenField",
@@ -1798,8 +1800,7 @@ class HwComboboxController extends Concerns(...concerns) {
1798
1800
  const callbackId = element.dataset.callbackId;
1799
1801
 
1800
1802
  if (this._callbackAttemptsExceeded(callbackId)) {
1801
- this._dequeueCallback(callbackId);
1802
- return
1803
+ return this._dequeueCallback(callbackId)
1803
1804
  } else {
1804
1805
  this._recordCallbackAttempt(callbackId);
1805
1806
  }
@@ -1813,7 +1814,7 @@ class HwComboboxController extends Concerns(...concerns) {
1813
1814
  this._resetMultiselectionMarks();
1814
1815
 
1815
1816
  if (inputType === "hw:multiselectSync") {
1816
- this.openByFocusing();
1817
+ this.open();
1817
1818
  } else if (inputType !== "hw:lockInSelection") {
1818
1819
  this._selectOnQuery(inputType);
1819
1820
  }
@@ -1824,7 +1825,7 @@ class HwComboboxController extends Concerns(...concerns) {
1824
1825
  }
1825
1826
 
1826
1827
  closerTargetConnected() {
1827
- this._closeAndBlur("hw:asyncCloser");
1828
+ this.close("hw:asyncCloser");
1828
1829
  }
1829
1830
 
1830
1831
  // Use +_printStack+ for debugging purposes
@@ -95,3 +95,12 @@ export function nextAnimationFrame() {
95
95
  export function nextEventLoopTick() {
96
96
  return new Promise((resolve) => setTimeout(() => resolve(), 0))
97
97
  }
98
+
99
+ export function randomUUID() {
100
+ const uuidPattern = "10000000-1000-4000-8000-100000000000"
101
+
102
+ return uuidPattern.replace(/[018]/g, (match) => {
103
+ const randomByte = crypto.getRandomValues(new Uint8Array(1))[0]
104
+ return (match ^ (randomByte & 15) >> (match / 4)).toString(16)
105
+ })
106
+ }
@@ -8,23 +8,33 @@ Combobox.Autocomplete = Base => class extends Base {
8
8
  }
9
9
  }
10
10
 
11
- _replaceFullQueryWithAutocompletedValue(option) {
11
+ _hardAutocomplete(option) {
12
+ const typedValue = this._typedQuery
12
13
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
13
14
 
14
15
  this._fullQuery = autocompletedValue
15
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
16
+
17
+ if (this._isAutocompletableWith(typedValue, autocompletedValue)) {
18
+ this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length)
19
+ } else {
20
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
21
+ }
16
22
  }
17
23
 
18
- _autocompleteMissingPortion(option) {
24
+ _softAutocomplete(option) {
19
25
  const typedValue = this._typedQuery
20
26
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
21
27
 
22
- if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
28
+ if (this._isAutocompletableWith(typedValue, autocompletedValue)) {
23
29
  this._fullQuery = autocompletedValue
24
30
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length)
25
31
  }
26
32
  }
27
33
 
34
+ _isAutocompletableWith(typedValue, autocompletedValue) {
35
+ return this._autocompletesInline && startsWith(autocompletedValue, typedValue)
36
+ }
37
+
28
38
  // +visuallyHideListbox+ hides the listbox from the user,
29
39
  // but makes it still searchable by JS.
30
40
  _visuallyHideListbox() {
@@ -1,4 +1,5 @@
1
1
  import Combobox from "hw_combobox/models/combobox/base"
2
+ import { randomUUID } from "hw_combobox/helpers"
2
3
 
3
4
  const MAX_CALLBACK_ATTEMPTS = 3
4
5
 
@@ -9,7 +10,7 @@ Combobox.Callbacks = Base => class extends Base {
9
10
  }
10
11
 
11
12
  _enqueueCallback() {
12
- const callbackId = crypto.randomUUID()
13
+ const callbackId = randomUUID()
13
14
  this.callbackQueue.push(callbackId)
14
15
  return callbackId
15
16
  }
@@ -39,10 +39,7 @@ Combobox.Dialog = Base => class extends Base {
39
39
 
40
40
  _resizeDialog = () => {
41
41
  if (window.visualViewport) {
42
- this.dialogTarget.style.setProperty(
43
- "--hw-visual-viewport-height",
44
- `${window.visualViewport.height}px`
45
- )
42
+ this.dialogTarget.style.setProperty("--hw-visual-viewport-height", `${window.visualViewport.height}px`)
46
43
  }
47
44
  }
48
45
 
@@ -4,6 +4,15 @@ import { applyFilter, debounce, unselectedPortion } from "hw_combobox/helpers"
4
4
  import { get } from "hw_combobox/vendor/requestjs"
5
5
 
6
6
  Combobox.Filtering = Base => class extends Base {
7
+ prepareToFilter({ key }) {
8
+ const intendsToFilter = key.match(/^[a-zA-Z0-9]$|^ArrowDown$/)
9
+
10
+ if (this._isClosed && intendsToFilter) {
11
+ this.open() // `.open()` sets the appropriate state so the combobox knows it’s open.
12
+ this._expand() // `.open()` will call `._expand()` via stimulus callbacks, but we’re calling it inline so it happens immediately.
13
+ }
14
+ }
15
+
7
16
  filterAndSelect({ inputType }) {
8
17
  this._filter(inputType)
9
18
 
@@ -50,12 +59,7 @@ Combobox.Filtering = Base => class extends Base {
50
59
  }
51
60
 
52
61
  _filterSync() {
53
- this._allFilterableOptionElements.forEach(
54
- applyFilter(
55
- this._fullQuery,
56
- { matching: this.filterableAttributeValue }
57
- )
58
- )
62
+ this._allFilterableOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }))
59
63
  }
60
64
 
61
65
  _clearQuery() {
@@ -53,8 +53,7 @@ Combobox.FormField = Base => class extends Base {
53
53
 
54
54
  get _hasEmptyFieldValue() {
55
55
  if (this._isMultiselect) {
56
- return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
57
- this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
56
+ return this.hiddenFieldTarget.dataset.valueForMultiselect == "" || this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
58
57
  } else {
59
58
  return this.hiddenFieldTarget.value === ""
60
59
  }
@@ -25,7 +25,7 @@ Combobox.Multiselect = Base => class extends Base {
25
25
  currentTarget.closest("[data-hw-combobox-chip]").remove()
26
26
 
27
27
  if (!this._isSmallViewport) {
28
- this.openByFocusing()
28
+ this.open()
29
29
  }
30
30
 
31
31
  this._announceToScreenReader(display, "removed")
@@ -50,7 +50,7 @@ Combobox.Multiselect = Base => class extends Base {
50
50
  cancel(event)
51
51
  },
52
52
  Escape: (event) => {
53
- this.openByFocusing()
53
+ this.open()
54
54
  cancel(event)
55
55
  }
56
56
  }
@@ -67,13 +67,16 @@ Combobox.Multiselect = Base => class extends Base {
67
67
 
68
68
  this._beforeClearingMultiselectQuery(async (display, value) => {
69
69
  this._fullQuery = ""
70
+
70
71
  this._filter("hw:multiselectSync")
71
72
  this._requestChips(value)
72
73
  this._addToFieldValue(value)
74
+
73
75
  if (shouldReopen) {
74
76
  await nextRepaint()
75
- this.openByFocusing()
77
+ this.open()
76
78
  }
79
+
77
80
  this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.")
78
81
  })
79
82
  }