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.
- checksums.yaml +4 -4
- data/MIT-LICENSE +1 -1
- data/README.md +19 -15
- data/app/assets/config/hw_combobox_manifest.js +1 -1
- data/app/assets/javascripts/controllers/hw_combobox_controller.js +5 -13
- data/app/assets/javascripts/hotwire_combobox.esm.js +67 -66
- data/app/assets/javascripts/hw_combobox/helpers.js +9 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +14 -4
- data/app/assets/javascripts/hw_combobox/models/combobox/callbacks.js +2 -1
- data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -4
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +10 -6
- data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +1 -2
- data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +6 -3
- data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +2 -2
- data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +4 -12
- data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +13 -18
- data/app/assets/stylesheets/hotwire_combobox.css +12 -2
- data/app/presenters/hotwire_combobox/component/announced.rb +5 -0
- data/app/presenters/hotwire_combobox/component/associations.rb +18 -0
- data/app/presenters/hotwire_combobox/component/async.rb +6 -0
- data/app/presenters/hotwire_combobox/component/customizable.rb +8 -20
- data/app/presenters/hotwire_combobox/component/freetext.rb +26 -0
- data/app/presenters/hotwire_combobox/component/markup/dialog.rb +57 -0
- data/app/presenters/hotwire_combobox/component/markup/fieldset.rb +46 -0
- data/app/presenters/hotwire_combobox/component/markup/form.rb +6 -0
- data/app/presenters/hotwire_combobox/component/markup/handle.rb +7 -0
- data/app/presenters/hotwire_combobox/component/markup/hidden_field.rb +28 -0
- data/app/presenters/hotwire_combobox/component/markup/input.rb +44 -0
- data/app/presenters/hotwire_combobox/component/markup/label.rb +5 -0
- data/app/presenters/hotwire_combobox/component/markup/listbox.rb +14 -0
- data/app/presenters/hotwire_combobox/component/markup/wrapper.rb +7 -0
- data/app/presenters/hotwire_combobox/component/multiselect.rb +6 -0
- data/app/presenters/hotwire_combobox/component/paginated.rb +18 -0
- data/app/presenters/hotwire_combobox/component.rb +32 -398
- data/app/presenters/hotwire_combobox/listbox/group.rb +3 -15
- data/app/presenters/hotwire_combobox/listbox/item.rb +5 -12
- data/app/presenters/hotwire_combobox/listbox/option.rb +3 -20
- data/app/views/hotwire_combobox/_component.html.erb +28 -6
- data/app/views/hotwire_combobox/_pagination.html.erb +2 -2
- data/config/hw_importmap.rb +1 -1
- data/lib/hotwire_combobox/helper.rb +24 -65
- data/lib/hotwire_combobox/platform.rb +15 -0
- data/lib/hotwire_combobox/version.rb +1 -1
- data/lib/hotwire_combobox.rb +1 -0
- metadata +33 -11
- data/app/assets/javascripts/hotwire_combobox.umd.js +0 -1843
- data/app/views/hotwire_combobox/combobox/_dialog.html.erb +0 -9
- data/app/views/hotwire_combobox/combobox/_hidden_field.html.erb +0 -4
- data/app/views/hotwire_combobox/combobox/_input.html.erb +0 -2
- 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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 01df7da888bb6651172b141161fcf58610b9c1e1e28e8f20a3bee7d880a9f6cb
|
4
|
+
data.tar.gz: 3ae59ccce1a2782b49759d2797244d8b8084996a8f115b46ac32f10fc22eab39
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 11e979427daf0c338006be0ab69732fcd4ca7f4860319b54e003730341419b1845069638acab2adaae1c45ecc961cc5216c30791e567cb0abb1dd6fc326ad794
|
7
|
+
data.tar.gz: 9f05dafd9e46f000641a86f11d9d223df4264d4f53b131f6f9be347bdb8356e4800eae22719d99c4b20bb665ce437efac27941ada324e725087801cd6fa5414e
|
data/MIT-LICENSE
CHANGED
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
|
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"
|
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"
|
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
|
-
|
51
|
+
Alternatively, modify `app/javascript/controllers/application.js` as follows:
|
54
52
|
|
55
53
|
```js
|
56
|
-
import {
|
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/
|
76
|
+
Then, register the library's stimulus controller in `app/javascript/controllers/application.js` as follows:
|
75
77
|
|
76
78
|
```js
|
77
|
-
import {
|
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
|
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.
|
122
|
-
2.
|
123
|
-
3.
|
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
|
-
//=
|
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.
|
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.
|
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.
|
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
|
-
|
169
|
+
_hardAutocomplete(option) {
|
170
|
+
const typedValue = this._typedQuery;
|
161
171
|
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
|
162
172
|
|
163
173
|
this._fullQuery = autocompletedValue;
|
164
|
-
|
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
|
-
|
182
|
+
_softAutocomplete(option) {
|
168
183
|
const typedValue = this._typedQuery;
|
169
184
|
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
|
170
185
|
|
171
|
-
if (this.
|
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 =
|
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.
|
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.
|
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.
|
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.
|
991
|
+
this.close("hw:keyHandler:enter");
|
970
992
|
cancel(event);
|
971
993
|
},
|
972
994
|
Escape: (event) => {
|
973
|
-
this.
|
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.
|
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.
|
1120
|
+
this._select(this._ensurableOption, this._softAutocomplete.bind(this));
|
1099
1121
|
} else if (this._isOpen && this._visibleOptionElements[0]) {
|
1100
|
-
this.
|
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.
|
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.
|
1553
|
+
this.close("hw:toggle");
|
1545
1554
|
} else {
|
1546
|
-
this.
|
1555
|
+
this.open();
|
1547
1556
|
}
|
1548
1557
|
}
|
1549
1558
|
|
1550
1559
|
closeOnClickOutside(event) {
|
1551
1560
|
const target = event.target;
|
1552
1561
|
|
1553
|
-
if (
|
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.
|
1566
|
+
this.close("hw:clickOutside");
|
1558
1567
|
}
|
1559
1568
|
|
1560
1569
|
closeOnFocusOutside({ target }) {
|
1561
|
-
if (
|
1570
|
+
if (this._isClosed) return
|
1562
1571
|
if (this.element.contains(target)) return
|
1563
1572
|
|
1564
|
-
this.
|
1573
|
+
this.close("hw:focusOutside");
|
1565
1574
|
}
|
1566
1575
|
|
1567
1576
|
clearOrToggleOnHandleClick() {
|
1568
1577
|
if (this._isQueried) {
|
1569
1578
|
this._clearQuery();
|
1570
|
-
this.
|
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.
|
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.
|
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
|
-
|
11
|
+
_hardAutocomplete(option) {
|
12
|
+
const typedValue = this._typedQuery
|
12
13
|
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
|
13
14
|
|
14
15
|
this._fullQuery = autocompletedValue
|
15
|
-
|
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
|
-
|
24
|
+
_softAutocomplete(option) {
|
19
25
|
const typedValue = this._typedQuery
|
20
26
|
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
|
21
27
|
|
22
|
-
if (this.
|
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 =
|
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.
|
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.
|
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.
|
77
|
+
this.open()
|
76
78
|
}
|
79
|
+
|
77
80
|
this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.")
|
78
81
|
})
|
79
82
|
}
|