hotwire_combobox 0.1.42 → 0.2.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/javascripts/controllers/hw_combobox_controller.js +25 -3
  4. data/app/assets/javascripts/hotwire_combobox.esm.js +531 -127
  5. data/app/assets/javascripts/hotwire_combobox.umd.js +531 -127
  6. data/app/assets/javascripts/hw_combobox/helpers.js +16 -0
  7. data/app/assets/javascripts/hw_combobox/models/combobox/announcements.js +7 -0
  8. data/app/assets/javascripts/hw_combobox/models/combobox/async_loading.js +4 -0
  9. data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +8 -6
  10. data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -1
  11. data/app/assets/javascripts/hw_combobox/models/combobox/events.js +21 -6
  12. data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +33 -28
  13. data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +74 -0
  14. data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +160 -0
  15. data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +15 -6
  16. data/app/assets/javascripts/hw_combobox/models/combobox/options.js +29 -9
  17. data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +103 -51
  18. data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +45 -16
  19. data/app/assets/javascripts/hw_combobox/models/combobox/validity.js +1 -1
  20. data/app/assets/javascripts/hw_combobox/models/combobox.js +3 -0
  21. data/app/assets/stylesheets/hotwire_combobox.css +84 -18
  22. data/app/presenters/hotwire_combobox/component/customizable.rb +9 -1
  23. data/app/presenters/hotwire_combobox/component.rb +95 -28
  24. data/app/presenters/hotwire_combobox/listbox/group.rb +45 -0
  25. data/app/presenters/hotwire_combobox/listbox/item.rb +104 -0
  26. data/app/presenters/hotwire_combobox/listbox/option.rb +9 -4
  27. data/app/views/hotwire_combobox/_component.html.erb +1 -0
  28. data/app/views/hotwire_combobox/_selection_chip.turbo_stream.erb +8 -0
  29. data/app/views/hotwire_combobox/layouts/_selection_chip.turbo_stream.erb +7 -0
  30. data/lib/hotwire_combobox/helper.rb +111 -86
  31. data/lib/hotwire_combobox/version.rb +1 -1
  32. metadata +9 -2
@@ -79,3 +79,19 @@ export function dispatch(eventName, { target, cancelable, detail } = {}) {
79
79
 
80
80
  return event
81
81
  }
82
+
83
+ export function nextRepaint() {
84
+ if (document.visibilityState === "hidden") {
85
+ return nextEventLoopTick()
86
+ } else {
87
+ return nextAnimationFrame()
88
+ }
89
+ }
90
+
91
+ export function nextAnimationFrame() {
92
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()))
93
+ }
94
+
95
+ export function nextEventLoopTick() {
96
+ return new Promise((resolve) => setTimeout(() => resolve(), 0))
97
+ }
@@ -0,0 +1,7 @@
1
+ import Combobox from "hw_combobox/models/combobox/base"
2
+
3
+ Combobox.Announcements = Base => class extends Base {
4
+ _announceToScreenReader(display, action) {
5
+ this.announcerTarget.innerText = `${display} ${action}`
6
+ }
7
+ }
@@ -4,4 +4,8 @@ Combobox.AsyncLoading = Base => class extends Base {
4
4
  get _isAsync() {
5
5
  return this.hasAsyncSrcValue
6
6
  }
7
+
8
+ get _isSync() {
9
+ return !this._isAsync
10
+ }
7
11
  }
@@ -8,16 +8,18 @@ Combobox.Autocomplete = Base => class extends Base {
8
8
  }
9
9
  }
10
10
 
11
- _autocompleteWith(option, { force }) {
12
- if (!this._autocompletesInline && !force) return
11
+ _replaceFullQueryWithAutocompletedValue(option) {
12
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
13
+
14
+ this._fullQuery = autocompletedValue
15
+ this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
16
+ }
13
17
 
18
+ _autocompleteMissingPortion(option) {
14
19
  const typedValue = this._typedQuery
15
20
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
16
21
 
17
- if (force) {
18
- this._fullQuery = autocompletedValue
19
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
20
- } else if (startsWith(autocompletedValue, typedValue)) {
22
+ if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
21
23
  this._fullQuery = autocompletedValue
22
24
  this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length)
23
25
  }
@@ -53,7 +53,7 @@ Combobox.Dialog = Base => class extends Base {
53
53
  this.dialogFocusTrapTarget.focus()
54
54
  }
55
55
 
56
- get _smallViewport() {
56
+ get _isSmallViewport() {
57
57
  return window.matchMedia(`(max-width: ${this.smallViewportMaxWidthValue})`).matches
58
58
  }
59
59
 
@@ -2,20 +2,35 @@ import Combobox from "hw_combobox/models/combobox/base"
2
2
  import { dispatch } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Events = Base => class extends Base {
5
- _dispatchSelectionEvent({ isNew }) {
6
- dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } })
5
+ _dispatchPreselectionEvent({ isNewAndAllowed, previousValue }) {
6
+ if (previousValue === this._incomingFieldValueString) return
7
+
8
+ dispatch("hw-combobox:preselection", {
9
+ target: this.element,
10
+ detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
11
+ })
12
+ }
13
+
14
+ _dispatchSelectionEvent() {
15
+ dispatch("hw-combobox:selection", {
16
+ target: this.element,
17
+ detail: this._eventableDetails
18
+ })
7
19
  }
8
20
 
9
- _dispatchClosedEvent() {
10
- dispatch("hw-combobox:closed", { target: this.element, detail: this._eventableDetails })
21
+ _dispatchRemovalEvent({ removedDisplay, removedValue }) {
22
+ dispatch("hw-combobox:removal", {
23
+ target: this.element,
24
+ detail: { ...this._eventableDetails, removedDisplay, removedValue }
25
+ })
11
26
  }
12
27
 
13
28
  get _eventableDetails() {
14
29
  return {
15
- value: this.hiddenFieldTarget.value,
30
+ value: this._incomingFieldValueString,
16
31
  display: this._fullQuery,
17
32
  query: this._typedQuery,
18
- fieldName: this.hiddenFieldTarget.name,
33
+ fieldName: this._fieldName,
19
34
  isValid: this._valueIsValid
20
35
  }
21
36
  }
@@ -1,58 +1,63 @@
1
1
 
2
2
  import Combobox from "hw_combobox/models/combobox/base"
3
- import { applyFilter, debounce, isDeleteEvent, unselectedPortion } from "hw_combobox/helpers"
3
+ 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
- filter(event) {
8
- if (this._isAsync) {
9
- this._debouncedFilterAsync(event)
7
+ filterAndSelect({ inputType }) {
8
+ this._filter(inputType)
9
+
10
+ if (this._isSync) {
11
+ this._selectOnQuery(inputType)
10
12
  } else {
11
- this._filterSync(event)
13
+ // noop, async selection is handled by stimulus callbacks
12
14
  }
13
-
14
- this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
15
15
  }
16
16
 
17
17
  _initializeFiltering() {
18
18
  this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this))
19
19
  }
20
20
 
21
- _debouncedFilterAsync(event) {
22
- this._filterAsync(event)
21
+ _filter(inputType) {
22
+ if (this._isAsync) {
23
+ this._debouncedFilterAsync(inputType)
24
+ } else {
25
+ this._filterSync()
26
+ }
27
+
28
+ this._markQueried()
29
+ }
30
+
31
+ _debouncedFilterAsync(inputType) {
32
+ this._filterAsync(inputType)
23
33
  }
24
34
 
25
- async _filterAsync(event) {
35
+ async _filterAsync(inputType) {
26
36
  const query = {
27
37
  q: this._fullQuery,
28
- input_type: event.inputType,
38
+ input_type: inputType,
29
39
  for_id: this.element.dataset.asyncId
30
40
  }
31
41
 
32
42
  await get(this.asyncSrcValue, { responseKind: "turbo-stream", query })
33
43
  }
34
44
 
35
- _filterSync(event) {
36
- this.open()
37
- this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }))
38
- this._commitFilter(event)
39
- }
40
-
41
- _commitFilter(event) {
42
- if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
43
- this._selectNew()
44
- } else if (isDeleteEvent(event)) {
45
- this._deselect()
46
- } else if (event.inputType === "hw:lockInSelection") {
47
- this._select(this._ensurableOption)
48
- } else if (this._isOpen) {
49
- this._select(this._visibleOptionElements[0])
50
- }
45
+ _filterSync() {
46
+ this._allFilterableOptionElements.forEach(
47
+ applyFilter(
48
+ this._fullQuery,
49
+ { matching: this.filterableAttributeValue }
50
+ )
51
+ )
51
52
  }
52
53
 
53
54
  _clearQuery() {
54
55
  this._fullQuery = ""
55
- this.filter({ inputType: "deleteContentBackward" })
56
+ this.filterAndSelect({ inputType: "deleteContentBackward" })
57
+ }
58
+
59
+ _markQueried() {
60
+ this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
56
61
  }
57
62
 
58
63
  get _isQueried() {
@@ -0,0 +1,74 @@
1
+ import Combobox from "hw_combobox/models/combobox/base"
2
+
3
+ Combobox.FormField = Base => class extends Base {
4
+ get _fieldValue() {
5
+ if (this._isMultiselect) {
6
+ const currentValue = this.hiddenFieldTarget.value
7
+ const arrayFromValue = currentValue ? currentValue.split(",") : []
8
+
9
+ return new Set(arrayFromValue)
10
+ } else {
11
+ return this.hiddenFieldTarget.value
12
+ }
13
+ }
14
+
15
+ get _fieldValueString() {
16
+ if (this._isMultiselect) {
17
+ return this._fieldValueArray.join(",")
18
+ } else {
19
+ return this.hiddenFieldTarget.value
20
+ }
21
+ }
22
+
23
+ get _incomingFieldValueString() {
24
+ if (this._isMultiselect) {
25
+ const array = this._fieldValueArray
26
+
27
+ if (this.hiddenFieldTarget.dataset.valueForMultiselect) {
28
+ array.push(this.hiddenFieldTarget.dataset.valueForMultiselect)
29
+ }
30
+
31
+ return array.join(",")
32
+ } else {
33
+ return this.hiddenFieldTarget.value
34
+ }
35
+ }
36
+
37
+ get _fieldValueArray() {
38
+ if (this._isMultiselect) {
39
+ return Array.from(this._fieldValue)
40
+ } else {
41
+ return [ this.hiddenFieldTarget.value ]
42
+ }
43
+ }
44
+
45
+ set _fieldValue(value) {
46
+ if (this._isMultiselect) {
47
+ this.hiddenFieldTarget.dataset.valueForMultiselect = value?.replace(/,/g, "")
48
+ this.hiddenFieldTarget.dataset.displayForMultiselect = this._fullQuery
49
+ } else {
50
+ this.hiddenFieldTarget.value = value
51
+ }
52
+ }
53
+
54
+ get _hasEmptyFieldValue() {
55
+ if (this._isMultiselect) {
56
+ return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
57
+ this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
58
+ } else {
59
+ return this.hiddenFieldTarget.value === ""
60
+ }
61
+ }
62
+
63
+ get _hasFieldValue() {
64
+ return !this._hasEmptyFieldValue
65
+ }
66
+
67
+ get _fieldName() {
68
+ return this.hiddenFieldTarget.name
69
+ }
70
+
71
+ set _fieldName(value) {
72
+ this.hiddenFieldTarget.name = value
73
+ }
74
+ }
@@ -0,0 +1,160 @@
1
+ import Combobox from "hw_combobox/models/combobox/base"
2
+ import { cancel, nextRepaint } from "hw_combobox/helpers"
3
+ import { get } from "hw_combobox/vendor/requestjs"
4
+
5
+ Combobox.Multiselect = Base => class extends Base {
6
+ navigateChip(event) {
7
+ this._chipKeyHandlers[event.key]?.call(this, event)
8
+ }
9
+
10
+ removeChip({ currentTarget, params }) {
11
+ let display
12
+ const option = this._optionElementWithValue(params.value)
13
+
14
+ if (option) {
15
+ display = option.getAttribute(this.autocompletableAttributeValue)
16
+ this._markNotSelected(option)
17
+ this._markNotMultiselected(option)
18
+ } else {
19
+ display = params.value // for new options
20
+ }
21
+
22
+ this._removeFromFieldValue(params.value)
23
+ this._filter("hw:multiselectSync")
24
+
25
+ currentTarget.closest("[data-hw-combobox-chip]").remove()
26
+
27
+ if (!this._isSmallViewport) {
28
+ this.openByFocusing()
29
+ }
30
+
31
+ this._announceToScreenReader(display, "removed")
32
+ this._dispatchRemovalEvent({ removedDisplay: display, removedValue: params.value })
33
+ }
34
+
35
+ hideChipsForCache() {
36
+ this.element.querySelectorAll("[data-hw-combobox-chip]").forEach(chip => chip.hidden = true)
37
+ }
38
+
39
+ _chipKeyHandlers = {
40
+ Backspace: (event) => {
41
+ this.removeChip(event)
42
+ cancel(event)
43
+ },
44
+ Enter: (event) => {
45
+ this.removeChip(event)
46
+ cancel(event)
47
+ },
48
+ Space: (event) => {
49
+ this.removeChip(event)
50
+ cancel(event)
51
+ },
52
+ Escape: (event) => {
53
+ this.openByFocusing()
54
+ cancel(event)
55
+ }
56
+ }
57
+
58
+ _initializeMultiselect() {
59
+ if (!this._isMultiPreselected) {
60
+ this._preselectMultiple()
61
+ this._markMultiPreselected()
62
+ }
63
+ }
64
+
65
+ async _createChip(shouldReopen) {
66
+ if (!this._isMultiselect) return
67
+
68
+ this._beforeClearingMultiselectQuery(async (display, value) => {
69
+ this._fullQuery = ""
70
+ this._filter("hw:multiselectSync")
71
+ this._requestChips(value)
72
+ this._addToFieldValue(value)
73
+ if (shouldReopen) {
74
+ await nextRepaint()
75
+ this.openByFocusing()
76
+ }
77
+ this._announceToScreenReader(display, "multi-selected. Press Shift + Tab, then Enter to remove.")
78
+ })
79
+ }
80
+
81
+ async _requestChips(values) {
82
+ await get(this.selectionChipSrcValue, {
83
+ responseKind: "turbo-stream",
84
+ query: {
85
+ for_id: this.element.dataset.asyncId,
86
+ combobox_values: values
87
+ }
88
+ })
89
+ }
90
+
91
+ _beforeClearingMultiselectQuery(callback) {
92
+ const display = this.hiddenFieldTarget.dataset.displayForMultiselect
93
+ const value = this.hiddenFieldTarget.dataset.valueForMultiselect
94
+
95
+ if (value && !this._fieldValue.has(value)) {
96
+ callback(display, value)
97
+ }
98
+
99
+ this.hiddenFieldTarget.dataset.displayForMultiselect = ""
100
+ this.hiddenFieldTarget.dataset.valueForMultiselect = ""
101
+ }
102
+
103
+ _resetMultiselectionMarks() {
104
+ if (!this._isMultiselect) return
105
+
106
+ this._fieldValueArray.forEach(value => {
107
+ const option = this._optionElementWithValue(value)
108
+
109
+ if (option) {
110
+ option.setAttribute("data-multiselected", "")
111
+ option.hidden = true
112
+ }
113
+ })
114
+ }
115
+
116
+ _markNotMultiselected(option) {
117
+ if (!this._isMultiselect) return
118
+
119
+ option.removeAttribute("data-multiselected")
120
+ option.hidden = false
121
+ }
122
+
123
+ _addToFieldValue(value) {
124
+ const newValue = this._fieldValue
125
+
126
+ newValue.add(String(value))
127
+ this.hiddenFieldTarget.value = Array.from(newValue).join(",")
128
+
129
+ if (this._isSync) this._resetMultiselectionMarks()
130
+ }
131
+
132
+ _removeFromFieldValue(value) {
133
+ const newValue = this._fieldValue
134
+
135
+ newValue.delete(String(value))
136
+ this.hiddenFieldTarget.value = Array.from(newValue).join(",")
137
+
138
+ if (this._isSync) this._resetMultiselectionMarks()
139
+ }
140
+
141
+ _focusLastChipDismisser() {
142
+ this.chipDismisserTargets[this.chipDismisserTargets.length - 1]?.focus()
143
+ }
144
+
145
+ _markMultiPreselected() {
146
+ this.element.dataset.multiPreselected = ""
147
+ }
148
+
149
+ get _isMultiselect() {
150
+ return this.hasSelectionChipSrcValue
151
+ }
152
+
153
+ get _isSingleSelect() {
154
+ return !this._isMultiselect
155
+ }
156
+
157
+ get _isMultiPreselected() {
158
+ return this.element.hasAttribute("data-multi-preselected")
159
+ }
160
+ }
@@ -4,17 +4,22 @@ import { cancel } from "hw_combobox/helpers"
4
4
  Combobox.Navigation = Base => class extends Base {
5
5
  navigate(event) {
6
6
  if (this._autocompletesList) {
7
- this._keyHandlers[event.key]?.call(this, event)
7
+ this._navigationKeyHandlers[event.key]?.call(this, event)
8
8
  }
9
9
  }
10
10
 
11
- _keyHandlers = {
11
+ _navigationKeyHandlers = {
12
12
  ArrowUp: (event) => {
13
13
  this._selectIndex(this._selectedOptionIndex - 1)
14
14
  cancel(event)
15
15
  },
16
16
  ArrowDown: (event) => {
17
17
  this._selectIndex(this._selectedOptionIndex + 1)
18
+
19
+ if (this._selectedOptionIndex === 0) {
20
+ this._actingListbox.scrollTop = 0
21
+ }
22
+
18
23
  cancel(event)
19
24
  },
20
25
  Home: (event) => {
@@ -26,14 +31,18 @@ Combobox.Navigation = Base => class extends Base {
26
31
  cancel(event)
27
32
  },
28
33
  Enter: (event) => {
29
- this.close()
30
- this._actingCombobox.blur()
34
+ this._closeAndBlur("hw:keyHandler:enter")
31
35
  cancel(event)
32
36
  },
33
37
  Escape: (event) => {
34
- this.close()
35
- this._actingCombobox.blur()
38
+ this._closeAndBlur("hw:keyHandler:escape")
36
39
  cancel(event)
40
+ },
41
+ Backspace: (event) => {
42
+ if (this._isMultiselect && !this._fullQuery) {
43
+ this._focusLastChipDismisser()
44
+ cancel(event)
45
+ }
37
46
  }
38
47
  }
39
48
  }
@@ -2,9 +2,25 @@ import Combobox from "hw_combobox/models/combobox/base"
2
2
  import { visible } from "hw_combobox/helpers"
3
3
 
4
4
  Combobox.Options = Base => class extends Base {
5
- _resetOptions() {
6
- this._deselect()
7
- this.hiddenFieldTarget.name = this.originalNameValue
5
+ _resetOptionsSilently() {
6
+ this._resetOptions(this._deselect.bind(this))
7
+ }
8
+
9
+ _resetOptionsAndNotify() {
10
+ this._resetOptions(this._deselectAndNotify.bind(this))
11
+ }
12
+
13
+ _resetOptions(deselectionStrategy) {
14
+ this._fieldName = this.originalNameValue
15
+ deselectionStrategy()
16
+ }
17
+
18
+ _optionElementWithValue(value) {
19
+ return this._actingListbox.querySelector(`[${this.filterableAttributeValue}][data-value='${value}']`)
20
+ }
21
+
22
+ _displayForOptionElement(element) {
23
+ return element.getAttribute(this.autocompletableAttributeValue)
8
24
  }
9
25
 
10
26
  get _allowNew() {
@@ -12,19 +28,23 @@ Combobox.Options = Base => class extends Base {
12
28
  }
13
29
 
14
30
  get _allOptions() {
15
- return Array.from(this._allOptionElements)
31
+ return Array.from(this._allFilterableOptionElements)
16
32
  }
17
33
 
18
- get _allOptionElements() {
19
- return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]`)
34
+ get _allFilterableOptionElements() {
35
+ return this._actingListbox.querySelectorAll(`[${this.filterableAttributeValue}]:not([data-multiselected])`)
20
36
  }
21
37
 
22
38
  get _visibleOptionElements() {
23
- return [ ...this._allOptionElements ].filter(visible)
39
+ return [ ...this._allFilterableOptionElements ].filter(visible)
24
40
  }
25
41
 
26
42
  get _selectedOptionElement() {
27
- return this._actingListbox.querySelector("[role=option][aria-selected=true]")
43
+ return this._actingListbox.querySelector("[role=option][aria-selected=true]:not([data-multiselected])")
44
+ }
45
+
46
+ get _multiselectedOptionElements() {
47
+ return this._actingListbox.querySelectorAll("[role=option][data-multiselected]")
28
48
  }
29
49
 
30
50
  get _selectedOptionIndex() {
@@ -32,7 +52,7 @@ Combobox.Options = Base => class extends Base {
32
52
  }
33
53
 
34
54
  get _isUnjustifiablyBlank() {
35
- const valueIsMissing = !this.hiddenFieldTarget.value
55
+ const valueIsMissing = this._hasEmptyFieldValue
36
56
  const noBlankOptionSelected = !this._selectedOptionElement
37
57
 
38
58
  return valueIsMissing && noBlankOptionSelected