hotwire_combobox 0.1.38 → 0.1.39

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8218e50f0e61c4bda1ea76e874cb44b2dd003a49c52965ead080d0ddd6fdb1ba
4
- data.tar.gz: 0dfad8d8e83525305406bc4afa0f7c687fa82fe5277e9768f22f9e1638b9b677
3
+ metadata.gz: 49f81dc7ada4155b100cf7043fbb8b02a10f0e29c636bc1ba4b1f97bb0f3427f
4
+ data.tar.gz: 785782d46ca4d70fc2b8463d0c9812a0d1ea2009c21274469457cf313a4d59b2
5
5
  SHA512:
6
- metadata.gz: 1f38007adfe6dd93118590f8207d37c98ff3069bc29846f674e4e4dd18b97a5a60d5f21c0c80f7bc86ff34837ef3cf7eb81683d05cd179f192a3c63182574c9c
7
- data.tar.gz: 67d63843c03f7e304cdd11d0646dd245cc07f590af7d4b2593b18a599f4184a64ee483237b369c86697c18a0c7e823a2e424d516fdf26f8c383acdac8aa3a2ae
6
+ metadata.gz: 0dac85e0c02124f5e1c6aee86fa3f06bda5b9434b2df5953e6992bc7df70ddaec4e4e252ce16ac7140a9d09edb9d1cb257c364f26cf21b41bf500a544614cb27
7
+ data.tar.gz: 8e63361787fac58a9a624bccd4a14e6939d26b95d18d87a5852016ff13d8e3afa7c217bc9d4b1e8ada89a3b0aa0935f8b006f845c54ce82531b069563db28353
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <img src="docs/assets/images/logo.png" height=150>
3
3
  </p>
4
4
 
5
- # Easy Autocomplete for Ruby on Rails
5
+ # Easy and Accessible Autocomplete for Ruby on Rails
6
6
 
7
7
  [![CI Tests](https://github.com/josefarias/hotwire_combobox/actions/workflows/ci_tests.yml/badge.svg)](https://github.com/josefarias/hotwire_combobox/actions/workflows/ci_tests.yml) [![Gem Version](https://badge.fury.io/rb/hotwire_combobox.svg)](https://badge.fury.io/rb/hotwire_combobox)
8
8
 
@@ -10,6 +10,7 @@ const concerns = [
10
10
  Combobox.AsyncLoading,
11
11
  Combobox.Autocomplete,
12
12
  Combobox.Dialog,
13
+ Combobox.Events,
13
14
  Combobox.Filtering,
14
15
  Combobox.Navigation,
15
16
  Combobox.NewOptions,
@@ -104,6 +104,23 @@ function unselectedPortion(element) {
104
104
  }
105
105
  }
106
106
 
107
+ function dispatch(eventName, { target, cancelable, detail } = {}) {
108
+ const event = new CustomEvent(eventName, {
109
+ cancelable,
110
+ bubbles: true,
111
+ composed: true,
112
+ detail
113
+ });
114
+
115
+ if (target && target.isConnected) {
116
+ target.dispatchEvent(event);
117
+ } else {
118
+ document.documentElement.dispatchEvent(event);
119
+ }
120
+
121
+ return event
122
+ }
123
+
107
124
  Combobox.Autocomplete = Base => class extends Base {
108
125
  _connectListAutocomplete() {
109
126
  if (!this._autocompletesList) {
@@ -157,6 +174,12 @@ Combobox.Autocomplete = Base => class extends Base {
157
174
  };
158
175
 
159
176
  Combobox.Dialog = Base => class extends Base {
177
+ rerouteListboxStreamToDialog({ detail: { newStream } }) {
178
+ if (newStream.target == this.listboxTarget.id && this._dialogIsOpen) {
179
+ newStream.setAttribute("target", this.dialogListboxTarget.id);
180
+ }
181
+ }
182
+
160
183
  _connectDialog() {
161
184
  if (window.visualViewport) {
162
185
  window.visualViewport.addEventListener("resize", this._resizeDialog);
@@ -206,6 +229,25 @@ Combobox.Dialog = Base => class extends Base {
206
229
  get _smallViewport() {
207
230
  return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
208
231
  }
232
+
233
+ get _dialogIsOpen() {
234
+ return this.dialogTarget.open
235
+ }
236
+ };
237
+
238
+ Combobox.Events = Base => class extends Base {
239
+ _dispatchSelectionEvent({ isNew }) {
240
+ const detail = {
241
+ value: this.hiddenFieldTarget.value,
242
+ display: this._fullQuery,
243
+ query: this._typedQuery,
244
+ fieldName: this.hiddenFieldTarget.name,
245
+ isValid: this._valueIsValid,
246
+ isNew: isNew
247
+ };
248
+
249
+ dispatch("hw-combobox:selection", { target: this.element, detail });
250
+ }
209
251
  };
210
252
 
211
253
  class FetchResponse {
@@ -523,11 +565,16 @@ Combobox.Filtering = Base => class extends Base {
523
565
  this._selectNew();
524
566
  } else if (isDeleteEvent(event)) {
525
567
  this._deselect();
526
- } else {
568
+ } else if (this._isOpen) {
527
569
  this._select(this._visibleOptionElements[0]);
528
570
  }
529
571
  }
530
572
 
573
+ _clearQuery() {
574
+ this._fullQuery = "";
575
+ this.filter({ inputType: "deleteContentBackward" });
576
+ }
577
+
531
578
  get _isQueried() {
532
579
  return this._fullQuery.length > 0
533
580
  }
@@ -647,6 +694,13 @@ Combobox.Options = Base => class extends Base {
647
694
  get _selectedOptionIndex() {
648
695
  return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement)
649
696
  }
697
+
698
+ get _isUnjustifiablyBlank() {
699
+ const valueIsMissing = !this.hiddenFieldTarget.value;
700
+ const noBlankOptionSelected = !this._selectedOptionElement;
701
+
702
+ return valueIsMissing && noBlankOptionSelected
703
+ }
650
704
  };
651
705
 
652
706
  Combobox.Selection = Base => class extends Base {
@@ -666,9 +720,9 @@ Combobox.Selection = Base => class extends Base {
666
720
  this._resetOptions();
667
721
 
668
722
  if (option) {
669
- this._markValid();
670
723
  this._autocompleteWith(option, { force: forceAutocomplete });
671
724
  this._commitSelection(option, { selected: true });
725
+ this._markValid();
672
726
  } else {
673
727
  this._markInvalid();
674
728
  }
@@ -681,6 +735,8 @@ Combobox.Selection = Base => class extends Base {
681
735
  this.hiddenFieldTarget.value = option.dataset.value;
682
736
  option.scrollIntoView({ block: "nearest" });
683
737
  }
738
+
739
+ this._dispatchSelectionEvent({ isNew: false });
684
740
  }
685
741
 
686
742
  _markSelected(option, { selected }) {
@@ -694,15 +750,22 @@ Combobox.Selection = Base => class extends Base {
694
750
 
695
751
  _deselect() {
696
752
  const option = this._selectedOptionElement;
753
+
697
754
  if (option) this._commitSelection(option, { selected: false });
755
+
698
756
  this.hiddenFieldTarget.value = null;
699
757
  this._setActiveDescendant("");
758
+
759
+ if (!option) this._dispatchSelectionEvent({ isNew: false });
700
760
  }
701
761
 
702
762
  _selectNew() {
703
763
  this._resetOptions();
704
764
  this.hiddenFieldTarget.value = this._fullQuery;
705
765
  this.hiddenFieldTarget.name = this.nameWhenNewValue;
766
+ this._markValid();
767
+
768
+ this._dispatchSelectionEvent({ isNew: true });
706
769
  }
707
770
 
708
771
  _selectIndex(index) {
@@ -725,6 +788,11 @@ Combobox.Selection = Base => class extends Base {
725
788
  this._select(this._ensurableOption, { forceAutocomplete: true });
726
789
  this.filter({ inputType: "hw:lockInSelection" });
727
790
  }
791
+
792
+ if (this._isUnjustifiablyBlank) {
793
+ this._deselect();
794
+ this._clearQuery();
795
+ }
728
796
  }
729
797
 
730
798
  _setActiveDescendant(id) {
@@ -1089,7 +1157,7 @@ Combobox.Toggle = Base => class extends Base {
1089
1157
  _collapse() {
1090
1158
  this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1091
1159
 
1092
- if (this.dialogTarget.open) {
1160
+ if (this._dialogIsOpen) {
1093
1161
  this._closeInDialog();
1094
1162
  } else {
1095
1163
  this._closeInline();
@@ -1139,29 +1207,35 @@ Combobox.Validity = Base => class extends Base {
1139
1207
  _markValid() {
1140
1208
  if (this._valueIsInvalid) return
1141
1209
 
1142
- if (this.hasInvalidClass) {
1143
- this.comboboxTarget.classList.remove(this.invalidClass);
1144
- }
1210
+ this._forAllComboboxes(combobox => {
1211
+ if (this.hasInvalidClass) {
1212
+ combobox.classList.remove(this.invalidClass);
1213
+ }
1145
1214
 
1146
- this.comboboxTarget.removeAttribute("aria-invalid");
1147
- this.comboboxTarget.removeAttribute("aria-errormessage");
1215
+ combobox.removeAttribute("aria-invalid");
1216
+ combobox.removeAttribute("aria-errormessage");
1217
+ });
1148
1218
  }
1149
1219
 
1150
1220
  _markInvalid() {
1151
1221
  if (this._valueIsValid) return
1152
1222
 
1153
- if (this.hasInvalidClass) {
1154
- this.comboboxTarget.classList.add(this.invalidClass);
1155
- }
1223
+ this._forAllComboboxes(combobox => {
1224
+ if (this.hasInvalidClass) {
1225
+ combobox.classList.add(this.invalidClass);
1226
+ }
1156
1227
 
1157
- this.comboboxTarget.setAttribute("aria-invalid", true);
1158
- this.comboboxTarget.setAttribute("aria-errormessage", `Please select a valid option for ${this.comboboxTarget.name}`);
1228
+ combobox.setAttribute("aria-invalid", true);
1229
+ combobox.setAttribute("aria-errormessage", `Please select a valid option for ${combobox.name}`);
1230
+ });
1159
1231
  }
1160
1232
 
1161
1233
  get _valueIsValid() {
1162
1234
  return !this._valueIsInvalid
1163
1235
  }
1164
1236
 
1237
+ // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1238
+ // because the `required` attribute is only forwarded to the `comboboxTarget` element
1165
1239
  get _valueIsInvalid() {
1166
1240
  const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1167
1241
  return isRequiredAndEmpty
@@ -1176,6 +1250,7 @@ const concerns = [
1176
1250
  Combobox.AsyncLoading,
1177
1251
  Combobox.Autocomplete,
1178
1252
  Combobox.Dialog,
1253
+ Combobox.Events,
1179
1254
  Combobox.Filtering,
1180
1255
  Combobox.Navigation,
1181
1256
  Combobox.NewOptions,
@@ -108,6 +108,23 @@
108
108
  }
109
109
  }
110
110
 
111
+ function dispatch(eventName, { target, cancelable, detail } = {}) {
112
+ const event = new CustomEvent(eventName, {
113
+ cancelable,
114
+ bubbles: true,
115
+ composed: true,
116
+ detail
117
+ });
118
+
119
+ if (target && target.isConnected) {
120
+ target.dispatchEvent(event);
121
+ } else {
122
+ document.documentElement.dispatchEvent(event);
123
+ }
124
+
125
+ return event
126
+ }
127
+
111
128
  Combobox.Autocomplete = Base => class extends Base {
112
129
  _connectListAutocomplete() {
113
130
  if (!this._autocompletesList) {
@@ -161,6 +178,12 @@
161
178
  };
162
179
 
163
180
  Combobox.Dialog = Base => class extends Base {
181
+ rerouteListboxStreamToDialog({ detail: { newStream } }) {
182
+ if (newStream.target == this.listboxTarget.id && this._dialogIsOpen) {
183
+ newStream.setAttribute("target", this.dialogListboxTarget.id);
184
+ }
185
+ }
186
+
164
187
  _connectDialog() {
165
188
  if (window.visualViewport) {
166
189
  window.visualViewport.addEventListener("resize", this._resizeDialog);
@@ -210,6 +233,25 @@
210
233
  get _smallViewport() {
211
234
  return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
212
235
  }
236
+
237
+ get _dialogIsOpen() {
238
+ return this.dialogTarget.open
239
+ }
240
+ };
241
+
242
+ Combobox.Events = Base => class extends Base {
243
+ _dispatchSelectionEvent({ isNew }) {
244
+ const detail = {
245
+ value: this.hiddenFieldTarget.value,
246
+ display: this._fullQuery,
247
+ query: this._typedQuery,
248
+ fieldName: this.hiddenFieldTarget.name,
249
+ isValid: this._valueIsValid,
250
+ isNew: isNew
251
+ };
252
+
253
+ dispatch("hw-combobox:selection", { target: this.element, detail });
254
+ }
213
255
  };
214
256
 
215
257
  class FetchResponse {
@@ -527,11 +569,16 @@
527
569
  this._selectNew();
528
570
  } else if (isDeleteEvent(event)) {
529
571
  this._deselect();
530
- } else {
572
+ } else if (this._isOpen) {
531
573
  this._select(this._visibleOptionElements[0]);
532
574
  }
533
575
  }
534
576
 
577
+ _clearQuery() {
578
+ this._fullQuery = "";
579
+ this.filter({ inputType: "deleteContentBackward" });
580
+ }
581
+
535
582
  get _isQueried() {
536
583
  return this._fullQuery.length > 0
537
584
  }
@@ -651,6 +698,13 @@
651
698
  get _selectedOptionIndex() {
652
699
  return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement)
653
700
  }
701
+
702
+ get _isUnjustifiablyBlank() {
703
+ const valueIsMissing = !this.hiddenFieldTarget.value;
704
+ const noBlankOptionSelected = !this._selectedOptionElement;
705
+
706
+ return valueIsMissing && noBlankOptionSelected
707
+ }
654
708
  };
655
709
 
656
710
  Combobox.Selection = Base => class extends Base {
@@ -670,9 +724,9 @@
670
724
  this._resetOptions();
671
725
 
672
726
  if (option) {
673
- this._markValid();
674
727
  this._autocompleteWith(option, { force: forceAutocomplete });
675
728
  this._commitSelection(option, { selected: true });
729
+ this._markValid();
676
730
  } else {
677
731
  this._markInvalid();
678
732
  }
@@ -685,6 +739,8 @@
685
739
  this.hiddenFieldTarget.value = option.dataset.value;
686
740
  option.scrollIntoView({ block: "nearest" });
687
741
  }
742
+
743
+ this._dispatchSelectionEvent({ isNew: false });
688
744
  }
689
745
 
690
746
  _markSelected(option, { selected }) {
@@ -698,15 +754,22 @@
698
754
 
699
755
  _deselect() {
700
756
  const option = this._selectedOptionElement;
757
+
701
758
  if (option) this._commitSelection(option, { selected: false });
759
+
702
760
  this.hiddenFieldTarget.value = null;
703
761
  this._setActiveDescendant("");
762
+
763
+ if (!option) this._dispatchSelectionEvent({ isNew: false });
704
764
  }
705
765
 
706
766
  _selectNew() {
707
767
  this._resetOptions();
708
768
  this.hiddenFieldTarget.value = this._fullQuery;
709
769
  this.hiddenFieldTarget.name = this.nameWhenNewValue;
770
+ this._markValid();
771
+
772
+ this._dispatchSelectionEvent({ isNew: true });
710
773
  }
711
774
 
712
775
  _selectIndex(index) {
@@ -729,6 +792,11 @@
729
792
  this._select(this._ensurableOption, { forceAutocomplete: true });
730
793
  this.filter({ inputType: "hw:lockInSelection" });
731
794
  }
795
+
796
+ if (this._isUnjustifiablyBlank) {
797
+ this._deselect();
798
+ this._clearQuery();
799
+ }
732
800
  }
733
801
 
734
802
  _setActiveDescendant(id) {
@@ -1093,7 +1161,7 @@
1093
1161
  _collapse() {
1094
1162
  this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1095
1163
 
1096
- if (this.dialogTarget.open) {
1164
+ if (this._dialogIsOpen) {
1097
1165
  this._closeInDialog();
1098
1166
  } else {
1099
1167
  this._closeInline();
@@ -1143,29 +1211,35 @@
1143
1211
  _markValid() {
1144
1212
  if (this._valueIsInvalid) return
1145
1213
 
1146
- if (this.hasInvalidClass) {
1147
- this.comboboxTarget.classList.remove(this.invalidClass);
1148
- }
1214
+ this._forAllComboboxes(combobox => {
1215
+ if (this.hasInvalidClass) {
1216
+ combobox.classList.remove(this.invalidClass);
1217
+ }
1149
1218
 
1150
- this.comboboxTarget.removeAttribute("aria-invalid");
1151
- this.comboboxTarget.removeAttribute("aria-errormessage");
1219
+ combobox.removeAttribute("aria-invalid");
1220
+ combobox.removeAttribute("aria-errormessage");
1221
+ });
1152
1222
  }
1153
1223
 
1154
1224
  _markInvalid() {
1155
1225
  if (this._valueIsValid) return
1156
1226
 
1157
- if (this.hasInvalidClass) {
1158
- this.comboboxTarget.classList.add(this.invalidClass);
1159
- }
1227
+ this._forAllComboboxes(combobox => {
1228
+ if (this.hasInvalidClass) {
1229
+ combobox.classList.add(this.invalidClass);
1230
+ }
1160
1231
 
1161
- this.comboboxTarget.setAttribute("aria-invalid", true);
1162
- this.comboboxTarget.setAttribute("aria-errormessage", `Please select a valid option for ${this.comboboxTarget.name}`);
1232
+ combobox.setAttribute("aria-invalid", true);
1233
+ combobox.setAttribute("aria-errormessage", `Please select a valid option for ${combobox.name}`);
1234
+ });
1163
1235
  }
1164
1236
 
1165
1237
  get _valueIsValid() {
1166
1238
  return !this._valueIsInvalid
1167
1239
  }
1168
1240
 
1241
+ // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1242
+ // because the `required` attribute is only forwarded to the `comboboxTarget` element
1169
1243
  get _valueIsInvalid() {
1170
1244
  const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1171
1245
  return isRequiredAndEmpty
@@ -1180,6 +1254,7 @@
1180
1254
  Combobox.AsyncLoading,
1181
1255
  Combobox.Autocomplete,
1182
1256
  Combobox.Dialog,
1257
+ Combobox.Events,
1183
1258
  Combobox.Filtering,
1184
1259
  Combobox.Navigation,
1185
1260
  Combobox.NewOptions,
@@ -62,3 +62,20 @@ export function unselectedPortion(element) {
62
62
  return element.value.substring(0, element.selectionStart)
63
63
  }
64
64
  }
65
+
66
+ export function dispatch(eventName, { target, cancelable, detail } = {}) {
67
+ const event = new CustomEvent(eventName, {
68
+ cancelable,
69
+ bubbles: true,
70
+ composed: true,
71
+ detail
72
+ })
73
+
74
+ if (target && target.isConnected) {
75
+ target.dispatchEvent(event)
76
+ } else {
77
+ document.documentElement.dispatchEvent(event)
78
+ }
79
+
80
+ return event
81
+ }
@@ -1,6 +1,12 @@
1
1
  import Combobox from "hw_combobox/models/combobox/base"
2
2
 
3
3
  Combobox.Dialog = Base => class extends Base {
4
+ rerouteListboxStreamToDialog({ detail: { newStream } }) {
5
+ if (newStream.target == this.listboxTarget.id && this._dialogIsOpen) {
6
+ newStream.setAttribute("target", this.dialogListboxTarget.id)
7
+ }
8
+ }
9
+
4
10
  _connectDialog() {
5
11
  if (window.visualViewport) {
6
12
  window.visualViewport.addEventListener("resize", this._resizeDialog)
@@ -50,4 +56,8 @@ Combobox.Dialog = Base => class extends Base {
50
56
  get _smallViewport() {
51
57
  return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
52
58
  }
59
+
60
+ get _dialogIsOpen() {
61
+ return this.dialogTarget.open
62
+ }
53
63
  }
@@ -0,0 +1,17 @@
1
+ import Combobox from "hw_combobox/models/combobox/base"
2
+ import { dispatch } from "hw_combobox/helpers"
3
+
4
+ Combobox.Events = Base => class extends Base {
5
+ _dispatchSelectionEvent({ isNew }) {
6
+ const detail = {
7
+ value: this.hiddenFieldTarget.value,
8
+ display: this._fullQuery,
9
+ query: this._typedQuery,
10
+ fieldName: this.hiddenFieldTarget.name,
11
+ isValid: this._valueIsValid,
12
+ isNew: isNew
13
+ }
14
+
15
+ dispatch("hw-combobox:selection", { target: this.element, detail })
16
+ }
17
+ }
@@ -41,11 +41,16 @@ Combobox.Filtering = Base => class extends Base {
41
41
  this._selectNew()
42
42
  } else if (isDeleteEvent(event)) {
43
43
  this._deselect()
44
- } else {
44
+ } else if (this._isOpen) {
45
45
  this._select(this._visibleOptionElements[0])
46
46
  }
47
47
  }
48
48
 
49
+ _clearQuery() {
50
+ this._fullQuery = ""
51
+ this.filter({ inputType: "deleteContentBackward" })
52
+ }
53
+
49
54
  get _isQueried() {
50
55
  return this._fullQuery.length > 0
51
56
  }
@@ -30,4 +30,11 @@ Combobox.Options = Base => class extends Base {
30
30
  get _selectedOptionIndex() {
31
31
  return [ ...this._visibleOptionElements ].indexOf(this._selectedOptionElement)
32
32
  }
33
+
34
+ get _isUnjustifiablyBlank() {
35
+ const valueIsMissing = !this.hiddenFieldTarget.value
36
+ const noBlankOptionSelected = !this._selectedOptionElement
37
+
38
+ return valueIsMissing && noBlankOptionSelected
39
+ }
33
40
  }
@@ -18,9 +18,9 @@ Combobox.Selection = Base => class extends Base {
18
18
  this._resetOptions()
19
19
 
20
20
  if (option) {
21
- this._markValid()
22
21
  this._autocompleteWith(option, { force: forceAutocomplete })
23
22
  this._commitSelection(option, { selected: true })
23
+ this._markValid()
24
24
  } else {
25
25
  this._markInvalid()
26
26
  }
@@ -33,6 +33,8 @@ Combobox.Selection = Base => class extends Base {
33
33
  this.hiddenFieldTarget.value = option.dataset.value
34
34
  option.scrollIntoView({ block: "nearest" })
35
35
  }
36
+
37
+ this._dispatchSelectionEvent({ isNew: false })
36
38
  }
37
39
 
38
40
  _markSelected(option, { selected }) {
@@ -46,15 +48,22 @@ Combobox.Selection = Base => class extends Base {
46
48
 
47
49
  _deselect() {
48
50
  const option = this._selectedOptionElement
51
+
49
52
  if (option) this._commitSelection(option, { selected: false })
53
+
50
54
  this.hiddenFieldTarget.value = null
51
55
  this._setActiveDescendant("")
56
+
57
+ if (!option) this._dispatchSelectionEvent({ isNew: false })
52
58
  }
53
59
 
54
60
  _selectNew() {
55
61
  this._resetOptions()
56
62
  this.hiddenFieldTarget.value = this._fullQuery
57
63
  this.hiddenFieldTarget.name = this.nameWhenNewValue
64
+ this._markValid()
65
+
66
+ this._dispatchSelectionEvent({ isNew: true })
58
67
  }
59
68
 
60
69
  _selectIndex(index) {
@@ -77,6 +86,11 @@ Combobox.Selection = Base => class extends Base {
77
86
  this._select(this._ensurableOption, { forceAutocomplete: true })
78
87
  this.filter({ inputType: "hw:lockInSelection" })
79
88
  }
89
+
90
+ if (this._isUnjustifiablyBlank) {
91
+ this._deselect()
92
+ this._clearQuery()
93
+ }
80
94
  }
81
95
 
82
96
  _setActiveDescendant(id) {
@@ -72,7 +72,7 @@ Combobox.Toggle = Base => class extends Base {
72
72
  _collapse() {
73
73
  this._actingCombobox.setAttribute("aria-expanded", false) // needs to happen before resetting acting combobox
74
74
 
75
- if (this.dialogTarget.open) {
75
+ if (this._dialogIsOpen) {
76
76
  this._closeInDialog()
77
77
  } else {
78
78
  this._closeInline()
@@ -4,29 +4,35 @@ Combobox.Validity = Base => class extends Base {
4
4
  _markValid() {
5
5
  if (this._valueIsInvalid) return
6
6
 
7
- if (this.hasInvalidClass) {
8
- this.comboboxTarget.classList.remove(this.invalidClass)
9
- }
10
-
11
- this.comboboxTarget.removeAttribute("aria-invalid")
12
- this.comboboxTarget.removeAttribute("aria-errormessage")
7
+ this._forAllComboboxes(combobox => {
8
+ if (this.hasInvalidClass) {
9
+ combobox.classList.remove(this.invalidClass)
10
+ }
11
+
12
+ combobox.removeAttribute("aria-invalid")
13
+ combobox.removeAttribute("aria-errormessage")
14
+ })
13
15
  }
14
16
 
15
17
  _markInvalid() {
16
18
  if (this._valueIsValid) return
17
19
 
18
- if (this.hasInvalidClass) {
19
- this.comboboxTarget.classList.add(this.invalidClass)
20
- }
20
+ this._forAllComboboxes(combobox => {
21
+ if (this.hasInvalidClass) {
22
+ combobox.classList.add(this.invalidClass)
23
+ }
21
24
 
22
- this.comboboxTarget.setAttribute("aria-invalid", true)
23
- this.comboboxTarget.setAttribute("aria-errormessage", `Please select a valid option for ${this.comboboxTarget.name}`)
25
+ combobox.setAttribute("aria-invalid", true)
26
+ combobox.setAttribute("aria-errormessage", `Please select a valid option for ${combobox.name}`)
27
+ })
24
28
  }
25
29
 
26
30
  get _valueIsValid() {
27
31
  return !this._valueIsInvalid
28
32
  }
29
33
 
34
+ // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
35
+ // because the `required` attribute is only forwarded to the `comboboxTarget` element
30
36
  get _valueIsInvalid() {
31
37
  const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value
32
38
  return isRequiredAndEmpty
@@ -4,6 +4,7 @@ import "hw_combobox/models/combobox/actors"
4
4
  import "hw_combobox/models/combobox/async_loading"
5
5
  import "hw_combobox/models/combobox/autocomplete"
6
6
  import "hw_combobox/models/combobox/dialog"
7
+ import "hw_combobox/models/combobox/events"
7
8
  import "hw_combobox/models/combobox/filtering"
8
9
  import "hw_combobox/models/combobox/navigation"
9
10
  import "hw_combobox/models/combobox/new_options"
@@ -150,7 +150,7 @@ class HotwireCombobox::Component
150
150
  end
151
151
 
152
152
  def fieldset_data
153
- data.reverse_merge \
153
+ data.merge \
154
154
  async_id: canonical_id,
155
155
  controller: view.token_list("hw-combobox", data[:controller]),
156
156
  hw_combobox_expanded_value: open,
@@ -162,7 +162,8 @@ class HotwireCombobox::Component
162
162
  hw_combobox_prefilled_display_value: prefilled_display,
163
163
  hw_combobox_filterable_attribute_value: "data-filterable-as",
164
164
  hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
165
- hw_combobox_selected_class: "hw-combobox__option--selected"
165
+ hw_combobox_selected_class: "hw-combobox__option--selected",
166
+ hw_combobox_invalid_class: "hw-combobox__input--invalid"
166
167
  end
167
168
 
168
169
  def prefilled_display
@@ -189,7 +190,7 @@ class HotwireCombobox::Component
189
190
 
190
191
 
191
192
  def canonical_id
192
- id || form&.field_id(name) || SecureRandom.uuid
193
+ @canonical_id ||= id || form&.field_id(name) || SecureRandom.uuid
193
194
  end
194
195
 
195
196
 
@@ -225,19 +226,20 @@ class HotwireCombobox::Component
225
226
  end
226
227
 
227
228
  def input_data
228
- combobox_attrs.fetch(:data, {}).reverse_merge! \
229
+ combobox_attrs.fetch(:data, {}).merge \
229
230
  action: "
230
231
  focus->hw-combobox#open
231
232
  input->hw-combobox#filter
232
233
  keydown->hw-combobox#navigate
233
234
  click@window->hw-combobox#closeOnClickOutside
234
- focusin@window->hw-combobox#closeOnFocusOutside".squish,
235
+ focusin@window->hw-combobox#closeOnFocusOutside
236
+ turbo:before-stream-render@document->hw-combobox#rerouteListboxStreamToDialog".squish,
235
237
  hw_combobox_target: "combobox",
236
238
  async_id: canonical_id
237
239
  end
238
240
 
239
241
  def input_aria
240
- combobox_attrs.fetch(:aria, {}).reverse_merge! \
242
+ combobox_attrs.fetch(:aria, {}).merge \
241
243
  controls: listbox_id,
242
244
  owns: listbox_id,
243
245
  haspopup: "listbox",
@@ -32,7 +32,7 @@ class HotwireCombobox::Listbox::Option
32
32
  end
33
33
 
34
34
  def id
35
- option.try(:id) || SecureRandom.uuid
35
+ @id ||= option.try(:id) || SecureRandom.uuid
36
36
  end
37
37
 
38
38
  def data
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.38"
2
+ VERSION = "0.1.39"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_combobox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.38
4
+ version: 0.1.39
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Farias
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-03-06 00:00:00.000000000 Z
11
+ date: 2024-03-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -52,8 +52,7 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '1.2'
55
- description: An autocomplete combobox implementation for Ruby on Rails apps using
56
- Hotwire.
55
+ description: An accessible autocomplete for Ruby on Rails apps using Hotwire.
57
56
  email:
58
57
  - jose@farias.mx
59
58
  executables: []
@@ -74,6 +73,7 @@ files:
74
73
  - app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js
75
74
  - app/assets/javascripts/hw_combobox/models/combobox/base.js
76
75
  - app/assets/javascripts/hw_combobox/models/combobox/dialog.js
76
+ - app/assets/javascripts/hw_combobox/models/combobox/events.js
77
77
  - app/assets/javascripts/hw_combobox/models/combobox/filtering.js
78
78
  - app/assets/javascripts/hw_combobox/models/combobox/navigation.js
79
79
  - app/assets/javascripts/hw_combobox/models/combobox/new_options.js
@@ -99,13 +99,13 @@ files:
99
99
  - lib/hotwire_combobox/engine.rb
100
100
  - lib/hotwire_combobox/helper.rb
101
101
  - lib/hotwire_combobox/version.rb
102
- homepage: https://github.com/josefarias/hotwire_combobox
102
+ homepage: https://hotwirecombobox.com/
103
103
  licenses:
104
104
  - MIT
105
105
  metadata:
106
- homepage_uri: https://github.com/josefarias/hotwire_combobox
106
+ homepage_uri: https://hotwirecombobox.com/
107
107
  source_code_uri: https://github.com/josefarias/hotwire_combobox
108
- changelog_uri: https://github.com/josefarias/hotwire_combobox
108
+ changelog_uri: https://github.com/josefarias/hotwire_combobox/releases
109
109
  post_install_message:
110
110
  rdoc_options: []
111
111
  require_paths:
@@ -114,7 +114,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
114
114
  requirements:
115
115
  - - ">="
116
116
  - !ruby/object:Gem::Version
117
- version: '0'
117
+ version: 2.7.0
118
118
  required_rubygems_version: !ruby/object:Gem::Requirement
119
119
  requirements:
120
120
  - - ">="
@@ -124,5 +124,5 @@ requirements: []
124
124
  rubygems_version: 3.5.6
125
125
  signing_key:
126
126
  specification_version: 4
127
- summary: Autocomplete for Rails apps using Hotwire
127
+ summary: Accessible Autocomplete for Rails apps
128
128
  test_files: []