hotwire_combobox 0.1.41 → 0.1.43

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: 1fc70c854bf6cdceda08dd727b854a63b7deb0703d9a8dafcab0bd74ca3e73d0
4
- data.tar.gz: ec4ad558a47dcdb94e68d360671a89f3d9147a7a2a172a741f06f59dfbfd096a
3
+ metadata.gz: 5d76e27ecc585f8ad3d4d596d2a6f8fe9916909734027d251cb35264604f192e
4
+ data.tar.gz: eda85b2dd3c21c407c806a5144c4d9b39c40a36c827c7d3b1986a5e27d432cf9
5
5
  SHA512:
6
- metadata.gz: 1cb4ab65ec2312a055400870a1c2e91a11e598c389daf96f1f3f079627fac208fbaad73bad1a957db1a5eff33eaaba7c7d2c7eccc7aa389bdcdf9c51e26832b6
7
- data.tar.gz: fbb2db40e5b529084c5c65ab644c3af9c0f5721741f2692dcc85e76362f82ebdddf94f979d02b80328b8b39828468e591d6d61faeec3ecff7f262c327bb78a6b
6
+ metadata.gz: 2cab9b83d4bf4caf3e741f5c12f336241e77f6eec0c97041f25162ecb4aade0d0c58b3ce59e5772b1e5edb89a2a6ad92051c8237f0d40bcee30e550e692add1b
7
+ data.tar.gz: 1d40dfa00ae3884e8c44693abfa542859ed27fb4fb124a90264c03a57ade1dcb31865f7d89078ea4304968f3adcaba51905d940c336a29ed860857fc5b6e5f40
data/README.md CHANGED
@@ -80,6 +80,9 @@ import HwComboboxController from "@josefarias/hotwire_combobox"
80
80
  application.register("hw-combobox", HwComboboxController)
81
81
  ```
82
82
 
83
+ > [!WARNING]
84
+ > Keep in mind you need to update both the npm package and the gem every time there's a new version of HotwireCombobox. You should always run the same version number on both sides.
85
+
83
86
  ### Configuring CSS
84
87
 
85
88
  This library comes with optional default styles. Follow the instructions below to include them in your app.
@@ -109,6 +112,19 @@ Require the styles in `app/assets/stylesheets/application.css`:
109
112
  Visit [the docs site](https://hotwirecombobox.com/) for a demo and detailed documentation.
110
113
  If the site is down, you can run the docs locally by cloning [the docs repo](https://github.com/josefarias/hotwire_combobox_docs).
111
114
 
115
+ ## Notes about accessibility
116
+
117
+ This gem follows the [APG combobox pattern guidelines](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) with some exceptions we feel increase the usefulness of the component without much detriment to the overall accessible experience.
118
+
119
+ These are the exceptions:
120
+
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
+
126
+ 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.
127
+
112
128
  ## Contributing
113
129
 
114
130
  Please read [CONTRIBUTING.md](./CONTRIBUTING.md).
@@ -80,7 +80,7 @@ export default class HwComboboxController extends Concerns(...concerns) {
80
80
 
81
81
  if (inputType && inputType !== "hw:lockInSelection") {
82
82
  if (delay) await sleep(delay)
83
- this._commitFilter({ inputType })
83
+ this._selectBasedOnQuery({ inputType })
84
84
  } else {
85
85
  this._preselectOption()
86
86
  }
@@ -37,6 +37,10 @@ Combobox.AsyncLoading = Base => class extends Base {
37
37
  get _isAsync() {
38
38
  return this.hasAsyncSrcValue
39
39
  }
40
+
41
+ get _isSync() {
42
+ return !this._isAsync
43
+ }
40
44
  };
41
45
 
42
46
  function Concerns(Base, ...mixins) {
@@ -128,16 +132,18 @@ Combobox.Autocomplete = Base => class extends Base {
128
132
  }
129
133
  }
130
134
 
131
- _autocompleteWith(option, { force }) {
132
- if (!this._autocompletesInline && !force) return
135
+ _replaceFullQueryWithAutocompletedValue(option) {
136
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
137
+
138
+ this._fullQuery = autocompletedValue;
139
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
140
+ }
133
141
 
142
+ _autocompleteMissingPortion(option) {
134
143
  const typedValue = this._typedQuery;
135
144
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue);
136
145
 
137
- if (force) {
138
- this._fullQuery = autocompletedValue;
139
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length);
140
- } else if (startsWith(autocompletedValue, typedValue)) {
146
+ if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
141
147
  this._fullQuery = autocompletedValue;
142
148
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length);
143
149
  }
@@ -236,17 +242,27 @@ Combobox.Dialog = Base => class extends Base {
236
242
  };
237
243
 
238
244
  Combobox.Events = Base => class extends Base {
239
- _dispatchSelectionEvent({ isNew }) {
240
- const detail = {
241
- value: this.hiddenFieldTarget.value,
245
+ _dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
246
+ if (previousValue !== this._value) {
247
+ dispatch("hw-combobox:selection", {
248
+ target: this.element,
249
+ detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
250
+ });
251
+ }
252
+ }
253
+
254
+ _dispatchClosedEvent() {
255
+ dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails });
256
+ }
257
+
258
+ get _eventableDetails() {
259
+ return {
260
+ value: this._value,
242
261
  display: this._fullQuery,
243
262
  query: this._typedQuery,
244
263
  fieldName: this.hiddenFieldTarget.name,
245
- isValid: this._valueIsValid,
246
- isNew: isNew
247
- };
248
-
249
- dispatch("hw-combobox:selection", { target: this.element, detail });
264
+ isValid: this._valueIsValid
265
+ }
250
266
  }
251
267
  };
252
268
 
@@ -528,20 +544,28 @@ async function get(url, options) {
528
544
  // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
529
545
 
530
546
  Combobox.Filtering = Base => class extends Base {
531
- filter(event) {
547
+ filterAndSelect(event) {
548
+ this._filter(event);
549
+
550
+ if (this._isSync) {
551
+ this._selectBasedOnQuery(event);
552
+ }
553
+ }
554
+
555
+ _initializeFiltering() {
556
+ this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
557
+ }
558
+
559
+ _filter(event) {
532
560
  if (this._isAsync) {
533
561
  this._debouncedFilterAsync(event);
534
562
  } else {
535
- this._filterSync(event);
563
+ this._filterSync();
536
564
  }
537
565
 
538
566
  this._actingCombobox.toggleAttribute("data-queried", this._isQueried);
539
567
  }
540
568
 
541
- _initializeFiltering() {
542
- this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this));
543
- }
544
-
545
569
  _debouncedFilterAsync(event) {
546
570
  this._filterAsync(event);
547
571
  }
@@ -556,27 +580,14 @@ Combobox.Filtering = Base => class extends Base {
556
580
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query });
557
581
  }
558
582
 
559
- _filterSync(event) {
583
+ _filterSync() {
560
584
  this.open();
561
585
  this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }));
562
- this._commitFilter(event);
563
- }
564
-
565
- _commitFilter(event) {
566
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
567
- this._selectNew();
568
- } else if (isDeleteEvent(event)) {
569
- this._deselect();
570
- } else if (event.inputType === "hw:lockInSelection") {
571
- this._select(this._ensurableOption);
572
- } else if (this._isOpen) {
573
- this._select(this._visibleOptionElements[0]);
574
- }
575
586
  }
576
587
 
577
588
  _clearQuery() {
578
589
  this._fullQuery = "";
579
- this.filter({ inputType: "deleteContentBackward" });
590
+ this.filterAndSelect({ inputType: "deleteContentBackward" });
580
591
  }
581
592
 
582
593
  get _isQueried() {
@@ -670,9 +681,17 @@ Combobox.NewOptions = Base => class extends Base {
670
681
  };
671
682
 
672
683
  Combobox.Options = Base => class extends Base {
673
- _resetOptions() {
674
- this._deselect();
675
- this.hiddenFieldTarget.name = this.originalNameValue;
684
+ _resetOptionsSilently() {
685
+ this._resetOptions(this._deselect.bind(this));
686
+ }
687
+
688
+ _resetOptionsAndNotify() {
689
+ this._resetOptions(this._deselectAndNotify.bind(this));
690
+ }
691
+
692
+ _resetOptions(deselectionStrategy) {
693
+ this._setName(this.originalNameValue);
694
+ deselectionStrategy();
676
695
  }
677
696
 
678
697
  get _allowNew() {
@@ -700,7 +719,7 @@ Combobox.Options = Base => class extends Base {
700
719
  }
701
720
 
702
721
  get _isUnjustifiablyBlank() {
703
- const valueIsMissing = !this.hiddenFieldTarget.value;
722
+ const valueIsMissing = !this._value;
704
723
  const noBlankOptionSelected = !this._selectedOptionElement;
705
724
 
706
725
  return valueIsMissing && noBlankOptionSelected
@@ -709,8 +728,7 @@ Combobox.Options = Base => class extends Base {
709
728
 
710
729
  Combobox.Selection = Base => class extends Base {
711
730
  selectOptionOnClick(event) {
712
- this.filter(event);
713
- this._select(event.currentTarget, { forceAutocomplete: true });
731
+ this._forceSelectionAndFilter(event.currentTarget, event);
714
732
  this.close();
715
733
  }
716
734
 
@@ -720,91 +738,136 @@ Combobox.Selection = Base => class extends Base {
720
738
  }
721
739
  }
722
740
 
723
- _select(option, { forceAutocomplete = false } = {}) {
724
- this._resetOptions();
725
-
726
- if (option) {
727
- this._autocompleteWith(option, { force: forceAutocomplete });
728
- this._commitSelection(option, { selected: true });
729
- this._markValid();
730
- } else {
741
+ _selectBasedOnQuery(event) {
742
+ if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
743
+ this._selectNew();
744
+ } else if (isDeleteEvent(event)) {
745
+ this._deselect();
746
+ } else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
747
+ this._selectAndAutocompleteMissingPortion(this._ensurableOption);
748
+ } else if (this._isOpen && this._visibleOptionElements[0]) {
749
+ this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0]);
750
+ } else if (this._isOpen) {
751
+ this._resetOptionsAndNotify();
731
752
  this._markInvalid();
732
- }
753
+ } else ;
733
754
  }
734
755
 
735
- _commitSelection(option, { selected }) {
736
- this._markSelected(option, { selected });
756
+ _select(option, autocompleteStrategy) {
757
+ const previousValue = this._value;
737
758
 
738
- if (selected) {
739
- this.hiddenFieldTarget.value = option.dataset.value;
740
- option.scrollIntoView({ block: "nearest" });
741
- }
759
+ this._resetOptionsSilently();
760
+
761
+ autocompleteStrategy(option);
742
762
 
743
- this._dispatchSelectionEvent({ isNew: false });
763
+ this._setValue(option.dataset.value);
764
+ this._markSelected(option);
765
+ this._markValid();
766
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
767
+
768
+ option.scrollIntoView({ block: "nearest" });
744
769
  }
745
770
 
746
- _markSelected(option, { selected }) {
747
- if (this.hasSelectedClass) {
748
- option.classList.toggle(this.selectedClass, selected);
749
- }
771
+ _selectNew() {
772
+ const previousValue = this._value;
750
773
 
751
- option.setAttribute("aria-selected", selected);
752
- this._setActiveDescendant(selected ? option.id : "");
774
+ this._resetOptionsSilently();
775
+ this._setValue(this._fullQuery);
776
+ this._setName(this.nameWhenNewValue);
777
+ this._markValid();
778
+ this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue });
753
779
  }
754
780
 
755
781
  _deselect() {
756
- const option = this._selectedOptionElement;
782
+ const previousValue = this._value;
757
783
 
758
- if (option) this._commitSelection(option, { selected: false });
784
+ if (this._selectedOptionElement) {
785
+ this._markNotSelected(this._selectedOptionElement);
786
+ }
759
787
 
760
- this.hiddenFieldTarget.value = null;
788
+ this._setValue(null);
761
789
  this._setActiveDescendant("");
762
790
 
763
- if (!option) this._dispatchSelectionEvent({ isNew: false });
791
+ return previousValue
764
792
  }
765
793
 
766
- _selectNew() {
767
- this._resetOptions();
768
- this.hiddenFieldTarget.value = this._fullQuery;
769
- this.hiddenFieldTarget.name = this.nameWhenNewValue;
770
- this._markValid();
794
+ _deselectAndNotify() {
795
+ const previousValue = this._deselect();
796
+ this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue });
797
+ }
771
798
 
772
- this._dispatchSelectionEvent({ isNew: true });
799
+ _forceSelectionAndFilter(option, event) {
800
+ this._forceSelectionWithoutFiltering(option);
801
+ this._filter(event);
802
+ }
803
+
804
+ _forceSelectionWithoutFiltering(option) {
805
+ this._selectAndReplaceFullQuery(option);
773
806
  }
774
807
 
775
808
  _selectIndex(index) {
776
809
  const option = wrapAroundAccess(this._visibleOptionElements, index);
777
- this._select(option, { forceAutocomplete: true });
810
+ this._forceSelectionWithoutFiltering(option);
778
811
  }
779
812
 
780
813
  _preselectOption() {
781
814
  if (this._hasValueButNoSelection && this._allOptions.length < 100) {
782
815
  const option = this._allOptions.find(option => {
783
- return option.dataset.value === this.hiddenFieldTarget.value
816
+ return option.dataset.value === this._value
784
817
  });
785
818
 
786
- if (option) this._markSelected(option, { selected: true });
819
+ if (option) this._markSelected(option);
787
820
  }
788
821
  }
789
822
 
823
+ _selectAndReplaceFullQuery(option) {
824
+ this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this));
825
+ }
826
+
827
+ _selectAndAutocompleteMissingPortion(option) {
828
+ this._select(option, this._autocompleteMissingPortion.bind(this));
829
+ }
830
+
790
831
  _lockInSelection() {
791
832
  if (this._shouldLockInSelection) {
792
- this._select(this._ensurableOption, { forceAutocomplete: true });
793
- this.filter({ inputType: "hw:lockInSelection" });
833
+ this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" });
794
834
  }
835
+ }
795
836
 
796
- if (this._isUnjustifiablyBlank) {
797
- this._deselect();
798
- this._clearQuery();
799
- }
837
+ _markSelected(option) {
838
+ if (this.hasSelectedClass) option.classList.add(this.selectedClass);
839
+ option.setAttribute("aria-selected", true);
840
+ this._setActiveDescendant(option.id);
841
+ }
842
+
843
+ _markNotSelected(option) {
844
+ if (this.hasSelectedClass) option.classList.remove(this.selectedClass);
845
+ option.removeAttribute("aria-selected");
846
+ this._removeActiveDescendant();
800
847
  }
801
848
 
802
849
  _setActiveDescendant(id) {
803
850
  this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id));
804
851
  }
805
852
 
853
+ _removeActiveDescendant() {
854
+ this._setActiveDescendant("");
855
+ }
856
+
857
+ _setValue(value) {
858
+ this.hiddenFieldTarget.value = value;
859
+ }
860
+
861
+ _setName(value) {
862
+ this.hiddenFieldTarget.name = value;
863
+ }
864
+
865
+ get _value() {
866
+ return this.hiddenFieldTarget.value
867
+ }
868
+
806
869
  get _hasValueButNoSelection() {
807
- return this.hiddenFieldTarget.value && !this._selectedOptionElement
870
+ return this._value && !this._selectedOptionElement
808
871
  }
809
872
 
810
873
  get _shouldLockInSelection() {
@@ -1098,7 +1161,11 @@ Combobox.Toggle = Base => class extends Base {
1098
1161
  close() {
1099
1162
  if (this._isOpen) {
1100
1163
  this._lockInSelection();
1164
+ this._clearInvalidQuery();
1165
+
1101
1166
  this.expandedValue = false;
1167
+
1168
+ this._dispatchClosedEvent();
1102
1169
  }
1103
1170
  }
1104
1171
 
@@ -1167,6 +1234,8 @@ Combobox.Toggle = Base => class extends Base {
1167
1234
  this._actingCombobox.setAttribute("aria-expanded", true); // needs to happen after setting acting combobox
1168
1235
  }
1169
1236
 
1237
+ // +._collapse()+ differs from `.close()` in that it might be called by stimulus on connect because
1238
+ // it interprets a change in `expandedValue` — whereas `.close()` is only called internally by us.
1170
1239
  _collapse() {
1171
1240
  this._actingCombobox.setAttribute("aria-expanded", false); // needs to happen before resetting acting combobox
1172
1241
 
@@ -1207,6 +1276,13 @@ Combobox.Toggle = Base => class extends Base {
1207
1276
  enableBodyScroll(this.dialogListboxTarget);
1208
1277
  }
1209
1278
 
1279
+ _clearInvalidQuery() {
1280
+ if (this._isUnjustifiablyBlank) {
1281
+ this._deselect();
1282
+ this._clearQuery();
1283
+ }
1284
+ }
1285
+
1210
1286
  get _isOpen() {
1211
1287
  return this.expandedValue
1212
1288
  }
@@ -1250,7 +1326,7 @@ Combobox.Validity = Base => class extends Base {
1250
1326
  // +_valueIsInvalid+ only checks if `comboboxTarget` (and not `_actingCombobox`) is required
1251
1327
  // because the `required` attribute is only forwarded to the `comboboxTarget` element
1252
1328
  get _valueIsInvalid() {
1253
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value;
1329
+ const isRequiredAndEmpty = this.comboboxTarget.required && !this._value;
1254
1330
  return isRequiredAndEmpty
1255
1331
  }
1256
1332
  };
@@ -1333,7 +1409,7 @@ class HwComboboxController extends Concerns(...concerns) {
1333
1409
 
1334
1410
  if (inputType && inputType !== "hw:lockInSelection") {
1335
1411
  if (delay) await sleep(delay);
1336
- this._commitFilter({ inputType });
1412
+ this._selectBasedOnQuery({ inputType });
1337
1413
  } else {
1338
1414
  this._preselectOption();
1339
1415
  }