hotwire_combobox 0.1.9 → 0.1.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 682b24c423ae14b503b0c018ef7299e89b25fb641057934a835bf615ffd9b999
4
- data.tar.gz: d588e46d58c757534239d6ccbcd85165e6ba05bfab127a5e315c9f6eb96b5168
3
+ metadata.gz: 0fabe58275ffd3010bca3cc350f94ebd673af65d7411156998418b7cd09c262e
4
+ data.tar.gz: 223c6a8e14566bc49cc900732ca234f559741b06f07c3e5d4724d9bf78b33732
5
5
  SHA512:
6
- metadata.gz: b6b954b089943a78f4cdf1a3cc4833e8ddafcf2fd5f697556c2c0cbd7ba89cf9425d065146b2046b34464c653ba9ba4bc793b349f97b17ef774aca718e721df7
7
- data.tar.gz: 9499ab225eb5b668361003e3776fa5972d3cfd2ae138b42b51138e1e976863a3aac5921e0a4f91f21a03e897e8a82ae25d2e05cc916c1a7c3b7ae012b8a6b5d7
6
+ metadata.gz: 4165ae08d560f369125e5542e26619fd2ccaea559232670d4dd1511cbf8972d7068b560ada69abb2ee61c0fad22826040bb9ced88a791192f0442e049a7c2124
7
+ data.tar.gz: 004c8565c5c9ba15950bda9e242315d2ad21f7ee3e0a28fff5c6fe6ee0b54fd495b9caed719df2ee5714cea096362e129be6339e19dd74ec82d9baf91da54672
data/README.md CHANGED
@@ -59,9 +59,6 @@ The `options` argument takes an array of any objects which respond to:
59
59
  | autocompletable_as | Used to autocomplete the input element when the user types into it. Falls back to calling `display` on the object if not provided. |
60
60
  | display | Used as a short-hand for other attributes. See the rest of the list for details. |
61
61
 
62
- > [!NOTE]
63
- > The `id` attribute is required only if `value` is not provided.
64
-
65
62
  You can use the `combobox_options` helper to create an array of option objects which respond to the above methods:
66
63
 
67
64
  ```ruby
@@ -89,7 +86,7 @@ Additionally, you can pass the following [Stimulus class values](https://stimulu
89
86
 
90
87
  ### Validity
91
88
 
92
- The hidden input can't have a value that's not in the list of options.
89
+ Unless `name_when_new` is passed, the hidden input can't have a value that's not in the list of options.
93
90
 
94
91
  If a nonexistent value is typed into the combobox, the value of the hidden input will be set empty.
95
92
 
@@ -2,12 +2,17 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static classes = [ "selected", "invalid" ]
5
- static targets = [ "combobox", "listbox", "valueField" ]
6
- static values = { expanded: Boolean, filterableAttribute: String, autocompletableAttribute: String }
5
+ static targets = [ "combobox", "listbox", "hiddenField" ]
6
+ static values = {
7
+ expanded: Boolean,
8
+ nameWhenNew: String,
9
+ originalName: String,
10
+ filterableAttribute: String,
11
+ autocompletableAttribute: String }
7
12
 
8
13
  connect() {
9
- if (this.valueFieldTarget.value) {
10
- this.selectOptionByValue(this.valueFieldTarget.value)
14
+ if (this.hiddenFieldTarget.value) {
15
+ this.selectOptionByValue(this.hiddenFieldTarget.value)
11
16
  }
12
17
  }
13
18
 
@@ -16,6 +21,7 @@ export default class extends Controller {
16
21
  }
17
22
 
18
23
  close() {
24
+ if (!this.isOpen) return
19
25
  this.commitSelection()
20
26
  this.expandedValue = false
21
27
  }
@@ -26,13 +32,16 @@ export default class extends Controller {
26
32
  }
27
33
 
28
34
  filter(event) {
35
+ const isDeleting = event.inputType === "deleteContentBackward"
29
36
  const query = this.comboboxTarget.value.trim()
30
37
 
31
38
  this.open()
32
39
 
33
40
  this.allOptionElements.forEach(applyFilter(query, { matching: this.filterableAttributeValue }))
34
41
 
35
- if (event.inputType === "deleteContentBackward") {
42
+ if (this.isValidNewOption(query, { ignoreAutocomplete: isDeleting })) {
43
+ this.selectNew(query)
44
+ } else if (isDeleting) {
36
45
  this.deselect(this.selectedOptionElement)
37
46
  } else {
38
47
  this.select(this.visibleOptionElements[0])
@@ -47,7 +56,6 @@ export default class extends Controller {
47
56
  if (this.element.contains(target)) return
48
57
 
49
58
  this.close()
50
- target.focus()
51
59
  }
52
60
 
53
61
  closeOnFocusOutside({ target }) {
@@ -56,7 +64,6 @@ export default class extends Controller {
56
64
  if (target.matches("main")) return
57
65
 
58
66
  this.close()
59
- target.focus()
60
67
  }
61
68
 
62
69
  // private
@@ -85,7 +92,9 @@ export default class extends Controller {
85
92
  }
86
93
 
87
94
  commitSelection() {
88
- this.select(this.selectedOptionElement, { force: true })
95
+ if (!this.isValidNewOption(this.comboboxTarget.value, { ignoreAutocomplete: true })) {
96
+ this.select(this.selectedOptionElement, { force: true })
97
+ }
89
98
  }
90
99
 
91
100
  expandedValueChanged() {
@@ -107,7 +116,7 @@ export default class extends Controller {
107
116
  }
108
117
 
109
118
  select(option, { force = false } = {}) {
110
- this.allOptionElements.forEach(option => this.deselect(option))
119
+ this.resetOptions()
111
120
 
112
121
  if (option) {
113
122
  if (this.hasSelectedClass) option.classList.add(this.selectedClass)
@@ -125,6 +134,17 @@ export default class extends Controller {
125
134
  }
126
135
  }
127
136
 
137
+ selectNew(query) {
138
+ this.resetOptions()
139
+ this.hiddenFieldTarget.value = query
140
+ this.hiddenFieldTarget.name = this.nameWhenNewValue
141
+ }
142
+
143
+ resetOptions() {
144
+ this.allOptionElements.forEach(option => this.deselect(option))
145
+ this.hiddenFieldTarget.name = this.originalNameValue
146
+ }
147
+
128
148
  selectIndex(index) {
129
149
  const option = wrapAroundAccess(this.visibleOptionElements, index)
130
150
  this.select(option, { force: true })
@@ -144,26 +164,34 @@ export default class extends Controller {
144
164
  executeSelect(option, { selected }) {
145
165
  if (selected) {
146
166
  option.setAttribute("aria-selected", true)
147
- this.valueFieldTarget.value = option.dataset.value
167
+ this.hiddenFieldTarget.value = option.dataset.value
148
168
  } else {
149
169
  option.setAttribute("aria-selected", false)
150
- this.valueFieldTarget.value = null
170
+ this.hiddenFieldTarget.value = null
151
171
  }
152
172
  }
153
173
 
154
174
  maybeAutocompleteWith(option, { force }) {
155
175
  const typedValue = this.comboboxTarget.value
156
- const autocompletedValue = option.dataset.autocompletableAs
176
+ const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)
157
177
 
158
178
  if (force) {
159
179
  this.comboboxTarget.value = autocompletedValue
160
180
  this.comboboxTarget.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
161
- } else if (autocompletedValue.toLowerCase().startsWith(typedValue.toLowerCase())) {
181
+ } else if (startsWith(autocompletedValue, typedValue)) {
162
182
  this.comboboxTarget.value = autocompletedValue
163
183
  this.comboboxTarget.setSelectionRange(typedValue.length, autocompletedValue.length)
164
184
  }
165
185
  }
166
186
 
187
+ isValidNewOption(query, { ignoreAutocomplete = false } = {}) {
188
+ const typedValue = this.comboboxTarget.value
189
+ const autocompletedValue = this.visibleOptionElements[0]?.getAttribute(this.autocompletableAttributeValue)
190
+ const insufficentAutocomplete = !autocompletedValue || !startsWith(autocompletedValue, typedValue)
191
+
192
+ return query.length > 0 && this.allowNew && (ignoreAutocomplete || insufficentAutocomplete)
193
+ }
194
+
167
195
  get allOptions() {
168
196
  return Array.from(this.allOptionElements)
169
197
  }
@@ -189,9 +217,13 @@ export default class extends Controller {
189
217
  }
190
218
 
191
219
  get valueIsInvalid() {
192
- const isRequiredAndEmpty = this.comboboxTarget.required && !this.valueFieldTarget.value
220
+ const isRequiredAndEmpty = this.comboboxTarget.required && !this.hiddenFieldTarget.value
193
221
  return isRequiredAndEmpty
194
222
  }
223
+
224
+ get allowNew() {
225
+ return !!this.nameWhenNewValue
226
+ }
195
227
  }
196
228
 
197
229
  function applyFilter(query, { matching }) {
@@ -224,3 +256,7 @@ function cancel(event) {
224
256
  event.stopPropagation()
225
257
  event.preventDefault()
226
258
  }
259
+
260
+ function startsWith(string, substring) {
261
+ return string.toLowerCase().startsWith(substring.toLowerCase())
262
+ }
@@ -1,105 +1,44 @@
1
1
  module HotwireCombobox
2
2
  module Helper
3
+ NonCollectionOptions = Class.new StandardError
4
+
3
5
  class << self
4
6
  delegate :bypass_convenience_methods?, to: :HotwireCombobox
5
- end
6
7
 
7
- def hw_combobox_options(options)
8
- options.map { |option| hw_combobox_option(**option) }
9
- end
10
- alias_method :combobox_options, :hw_combobox_options unless bypass_convenience_methods?
8
+ def hw_combobox_options(options, **methods)
9
+ unless options.respond_to?(:map)
10
+ raise NonCollectionOptions, "options must be an Array or an ActiveRecord::Relation"
11
+ end
11
12
 
12
- def hw_combobox_tag(name, value = nil, form: nil, options: [], data: {}, input: {}, **attrs)
13
- value_field_attrs = {}.tap do |h|
14
- h[:id] = default_hw_combobox_value_field_id attrs, form, name
15
- h[:name] = default_hw_combobox_value_field_name form, name
16
- h[:data] = default_hw_combobox_value_field_data
17
- h[:value] = form&.object&.public_send(name) || value
18
- end
19
-
20
- attrs[:type] ||= :text
21
- attrs[:role] = :combobox
22
- attrs[:id] = hw_combobox_id value_field_attrs[:id]
23
- attrs[:data] = default_hw_combobox_data input.fetch(:data, {})
24
- attrs[:aria] = default_hw_combobox_aria value_field_attrs, input.fetch(:aria, {})
25
-
26
- render "hotwire_combobox/combobox", options: options,
27
- attrs: attrs, value_field_attrs: value_field_attrs,
28
- listbox_id: hw_combobox_listbox_id(value_field_attrs[:id]),
29
- parent_data: default_hw_combobox_parent_data(attrs, data)
30
- end
31
- alias_method :combobox_tag, :hw_combobox_tag unless bypass_convenience_methods?
13
+ if ActiveRecord::Relation === options || ActiveRecord::Base === options.first
14
+ options.map do |option|
15
+ attrs = {}.tap do |attrs|
16
+ attrs[:id] = option.public_send(methods[:id]) if methods[:id]
17
+ attrs[:value] = option.public_send(methods[:value] || :id)
18
+ attrs[:display] = option.public_send(methods[:display]) if methods[:display]
19
+ end
32
20
 
33
- def hw_listbox_option_id(option)
34
- option.try(:id)
35
- end
36
-
37
- def hw_listbox_option_value(option)
38
- option.try(:value) || option.id
39
- end
21
+ hw_combobox_option(**attrs)
22
+ end
23
+ else
24
+ options.map { |option| hw_combobox_option(**option) }
25
+ end
26
+ end
40
27
 
41
- def hw_listbox_option_content(option)
42
- option.try(:content) || option.try(:display)
28
+ private
29
+ def hw_combobox_option(*args, **kwargs, &block)
30
+ HotwireCombobox::Option.new(*args, **kwargs, &block)
31
+ end
43
32
  end
44
33
 
45
- def hw_listbox_option_filterable_as(option)
46
- option.try(:filterable_as) || option.try(:display)
34
+ def hw_combobox_tag(*args, **kwargs, &block)
35
+ render "hotwire_combobox/combobox", component: HotwireCombobox::Component.new(*args, **kwargs, &block)
47
36
  end
37
+ alias_method :combobox_tag, :hw_combobox_tag unless bypass_convenience_methods?
48
38
 
49
- def hw_listbox_option_autocompletable_as(option)
50
- option.try(:autocompletable_as) || option.try(:display)
39
+ def hw_combobox_options(*args, **kwargs, &block)
40
+ HotwireCombobox::Helper.hw_combobox_options(*args, **kwargs, &block)
51
41
  end
52
-
53
- private
54
- def hw_combobox_option(...)
55
- HotwireCombobox::Option.new(...)
56
- end
57
-
58
- def default_hw_combobox_value_field_id(attrs, form, name)
59
- attrs.delete(:id) || form&.field_id(name)
60
- end
61
-
62
- def default_hw_combobox_value_field_name(form, name)
63
- form&.field_name(name) || name
64
- end
65
-
66
- def default_hw_combobox_value_field_data
67
- { "hw-combobox-target": "valueField" }
68
- end
69
-
70
- def default_hw_combobox_data(data)
71
- data.reverse_merge! \
72
- "action": "
73
- focus->hw-combobox#open
74
- input->hw-combobox#filter
75
- keydown->hw-combobox#navigate
76
- click@window->hw-combobox#closeOnClickOutside
77
- focusin@window->hw-combobox#closeOnFocusOutside".squish,
78
- "hw-combobox-target": "combobox"
79
- end
80
-
81
- def default_hw_combobox_aria(attrs, aria)
82
- aria.reverse_merge! \
83
- "controls": hw_combobox_listbox_id(attrs[:id]),
84
- "owns": hw_combobox_listbox_id(attrs[:id]),
85
- "haspopup": "listbox",
86
- "autocomplete": "both"
87
- end
88
-
89
- def default_hw_combobox_parent_data(attrs, data)
90
- data.reverse_merge! \
91
- "controller": token_list("hw-combobox", data.delete(:controller)),
92
- "hw-combobox-expanded-value": attrs.delete(:open),
93
- "hw-combobox-filterable-attribute-value": "data-filterable-as",
94
- "hw-combobox-autocompletable-attribute-value": "data-autocompletable-as"
95
- end
96
-
97
- def hw_combobox_id(id)
98
- "#{id}-hw-combobox"
99
- end
100
-
101
- def hw_combobox_listbox_id(id)
102
- "#{id}-hw-listbox"
103
- end
42
+ alias_method :combobox_options, :hw_combobox_options unless bypass_convenience_methods?
104
43
  end
105
44
  end
@@ -0,0 +1,127 @@
1
+ class HotwireCombobox::Component
2
+ include ActionView::Helpers::TagHelper
3
+
4
+ class << self
5
+ def options_from(options)
6
+ if HotwireCombobox::Option === options.first
7
+ options
8
+ else
9
+ HotwireCombobox::Helper.hw_combobox_options options, display: :to_combobox_display
10
+ end
11
+ end
12
+ end
13
+
14
+ def initialize(name, value = nil, id: nil, form: nil, name_when_new: nil, open: false, options: [], data: {}, input: {}, **rest)
15
+ @combobox_attrs = input.reverse_merge(rest).with_indifferent_access # input: {} allows for specifying e.g. data attributes on the input field
16
+ @options = self.class.options_from options
17
+ @id, @name, @value, @form, @name_when_new, @open, @data = id, name, value, form, name_when_new, open, data
18
+ end
19
+
20
+ def fieldset_attrs
21
+ {
22
+ class: "hw-combobox",
23
+ data: fieldset_data
24
+ }
25
+ end
26
+
27
+ def hidden_field_attrs
28
+ {
29
+ id: hidden_field_id,
30
+ name: hidden_field_name,
31
+ data: hidden_field_data,
32
+ value: hidden_field_value
33
+ }
34
+ end
35
+
36
+ def input_attrs
37
+ nested_attrs = %i[ data aria ]
38
+
39
+ {
40
+ id: input_id,
41
+ role: :combobox,
42
+ type: input_type,
43
+ data: input_data,
44
+ aria: input_aria
45
+ }.merge combobox_attrs.except(*nested_attrs)
46
+ end
47
+
48
+ def listbox_attrs
49
+ {
50
+ id: listbox_id,
51
+ role: :listbox,
52
+ hidden: "",
53
+ data: listbox_data
54
+ }
55
+ end
56
+
57
+ def listbox_options
58
+ options.map { |option| HotwireCombobox::Listbox::Option.new option }
59
+ end
60
+
61
+ private
62
+ attr_reader :id, :name, :value, :form, :name_when_new, :open, :options, :data, :combobox_attrs
63
+
64
+ def fieldset_data
65
+ data.reverse_merge \
66
+ controller: token_list("hw-combobox", data[:controller]),
67
+ hw_combobox_expanded_value: open,
68
+ hw_combobox_name_when_new_value: name_when_new,
69
+ hw_combobox_original_name_value: hidden_field_name,
70
+ hw_combobox_filterable_attribute_value: "data-filterable-as",
71
+ hw_combobox_autocompletable_attribute_value: "data-autocompletable-as"
72
+ end
73
+
74
+
75
+ def hidden_field_id
76
+ id || form&.field_id(name)
77
+ end
78
+
79
+ def hidden_field_name
80
+ form&.field_name(name) || name
81
+ end
82
+
83
+ def hidden_field_data
84
+ { hw_combobox_target: "hiddenField" }
85
+ end
86
+
87
+ def hidden_field_value
88
+ form&.object&.public_send(name) || value
89
+ end
90
+
91
+
92
+ def input_id
93
+ "#{hidden_field_id}-hw-combobox"
94
+ end
95
+
96
+ def input_type
97
+ combobox_attrs[:type].to_s.presence_in(%w[ text search ]) || "text"
98
+ end
99
+
100
+ def input_data
101
+ combobox_attrs.fetch(:data, {}).reverse_merge! \
102
+ action: "
103
+ focus->hw-combobox#open
104
+ input->hw-combobox#filter
105
+ keydown->hw-combobox#navigate
106
+ click@window->hw-combobox#closeOnClickOutside
107
+ focusin@window->hw-combobox#closeOnFocusOutside".squish,
108
+ hw_combobox_target: "combobox"
109
+ end
110
+
111
+ def input_aria
112
+ combobox_attrs.fetch(:aria, {}).reverse_merge! \
113
+ controls: listbox_id,
114
+ owns: listbox_id,
115
+ haspopup: "listbox",
116
+ autocomplete: "both"
117
+ end
118
+
119
+
120
+ def listbox_id
121
+ "#{hidden_field_id}-hw-listbox"
122
+ end
123
+
124
+ def listbox_data
125
+ { hw_combobox_target: "listbox" }
126
+ end
127
+ end
@@ -0,0 +1,50 @@
1
+ class HotwireCombobox::Listbox::Option
2
+ def initialize(option)
3
+ @option = option
4
+ end
5
+
6
+ def render_in(view_context)
7
+ view_context.tag.li content, **options
8
+ end
9
+
10
+ private
11
+ attr_reader :option
12
+
13
+ def content
14
+ option.try(:content) || option.try(:display)
15
+ end
16
+
17
+ def options
18
+ {
19
+ id: id,
20
+ role: :option,
21
+ style: "cursor: pointer;",
22
+ data: data
23
+ }
24
+ end
25
+
26
+ def id
27
+ option.try(:id)
28
+ end
29
+
30
+ def data
31
+ {
32
+ action: "click->hw-combobox#selectOption",
33
+ filterable_as: filterable_as,
34
+ autocompletable_as: autocompletable_as,
35
+ value: value
36
+ }
37
+ end
38
+
39
+ def filterable_as
40
+ option.try(:filterable_as) || option.try(:display)
41
+ end
42
+
43
+ def autocompletable_as
44
+ option.try(:autocompletable_as) || option.try(:display)
45
+ end
46
+
47
+ def value
48
+ option.try(:value) || option.id
49
+ end
50
+ end
@@ -1,21 +1,15 @@
1
- <%= tag.fieldset class: "hw-combobox", data: parent_data do %>
2
- <%= hidden_field_tag value_field_attrs.delete(:name),
3
- value_field_attrs.delete(:value), **value_field_attrs %>
1
+ <%= tag.fieldset **component.fieldset_attrs do %>
2
+ <% hidden_field_attrs = component.hidden_field_attrs %>
3
+ <%= hidden_field_tag \
4
+ hidden_field_attrs.delete(:name),
5
+ hidden_field_attrs.delete(:value),
6
+ **hidden_field_attrs %>
4
7
 
5
- <%= tag.input **attrs %>
8
+ <%= tag.input **component.input_attrs %>
6
9
 
7
- <%= tag.ul id: listbox_id, hidden: "", role: :listbox,
8
- data: { "hw-combobox-target": "listbox" } do |ul| %>
9
- <% options.each do |option| %>
10
- <%= tag.li hw_listbox_option_content(option),
11
- id: hw_listbox_option_id(option),
12
- role: :option,
13
- style: "cursor: pointer;",
14
- data: {
15
- "action": "click->hw-combobox#selectOption",
16
- "filterable-as": hw_listbox_option_filterable_as(option),
17
- "autocompletable-as": hw_listbox_option_autocompletable_as(option),
18
- "value": hw_listbox_option_value(option) } %>
10
+ <%= tag.ul **component.listbox_attrs do |ul| %>
11
+ <% component.listbox_options.each do |option| %>
12
+ <%= render option %>
19
13
  <% end %>
20
14
  <% end %>
21
15
  <% end %>
@@ -3,8 +3,18 @@ module HotwireCombobox
3
3
  isolate_namespace HotwireCombobox
4
4
 
5
5
  initializer "hotwire_combobox.view_helpers" do
6
- ActiveSupport.on_load :action_controller do
7
- helper HotwireCombobox::Helper
6
+ ActiveSupport.on_load :action_view do
7
+ include HotwireCombobox::Helper
8
+
9
+ unless HotwireCombobox.bypass_convenience_methods?
10
+ module FormBuilderExtensions
11
+ def combobox(*args, **kwargs, &block)
12
+ @template.hw_combobox_tag *args, **kwargs.merge(form: self), &block
13
+ end
14
+ end
15
+
16
+ ActionView::Helpers::FormBuilder.include FormBuilderExtensions
17
+ end
8
18
  end
9
19
  end
10
20
 
@@ -1,3 +1,3 @@
1
1
  module HotwireCombobox
2
- VERSION = "0.1.9"
2
+ VERSION = "0.1.11"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotwire_combobox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jose Farias
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-15 00:00:00.000000000 Z
11
+ date: 2023-12-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -67,6 +67,8 @@ files:
67
67
  - app/assets/javascripts/controllers/index.js
68
68
  - app/assets/javascripts/hotwire_combobox_application.js
69
69
  - app/helpers/hotwire_combobox/helper.rb
70
+ - app/presenters/hotwire_combobox/component.rb
71
+ - app/presenters/hotwire_combobox/listbox/option.rb
70
72
  - app/views/hotwire_combobox/_combobox.html.erb
71
73
  - config/importmap.rb
72
74
  - lib/hotwire_combobox.rb