hotwire_combobox 0.3.1 → 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 +34 -18
- data/app/assets/javascripts/hotwire_combobox.esm.js +147 -72
- 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 +46 -0
- data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -4
- data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +19 -8
- 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/javascripts/hw_combobox/models/combobox.js +1 -0
- data/app/assets/stylesheets/hotwire_combobox.css +13 -3
- 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 -19
- data/app/views/hotwire_combobox/_component.html.erb +28 -6
- data/app/views/hotwire_combobox/_pagination.html.erb +3 -3
- 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 +34 -11
- data/app/assets/javascripts/hotwire_combobox.umd.js +0 -1769
- 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
@@ -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() {
|
@@ -0,0 +1,46 @@
|
|
1
|
+
import Combobox from "hw_combobox/models/combobox/base"
|
2
|
+
import { randomUUID } from "hw_combobox/helpers"
|
3
|
+
|
4
|
+
const MAX_CALLBACK_ATTEMPTS = 3
|
5
|
+
|
6
|
+
Combobox.Callbacks = Base => class extends Base {
|
7
|
+
_initializeCallbacks() {
|
8
|
+
this.callbackQueue = []
|
9
|
+
this.callbackExecutionAttempts = {}
|
10
|
+
}
|
11
|
+
|
12
|
+
_enqueueCallback() {
|
13
|
+
const callbackId = randomUUID()
|
14
|
+
this.callbackQueue.push(callbackId)
|
15
|
+
return callbackId
|
16
|
+
}
|
17
|
+
|
18
|
+
_isNextCallback(callbackId) {
|
19
|
+
return this._nextCallback === callbackId
|
20
|
+
}
|
21
|
+
|
22
|
+
_callbackAttemptsExceeded(callbackId) {
|
23
|
+
return this._callbackAttempts(callbackId) > MAX_CALLBACK_ATTEMPTS
|
24
|
+
}
|
25
|
+
|
26
|
+
_callbackAttempts(callbackId) {
|
27
|
+
return this.callbackExecutionAttempts[callbackId] || 0
|
28
|
+
}
|
29
|
+
|
30
|
+
_recordCallbackAttempt(callbackId) {
|
31
|
+
this.callbackExecutionAttempts[callbackId] = this._callbackAttempts(callbackId) + 1
|
32
|
+
}
|
33
|
+
|
34
|
+
_dequeueCallback(callbackId) {
|
35
|
+
this.callbackQueue = this.callbackQueue.filter(id => id !== callbackId)
|
36
|
+
this._forgetCallbackExecutionAttempts(callbackId)
|
37
|
+
}
|
38
|
+
|
39
|
+
_forgetCallbackExecutionAttempts(callbackId) {
|
40
|
+
delete this.callbackExecutionAttempts[callbackId]
|
41
|
+
}
|
42
|
+
|
43
|
+
get _nextCallback() {
|
44
|
+
return this.callbackQueue[0]
|
45
|
+
}
|
46
|
+
}
|
@@ -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
|
|
@@ -14,6 +23,12 @@ Combobox.Filtering = Base => class extends Base {
|
|
14
23
|
}
|
15
24
|
}
|
16
25
|
|
26
|
+
clear(event) {
|
27
|
+
this._clearQuery()
|
28
|
+
this.chipDismisserTargets.forEach(el => el.click())
|
29
|
+
if (event && !event.defaultPrevented) event.target.focus()
|
30
|
+
}
|
31
|
+
|
17
32
|
_initializeFiltering() {
|
18
33
|
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this))
|
19
34
|
}
|
@@ -36,19 +51,15 @@ Combobox.Filtering = Base => class extends Base {
|
|
36
51
|
const query = {
|
37
52
|
q: this._fullQuery,
|
38
53
|
input_type: inputType,
|
39
|
-
for_id: this.element.dataset.asyncId
|
54
|
+
for_id: this.element.dataset.asyncId,
|
55
|
+
callback_id: this._enqueueCallback()
|
40
56
|
}
|
41
57
|
|
42
58
|
await get(this.asyncSrcValue, { responseKind: "turbo-stream", query })
|
43
59
|
}
|
44
60
|
|
45
61
|
_filterSync() {
|
46
|
-
this._allFilterableOptionElements.forEach(
|
47
|
-
applyFilter(
|
48
|
-
this._fullQuery,
|
49
|
-
{ matching: this.filterableAttributeValue }
|
50
|
-
)
|
51
|
-
)
|
62
|
+
this._allFilterableOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }))
|
52
63
|
}
|
53
64
|
|
54
65
|
_clearQuery() {
|
@@ -57,7 +68,7 @@ Combobox.Filtering = Base => class extends Base {
|
|
57
68
|
}
|
58
69
|
|
59
70
|
_markQueried() {
|
60
|
-
this.
|
71
|
+
this._forAllComboboxes(el => el.toggleAttribute("data-queried", this._isQueried))
|
61
72
|
}
|
62
73
|
|
63
74
|
get _isQueried() {
|
@@ -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
|
}
|
@@ -31,11 +31,11 @@ Combobox.Navigation = Base => class extends Base {
|
|
31
31
|
cancel(event)
|
32
32
|
},
|
33
33
|
Enter: (event) => {
|
34
|
-
this.
|
34
|
+
this.close("hw:keyHandler:enter")
|
35
35
|
cancel(event)
|
36
36
|
},
|
37
37
|
Escape: (event) => {
|
38
|
-
this.
|
38
|
+
this._isOpen ? this.close("hw:keyHandler:escape") : this._clearQuery()
|
39
39
|
cancel(event)
|
40
40
|
},
|
41
41
|
Backspace: (event) => {
|
@@ -4,7 +4,7 @@ import { wrapAroundAccess, isDeleteEvent } from "hw_combobox/helpers"
|
|
4
4
|
Combobox.Selection = Base => class extends Base {
|
5
5
|
selectOnClick({ currentTarget, inputType }) {
|
6
6
|
this._forceSelectionAndFilter(currentTarget, inputType)
|
7
|
-
this.
|
7
|
+
this.close("hw:optionRoleClick")
|
8
8
|
}
|
9
9
|
|
10
10
|
_connectSelection() {
|
@@ -20,9 +20,9 @@ Combobox.Selection = Base => class extends Base {
|
|
20
20
|
} else if (isDeleteEvent({ inputType: inputType })) {
|
21
21
|
this._deselect()
|
22
22
|
} else if (inputType === "hw:lockInSelection" && this._ensurableOption) {
|
23
|
-
this.
|
23
|
+
this._select(this._ensurableOption, this._softAutocomplete.bind(this))
|
24
24
|
} else if (this._isOpen && this._visibleOptionElements[0]) {
|
25
|
-
this.
|
25
|
+
this._select(this._visibleOptionElements[0], this._softAutocomplete.bind(this))
|
26
26
|
} else if (this._isOpen) {
|
27
27
|
this._resetOptionsAndNotify()
|
28
28
|
this._markInvalid()
|
@@ -98,21 +98,13 @@ Combobox.Selection = Base => class extends Base {
|
|
98
98
|
}
|
99
99
|
}
|
100
100
|
|
101
|
-
_selectAndAutocompleteMissingPortion(option) {
|
102
|
-
this._select(option, this._autocompleteMissingPortion.bind(this))
|
103
|
-
}
|
104
|
-
|
105
|
-
_selectAndAutocompleteFullQuery(option) {
|
106
|
-
this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this))
|
107
|
-
}
|
108
|
-
|
109
101
|
_forceSelectionAndFilter(option, inputType) {
|
110
102
|
this._forceSelectionWithoutFiltering(option)
|
111
103
|
this._filter(inputType)
|
112
104
|
}
|
113
105
|
|
114
106
|
_forceSelectionWithoutFiltering(option) {
|
115
|
-
this.
|
107
|
+
this._select(option, this._hardAutocomplete.bind(this))
|
116
108
|
}
|
117
109
|
|
118
110
|
_lockInSelection() {
|
@@ -6,10 +6,6 @@ Combobox.Toggle = Base => class extends Base {
|
|
6
6
|
this.expandedValue = true
|
7
7
|
}
|
8
8
|
|
9
|
-
openByFocusing() {
|
10
|
-
this._actingCombobox.focus()
|
11
|
-
}
|
12
|
-
|
13
9
|
close(inputType) {
|
14
10
|
if (this._isOpen) {
|
15
11
|
const shouldReopen = this._isMultiselect &&
|
@@ -24,9 +20,8 @@ Combobox.Toggle = Base => class extends Base {
|
|
24
20
|
|
25
21
|
this.expandedValue = false
|
26
22
|
|
27
|
-
this._dispatchSelectionEvent()
|
28
|
-
|
29
23
|
if (inputType != "hw:keyHandler:escape") {
|
24
|
+
this._dispatchSelectionEvent()
|
30
25
|
this._createChip(shouldReopen)
|
31
26
|
}
|
32
27
|
|
@@ -38,43 +33,38 @@ Combobox.Toggle = Base => class extends Base {
|
|
38
33
|
|
39
34
|
toggle() {
|
40
35
|
if (this.expandedValue) {
|
41
|
-
this.
|
36
|
+
this.close("hw:toggle")
|
42
37
|
} else {
|
43
|
-
this.
|
38
|
+
this.open()
|
44
39
|
}
|
45
40
|
}
|
46
41
|
|
47
42
|
closeOnClickOutside(event) {
|
48
43
|
const target = event.target
|
49
44
|
|
50
|
-
if (
|
45
|
+
if (this._isClosed) return
|
51
46
|
if (this.mainWrapperTarget.contains(target) && !this._isDialogDismisser(target)) return
|
52
47
|
if (this._withinElementBounds(event)) return
|
53
48
|
|
54
|
-
this.
|
49
|
+
this.close("hw:clickOutside")
|
55
50
|
}
|
56
51
|
|
57
52
|
closeOnFocusOutside({ target }) {
|
58
|
-
if (
|
53
|
+
if (this._isClosed) return
|
59
54
|
if (this.element.contains(target)) return
|
60
55
|
|
61
|
-
this.
|
56
|
+
this.close("hw:focusOutside")
|
62
57
|
}
|
63
58
|
|
64
59
|
clearOrToggleOnHandleClick() {
|
65
60
|
if (this._isQueried) {
|
66
61
|
this._clearQuery()
|
67
|
-
this.
|
62
|
+
this.open()
|
68
63
|
} else {
|
69
64
|
this.toggle()
|
70
65
|
}
|
71
66
|
}
|
72
67
|
|
73
|
-
_closeAndBlur(inputType) {
|
74
|
-
this.close(inputType)
|
75
|
-
this._actingCombobox.blur()
|
76
|
-
}
|
77
|
-
|
78
68
|
// Some browser extensions like 1Password overlay elements on top of the combobox.
|
79
69
|
// Hovering over these elements emits a click event for some reason.
|
80
70
|
// These events don't contain any telling information, so we use `_withinElementBounds`
|
@@ -121,6 +111,7 @@ Combobox.Toggle = Base => class extends Base {
|
|
121
111
|
this._preventFocusingComboboxAfterClosingDialog()
|
122
112
|
this._preventBodyScroll()
|
123
113
|
this.dialogTarget.showModal()
|
114
|
+
this._resizeDialog()
|
124
115
|
}
|
125
116
|
|
126
117
|
_openInline() {
|
@@ -156,4 +147,8 @@ Combobox.Toggle = Base => class extends Base {
|
|
156
147
|
get _isOpen() {
|
157
148
|
return this.expandedValue
|
158
149
|
}
|
150
|
+
|
151
|
+
get _isClosed() {
|
152
|
+
return !this._isOpen
|
153
|
+
}
|
159
154
|
}
|
@@ -4,6 +4,7 @@ import "hw_combobox/models/combobox/actors"
|
|
4
4
|
import "hw_combobox/models/combobox/announcements"
|
5
5
|
import "hw_combobox/models/combobox/async_loading"
|
6
6
|
import "hw_combobox/models/combobox/autocomplete"
|
7
|
+
import "hw_combobox/models/combobox/callbacks"
|
7
8
|
import "hw_combobox/models/combobox/dialog"
|
8
9
|
import "hw_combobox/models/combobox/events"
|
9
10
|
import "hw_combobox/models/combobox/filtering"
|
@@ -1,6 +1,7 @@
|
|
1
1
|
:root {
|
2
2
|
--hw-active-bg-color: #F3F4F6;
|
3
3
|
--hw-border-color: #D1D5DB;
|
4
|
+
--hw-component-bg-color: #FFFFFF;
|
4
5
|
--hw-group-color: #57595C;
|
5
6
|
--hw-group-bg-color: #FFFFFF;
|
6
7
|
--hw-invalid-color: #EF4444;
|
@@ -22,7 +23,7 @@
|
|
22
23
|
--hw-dialog-label-size: 1.05rem;
|
23
24
|
--hw-dialog-listbox-margin: 1.25rem 0 0;
|
24
25
|
--hw-dialog-padding: 1rem 1rem 0;
|
25
|
-
--hw-dialog-top-offset:
|
26
|
+
--hw-dialog-top-offset: 18vh;
|
26
27
|
|
27
28
|
--hw-font-size: 1rem;
|
28
29
|
|
@@ -63,6 +64,7 @@
|
|
63
64
|
}
|
64
65
|
|
65
66
|
.hw-combobox__main__wrapper {
|
67
|
+
background-color: var(--hw-component-bg-color);
|
66
68
|
border: var(--hw-border-width--slim) solid var(--hw-border-color);
|
67
69
|
border-radius: var(--hw-border-radius);
|
68
70
|
padding: var(--hw-padding--slim) calc(var(--hw-handle-width) + var(--hw-padding--slimmer)) var(--hw-padding--slim) var(--hw-padding--thick);
|
@@ -201,7 +203,7 @@
|
|
201
203
|
overflow: hidden;
|
202
204
|
padding: var(--hw-dialog-padding);
|
203
205
|
pointer-events: auto;
|
204
|
-
position:
|
206
|
+
position: fixed;
|
205
207
|
top: var(--hw-dialog-top-offset);
|
206
208
|
width: auto;
|
207
209
|
|
@@ -209,12 +211,16 @@
|
|
209
211
|
align-items: center;
|
210
212
|
display: flex;
|
211
213
|
flex-direction: column;
|
212
|
-
justify-content: start;
|
214
|
+
justify-content: flex-start;
|
213
215
|
}
|
214
216
|
|
215
217
|
&::backdrop {
|
216
218
|
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.6) 50%, white 50%);
|
217
219
|
}
|
220
|
+
|
221
|
+
.hw-combobox--ios & {
|
222
|
+
position: absolute;
|
223
|
+
}
|
218
224
|
}
|
219
225
|
|
220
226
|
.hw-combobox__dialog__label {
|
@@ -291,4 +297,8 @@
|
|
291
297
|
|
292
298
|
.hw_combobox__pagination__wrapper {
|
293
299
|
background-color: var(--hw-option-bg-color);
|
300
|
+
|
301
|
+
&:only-child {
|
302
|
+
background-color: transparent;
|
303
|
+
}
|
294
304
|
}
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module HotwireCombobox::Component::Associations
|
2
|
+
private
|
3
|
+
def infer_association_name
|
4
|
+
if name.end_with?("_id")
|
5
|
+
name.sub(/_id\z/, "")
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
def associated_object
|
10
|
+
@associated_object ||= if association_exists?
|
11
|
+
form_object&.public_send association_name
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def association_exists?
|
16
|
+
association_name && form_object&.respond_to?(association_name)
|
17
|
+
end
|
18
|
+
end
|
@@ -1,32 +1,20 @@
|
|
1
1
|
module HotwireCombobox::Component::Customizable
|
2
2
|
CUSTOMIZABLE_ELEMENTS = %i[
|
3
|
+
dialog dialog_input dialog_label dialog_listbox dialog_wrapper
|
3
4
|
fieldset
|
4
|
-
|
5
|
+
handle
|
5
6
|
hidden_field
|
6
|
-
main_wrapper
|
7
7
|
input
|
8
|
-
|
8
|
+
label
|
9
9
|
listbox
|
10
|
-
|
11
|
-
dialog_wrapper
|
12
|
-
dialog_label
|
13
|
-
dialog_input
|
14
|
-
dialog_listbox
|
10
|
+
main_wrapper
|
15
11
|
].freeze
|
16
12
|
|
17
|
-
PROTECTED_ATTRS = %i[
|
18
|
-
id
|
19
|
-
name
|
20
|
-
value
|
21
|
-
open
|
22
|
-
role
|
23
|
-
hidden
|
24
|
-
for
|
25
|
-
].freeze
|
13
|
+
PROTECTED_ATTRS = %i[ for hidden id name open role value ].freeze
|
26
14
|
|
27
15
|
CUSTOMIZABLE_ELEMENTS.each do |element|
|
28
16
|
define_method "customize_#{element}" do |**attrs|
|
29
|
-
|
17
|
+
store_customizations element, **attrs
|
30
18
|
end
|
31
19
|
end
|
32
20
|
|
@@ -35,14 +23,14 @@ module HotwireCombobox::Component::Customizable
|
|
35
23
|
@custom_attrs ||= Hash.new { |h, k| h[k] = {} }
|
36
24
|
end
|
37
25
|
|
38
|
-
def
|
26
|
+
def store_customizations(element, **attrs)
|
39
27
|
element = element.to_sym.presence_in(CUSTOMIZABLE_ELEMENTS)
|
40
28
|
sanitized_attrs = attrs.deep_symbolize_keys.except(*PROTECTED_ATTRS)
|
41
29
|
|
42
30
|
custom_attrs.store element, sanitized_attrs
|
43
31
|
end
|
44
32
|
|
45
|
-
def
|
33
|
+
def customize(element, base: {})
|
46
34
|
custom = custom_attrs[element]
|
47
35
|
|
48
36
|
coalesce = ->(key, value) do
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module HotwireCombobox::Component::Freetext
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
included do
|
5
|
+
include ActiveModel::Validations
|
6
|
+
validate :name_when_new_on_multiselect_must_match_original_name
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
def name_when_new
|
11
|
+
if free_text && @name_when_new.blank?
|
12
|
+
hidden_field_name
|
13
|
+
else
|
14
|
+
@name_when_new
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def name_when_new_on_multiselect_must_match_original_name
|
19
|
+
return unless multiselect? && name_when_new.present?
|
20
|
+
|
21
|
+
unless name_when_new.to_s == hidden_field_name
|
22
|
+
errors.add :name_when_new, :must_match_original_name,
|
23
|
+
message: "must match the regular name ('#{hidden_field_name}', in this case) on multiselect comboboxes."
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module HotwireCombobox::Component::Markup::Dialog
|
2
|
+
def dialog_wrapper_attrs
|
3
|
+
customize :dialog_wrapper, base: { class: "hw-combobox__dialog__wrapper" }
|
4
|
+
end
|
5
|
+
|
6
|
+
def dialog_attrs
|
7
|
+
customize :dialog, base: {
|
8
|
+
class: "hw-combobox__dialog", role: :dialog, data: {
|
9
|
+
action: "keydown->hw-combobox#navigate", hw_combobox_target: "dialog" } }
|
10
|
+
end
|
11
|
+
|
12
|
+
def dialog_label
|
13
|
+
@dialog_label || label
|
14
|
+
end
|
15
|
+
|
16
|
+
def dialog_label_attrs
|
17
|
+
customize :dialog_label, base: { class: "hw-combobox__dialog__label", for: dialog_input_id }
|
18
|
+
end
|
19
|
+
|
20
|
+
def dialog_input_attrs
|
21
|
+
customize :dialog_input, base: {
|
22
|
+
id: dialog_input_id, role: :combobox, autofocus: "", type: input_type,
|
23
|
+
class: "hw-combobox__dialog__input", data: dialog_input_data, aria: dialog_input_aria }
|
24
|
+
end
|
25
|
+
|
26
|
+
def dialog_listbox_attrs
|
27
|
+
customize :dialog_listbox, base: {
|
28
|
+
id: dialog_listbox_id, role: :listbox, class: "hw-combobox__dialog__listbox",
|
29
|
+
data: { hw_combobox_target: "dialogListbox" }, aria: { multiselectable: multiselect? } }
|
30
|
+
end
|
31
|
+
|
32
|
+
def dialog_focus_trap_attrs
|
33
|
+
{ tabindex: "-1", data: { hw_combobox_target: "dialogFocusTrap" } }
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
def dialog_input_id
|
38
|
+
"#{canonical_id}-hw-dialog-combobox"
|
39
|
+
end
|
40
|
+
|
41
|
+
def dialog_input_data
|
42
|
+
{ action: "
|
43
|
+
keydown->hw-combobox#prepareToFilter
|
44
|
+
input->hw-combobox#filterAndSelect
|
45
|
+
keydown->hw-combobox#navigate
|
46
|
+
click@window->hw-combobox#closeOnClickOutside".squish,
|
47
|
+
hw_combobox_target: "dialogCombobox" }
|
48
|
+
end
|
49
|
+
|
50
|
+
def dialog_input_aria
|
51
|
+
{ controls: dialog_listbox_id, owns: dialog_listbox_id, autocomplete: autocomplete, activedescendant: "" }
|
52
|
+
end
|
53
|
+
|
54
|
+
def dialog_listbox_id
|
55
|
+
"#{canonical_id}-hw-dialog-listbox"
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module HotwireCombobox::Component::Markup::Fieldset
|
2
|
+
def fieldset_attrs
|
3
|
+
customize :fieldset, base: {
|
4
|
+
class: [ "hw-combobox", platform_classes, { "hw-combobox--multiple": multiselect? } ], data: fieldset_data }
|
5
|
+
end
|
6
|
+
|
7
|
+
private
|
8
|
+
def platform_classes
|
9
|
+
platform = HotwireCombobox::Platform.new request&.user_agent
|
10
|
+
|
11
|
+
view.token_list \
|
12
|
+
"hw-combobox--ios": platform.ios?,
|
13
|
+
"hw-combobox--android": platform.android?,
|
14
|
+
"hw-combobox--mobile-webkit": platform.mobile_webkit?
|
15
|
+
end
|
16
|
+
|
17
|
+
def fieldset_data
|
18
|
+
data.merge \
|
19
|
+
async_id: canonical_id,
|
20
|
+
controller: view.token_list("hw-combobox", data[:controller]),
|
21
|
+
hw_combobox_expanded_value: open,
|
22
|
+
hw_combobox_name_when_new_value: name_when_new,
|
23
|
+
hw_combobox_original_name_value: hidden_field_name,
|
24
|
+
hw_combobox_autocomplete_value: autocomplete,
|
25
|
+
hw_combobox_small_viewport_max_width_value: mobile_at,
|
26
|
+
hw_combobox_async_src_value: async_src,
|
27
|
+
hw_combobox_prefilled_display_value: prefilled_display,
|
28
|
+
hw_combobox_selection_chip_src_value: multiselect_chip_src,
|
29
|
+
hw_combobox_filterable_attribute_value: "data-filterable-as",
|
30
|
+
hw_combobox_autocompletable_attribute_value: "data-autocompletable-as",
|
31
|
+
hw_combobox_selected_class: "hw-combobox__option--selected",
|
32
|
+
hw_combobox_invalid_class: "hw-combobox__input--invalid"
|
33
|
+
end
|
34
|
+
|
35
|
+
def prefilled_display
|
36
|
+
return if multiselect? || !hidden_field_value
|
37
|
+
|
38
|
+
if async_src && associated_object
|
39
|
+
associated_object.to_combobox_display
|
40
|
+
elsif async_src && form_object&.respond_to?(name)
|
41
|
+
form_object.public_send name
|
42
|
+
else
|
43
|
+
options.find_by_value(hidden_field_value)&.autocompletable_as || hidden_field_value
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|