hotwire_combobox 0.1.42 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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