hotwire_combobox 0.1.9 → 0.1.11

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