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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/MIT-LICENSE +1 -1
  3. data/README.md +19 -15
  4. data/app/assets/config/hw_combobox_manifest.js +1 -1
  5. data/app/assets/javascripts/controllers/hw_combobox_controller.js +34 -18
  6. data/app/assets/javascripts/hotwire_combobox.esm.js +147 -72
  7. data/app/assets/javascripts/hw_combobox/helpers.js +9 -0
  8. data/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +14 -4
  9. data/app/assets/javascripts/hw_combobox/models/combobox/callbacks.js +46 -0
  10. data/app/assets/javascripts/hw_combobox/models/combobox/dialog.js +1 -4
  11. data/app/assets/javascripts/hw_combobox/models/combobox/filtering.js +19 -8
  12. data/app/assets/javascripts/hw_combobox/models/combobox/form_field.js +1 -2
  13. data/app/assets/javascripts/hw_combobox/models/combobox/multiselect.js +6 -3
  14. data/app/assets/javascripts/hw_combobox/models/combobox/navigation.js +2 -2
  15. data/app/assets/javascripts/hw_combobox/models/combobox/selection.js +4 -12
  16. data/app/assets/javascripts/hw_combobox/models/combobox/toggle.js +13 -18
  17. data/app/assets/javascripts/hw_combobox/models/combobox.js +1 -0
  18. data/app/assets/stylesheets/hotwire_combobox.css +13 -3
  19. data/app/presenters/hotwire_combobox/component/announced.rb +5 -0
  20. data/app/presenters/hotwire_combobox/component/associations.rb +18 -0
  21. data/app/presenters/hotwire_combobox/component/async.rb +6 -0
  22. data/app/presenters/hotwire_combobox/component/customizable.rb +8 -20
  23. data/app/presenters/hotwire_combobox/component/freetext.rb +26 -0
  24. data/app/presenters/hotwire_combobox/component/markup/dialog.rb +57 -0
  25. data/app/presenters/hotwire_combobox/component/markup/fieldset.rb +46 -0
  26. data/app/presenters/hotwire_combobox/component/markup/form.rb +6 -0
  27. data/app/presenters/hotwire_combobox/component/markup/handle.rb +7 -0
  28. data/app/presenters/hotwire_combobox/component/markup/hidden_field.rb +28 -0
  29. data/app/presenters/hotwire_combobox/component/markup/input.rb +44 -0
  30. data/app/presenters/hotwire_combobox/component/markup/label.rb +5 -0
  31. data/app/presenters/hotwire_combobox/component/markup/listbox.rb +14 -0
  32. data/app/presenters/hotwire_combobox/component/markup/wrapper.rb +7 -0
  33. data/app/presenters/hotwire_combobox/component/multiselect.rb +6 -0
  34. data/app/presenters/hotwire_combobox/component/paginated.rb +18 -0
  35. data/app/presenters/hotwire_combobox/component.rb +32 -398
  36. data/app/presenters/hotwire_combobox/listbox/group.rb +3 -15
  37. data/app/presenters/hotwire_combobox/listbox/item.rb +5 -12
  38. data/app/presenters/hotwire_combobox/listbox/option.rb +3 -19
  39. data/app/views/hotwire_combobox/_component.html.erb +28 -6
  40. data/app/views/hotwire_combobox/_pagination.html.erb +3 -3
  41. data/config/hw_importmap.rb +1 -1
  42. data/lib/hotwire_combobox/helper.rb +24 -65
  43. data/lib/hotwire_combobox/platform.rb +15 -0
  44. data/lib/hotwire_combobox/version.rb +1 -1
  45. data/lib/hotwire_combobox.rb +1 -0
  46. metadata +34 -11
  47. data/app/assets/javascripts/hotwire_combobox.umd.js +0 -1769
  48. data/app/views/hotwire_combobox/combobox/_dialog.html.erb +0 -9
  49. data/app/views/hotwire_combobox/combobox/_hidden_field.html.erb +0 -4
  50. data/app/views/hotwire_combobox/combobox/_input.html.erb +0 -2
  51. 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
- _replaceFullQueryWithAutocompletedValue(option) {
11
+ _hardAutocomplete(option) {
12
+ const typedValue = this._typedQuery
12
13
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
13
14
 
14
15
  this._fullQuery = autocompletedValue
15
- this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
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
- _autocompleteMissingPortion(option) {
24
+ _softAutocomplete(option) {
19
25
  const typedValue = this._typedQuery
20
26
  const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
21
27
 
22
- if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
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._actingCombobox.toggleAttribute("data-queried", this._isQueried)
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.openByFocusing()
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.openByFocusing()
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.openByFocusing()
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._closeAndBlur("hw:keyHandler:enter")
34
+ this.close("hw:keyHandler:enter")
35
35
  cancel(event)
36
36
  },
37
37
  Escape: (event) => {
38
- this._closeAndBlur("hw:keyHandler:escape")
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._closeAndBlur("hw:optionRoleClick")
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._selectAndAutocompleteMissingPortion(this._ensurableOption)
23
+ this._select(this._ensurableOption, this._softAutocomplete.bind(this))
24
24
  } else if (this._isOpen && this._visibleOptionElements[0]) {
25
- this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0])
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._selectAndAutocompleteFullQuery(option)
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._closeAndBlur("hw:toggle")
36
+ this.close("hw:toggle")
42
37
  } else {
43
- this.openByFocusing()
38
+ this.open()
44
39
  }
45
40
  }
46
41
 
47
42
  closeOnClickOutside(event) {
48
43
  const target = event.target
49
44
 
50
- if (!this._isOpen) return
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._closeAndBlur("hw:clickOutside")
49
+ this.close("hw:clickOutside")
55
50
  }
56
51
 
57
52
  closeOnFocusOutside({ target }) {
58
- if (!this._isOpen) return
53
+ if (this._isClosed) return
59
54
  if (this.element.contains(target)) return
60
55
 
61
- this._closeAndBlur("hw:focusOutside")
56
+ this.close("hw:focusOutside")
62
57
  }
63
58
 
64
59
  clearOrToggleOnHandleClick() {
65
60
  if (this._isQueried) {
66
61
  this._clearQuery()
67
- this._actingCombobox.focus()
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: 4rem;
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: absolute;
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,5 @@
1
+ module HotwireCombobox::Component::Announced
2
+ def announcer_attrs
3
+ { class: "hw-combobox__announcer", data: { hw_combobox_target: "announcer" }, aria: { live: :polite, atomic: true } }
4
+ end
5
+ end
@@ -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
@@ -0,0 +1,6 @@
1
+ module HotwireCombobox::Component::Async
2
+ private
3
+ def async_src
4
+ view.hw_uri_with_params @async_src, for_id: canonical_id, format: :turbo_stream
5
+ end
6
+ 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
- label
5
+ handle
5
6
  hidden_field
6
- main_wrapper
7
7
  input
8
- handle
8
+ label
9
9
  listbox
10
- dialog
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
- customize element, **attrs
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 customize(element, **attrs)
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 apply_customizations_to(element, base: {})
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
@@ -0,0 +1,6 @@
1
+ module HotwireCombobox::Component::Markup::Form
2
+ private
3
+ def form_object
4
+ form&.object
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module HotwireCombobox::Component::Markup::Handle
2
+ def handle_attrs
3
+ customize :handle, base: {
4
+ class: "hw-combobox__handle",
5
+ data: { action: "click->hw-combobox#clearOrToggleOnHandleClick", hw_combobox_target: "handle" } }
6
+ end
7
+ end