hotwire_combobox 0.1.1 → 0.1.2
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/README.md +24 -1
- data/app/assets/javascripts/application.js +1 -0
- data/app/assets/javascripts/controllers/application.js +9 -0
- data/app/assets/javascripts/controllers/combobox_controller.js +210 -0
- data/app/assets/javascripts/controllers/index.js +3 -0
- data/app/helpers/combobox/helper.rb +75 -0
- data/app/views/combobox/_combobox.html.erb +19 -0
- data/config/importmap.rb +1 -0
- data/lib/combobox/engine.rb +10 -0
- data/lib/combobox/version.rb +1 -1
- data/lib/combobox.rb +0 -1
- metadata +42 -17
- data/app/assets/config/combobox_manifest.js +0 -1
- data/app/assets/stylesheets/combobox/application.css +0 -15
- data/app/controllers/combobox/application_controller.rb +0 -4
- data/app/helpers/combobox/application_helper.rb +0 -4
- data/app/jobs/combobox/application_job.rb +0 -4
- data/app/mailers/combobox/application_mailer.rb +0 -6
- data/app/models/combobox/application_record.rb +0 -5
- data/app/views/layouts/combobox/application.html.erb +0 -15
- data/config/routes.rb +0 -2
- data/lib/tasks/combobox_tasks.rake +0 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 49ff77cf8ae3aa9bcfdf0d5a76c0f58ba930e66ac7959c7fb52e592809658534
|
4
|
+
data.tar.gz: ea4b9062507631d96711d3c4cc1ea87bba7cad5ed935490aff4c863c33b6c640
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 13099362009ab729aad33c3b29399e45c1ebfe8197eed16a2f4be160a7bf2566d7b8a828d063aa39bb48ca94aa47834d585df8a01712c154870048d0670b19b0
|
7
|
+
data.tar.gz: 7dd571d6e65293c6ee93c3ba5492c1bb081d37fc261e9311c054d16ed2888dd26abee32ca6dfbec6174b0af4baedca95a85d98b0cc2a8e4a05551a5940442a28
|
data/README.md
CHANGED
@@ -22,7 +22,30 @@ $ gem install hotwire_combobox
|
|
22
22
|
```
|
23
23
|
|
24
24
|
## Contributing
|
25
|
-
|
25
|
+
|
26
|
+
### Setup
|
27
|
+
```bash
|
28
|
+
$ bin/setup
|
29
|
+
```
|
30
|
+
|
31
|
+
### Running the tests
|
32
|
+
```bash
|
33
|
+
$ bundle exec rake app:test
|
34
|
+
```
|
35
|
+
|
36
|
+
```bash
|
37
|
+
$ bundle exec rake app:test:system
|
38
|
+
```
|
39
|
+
|
40
|
+
### Running the dummy app
|
41
|
+
```bash
|
42
|
+
$ bin/rails s
|
43
|
+
```
|
44
|
+
|
45
|
+
### Releasing
|
46
|
+
|
47
|
+
1. Bump the version in `lib/hotwire_combobox/version.rb`
|
48
|
+
2. Run `bundle exec rake release`
|
26
49
|
|
27
50
|
## License
|
28
51
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
@@ -0,0 +1 @@
|
|
1
|
+
import "controllers"
|
@@ -0,0 +1,210 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static classes = [ "selected" ]
|
5
|
+
static targets = [ "combobox", "listbox", "valueField" ]
|
6
|
+
static values = { expanded: Boolean, filterableAttribute: String, autocompletableAttribute: String }
|
7
|
+
|
8
|
+
connect() {
|
9
|
+
if (this.valueFieldTarget.value) {
|
10
|
+
this.selectOptionByValue(this.valueFieldTarget.value)
|
11
|
+
}
|
12
|
+
}
|
13
|
+
|
14
|
+
open() {
|
15
|
+
this.expandedValue = true
|
16
|
+
}
|
17
|
+
|
18
|
+
close() {
|
19
|
+
this.expandedValue = false
|
20
|
+
}
|
21
|
+
|
22
|
+
selectOption(event) {
|
23
|
+
this.select(event.currentTarget)
|
24
|
+
this.commitSelection()
|
25
|
+
}
|
26
|
+
|
27
|
+
filter(event) {
|
28
|
+
const query = this.comboboxTarget.value.trim()
|
29
|
+
|
30
|
+
this.open()
|
31
|
+
|
32
|
+
this.allOptionElements.forEach(applyFilter(query, { matching: this.filterableAttributeValue }))
|
33
|
+
|
34
|
+
if (event.inputType === "deleteContentBackward") {
|
35
|
+
this.deselect(this.selectedOptionElement)
|
36
|
+
} else {
|
37
|
+
this.select(this.visibleOptionElements[0])
|
38
|
+
}
|
39
|
+
}
|
40
|
+
|
41
|
+
navigate(event) {
|
42
|
+
this.keyHandlers[event.key]?.call(this, event)
|
43
|
+
}
|
44
|
+
|
45
|
+
closeOnClickOutside({ target }) {
|
46
|
+
if (this.element.contains(target)) return
|
47
|
+
|
48
|
+
this.close()
|
49
|
+
}
|
50
|
+
|
51
|
+
closeOnFocusOutside({ target }) {
|
52
|
+
if (!this.isOpen) return
|
53
|
+
if (this.element.contains(target)) return
|
54
|
+
if (target.matches("main")) return
|
55
|
+
|
56
|
+
this.close()
|
57
|
+
}
|
58
|
+
|
59
|
+
// private
|
60
|
+
|
61
|
+
keyHandlers = {
|
62
|
+
ArrowUp(event) {
|
63
|
+
this.selectIndex(this.selectedOptionIndex - 1)
|
64
|
+
cancel(event)
|
65
|
+
},
|
66
|
+
ArrowDown(event) {
|
67
|
+
this.selectIndex(this.selectedOptionIndex + 1)
|
68
|
+
cancel(event)
|
69
|
+
},
|
70
|
+
Home(event) {
|
71
|
+
this.selectIndex(0)
|
72
|
+
cancel(event)
|
73
|
+
},
|
74
|
+
End(event) {
|
75
|
+
this.selectIndex(this.visibleOptionElements.length - 1)
|
76
|
+
cancel(event)
|
77
|
+
},
|
78
|
+
Enter(event) {
|
79
|
+
this.commitSelection()
|
80
|
+
cancel(event)
|
81
|
+
}
|
82
|
+
}
|
83
|
+
|
84
|
+
commitSelection() {
|
85
|
+
this.select(this.selectedOptionElement, { force: true })
|
86
|
+
this.close()
|
87
|
+
}
|
88
|
+
|
89
|
+
expandedValueChanged() {
|
90
|
+
if (this.expandedValue) {
|
91
|
+
this.expand()
|
92
|
+
} else {
|
93
|
+
this.collapse()
|
94
|
+
}
|
95
|
+
}
|
96
|
+
|
97
|
+
expand() {
|
98
|
+
this.listboxTarget.hidden = false
|
99
|
+
this.comboboxTarget.setAttribute("aria-expanded", true)
|
100
|
+
}
|
101
|
+
|
102
|
+
collapse() {
|
103
|
+
this.listboxTarget.hidden = true
|
104
|
+
this.comboboxTarget.setAttribute("aria-expanded", false)
|
105
|
+
}
|
106
|
+
|
107
|
+
select(option, { force = false } = {}) {
|
108
|
+
this.allOptionElements.forEach(option => this.deselect(option))
|
109
|
+
|
110
|
+
if (option) {
|
111
|
+
if (this.hasSelectedClass) option.classList.add(this.selectedClass)
|
112
|
+
this.maybeAutocompleteWith(option, { force })
|
113
|
+
this.executeSelect(option, { selected: true })
|
114
|
+
}
|
115
|
+
}
|
116
|
+
|
117
|
+
selectIndex(index) {
|
118
|
+
const option = wrapAroundAccess(this.visibleOptionElements, index)
|
119
|
+
this.select(option, { force: true })
|
120
|
+
}
|
121
|
+
|
122
|
+
selectOptionByValue(value) {
|
123
|
+
this.allOptions.find(option => option.dataset.value === value)?.click()
|
124
|
+
}
|
125
|
+
|
126
|
+
deselect(option) {
|
127
|
+
if (option) {
|
128
|
+
if (this.hasSelectedClass) option.classList.remove(this.selectedClass)
|
129
|
+
this.executeSelect(option, { selected: false })
|
130
|
+
}
|
131
|
+
}
|
132
|
+
|
133
|
+
executeSelect(option, { selected }) {
|
134
|
+
if (selected) {
|
135
|
+
option.setAttribute("aria-selected", true)
|
136
|
+
this.valueFieldTarget.value = option.dataset.value
|
137
|
+
} else {
|
138
|
+
option.setAttribute("aria-selected", false)
|
139
|
+
this.valueFieldTarget.value = null
|
140
|
+
}
|
141
|
+
}
|
142
|
+
|
143
|
+
maybeAutocompleteWith(option, { force }) {
|
144
|
+
const typedValue = this.comboboxTarget.value
|
145
|
+
const autocompletedValue = option.dataset.autocompletableAs
|
146
|
+
|
147
|
+
if (force) {
|
148
|
+
this.comboboxTarget.value = autocompletedValue
|
149
|
+
this.comboboxTarget.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
|
150
|
+
} else if (autocompletedValue.toLowerCase().startsWith(typedValue.toLowerCase())) {
|
151
|
+
this.comboboxTarget.value = autocompletedValue
|
152
|
+
this.comboboxTarget.setSelectionRange(typedValue.length, autocompletedValue.length)
|
153
|
+
}
|
154
|
+
}
|
155
|
+
|
156
|
+
get allOptions() {
|
157
|
+
return Array.from(this.allOptionElements)
|
158
|
+
}
|
159
|
+
|
160
|
+
get allOptionElements() {
|
161
|
+
return this.listboxTarget.querySelectorAll(`[${this.filterableAttributeValue}]`)
|
162
|
+
}
|
163
|
+
|
164
|
+
get visibleOptionElements() {
|
165
|
+
return [ ...this.allOptionElements ].filter(visible)
|
166
|
+
}
|
167
|
+
|
168
|
+
get selectedOptionElement() {
|
169
|
+
return this.listboxTarget.querySelector("[role=option][aria-selected=true]")
|
170
|
+
}
|
171
|
+
|
172
|
+
get selectedOptionIndex() {
|
173
|
+
return [ ...this.visibleOptionElements ].indexOf(this.selectedOptionElement)
|
174
|
+
}
|
175
|
+
|
176
|
+
get isOpen() {
|
177
|
+
return this.expandedValue
|
178
|
+
}
|
179
|
+
}
|
180
|
+
|
181
|
+
function applyFilter(query, { matching }) {
|
182
|
+
return (target) => {
|
183
|
+
if (query) {
|
184
|
+
const value = target.getAttribute(matching) || ""
|
185
|
+
const match = value.toLowerCase().includes(query.toLowerCase())
|
186
|
+
|
187
|
+
target.hidden = !match
|
188
|
+
} else {
|
189
|
+
target.hidden = false
|
190
|
+
}
|
191
|
+
}
|
192
|
+
}
|
193
|
+
|
194
|
+
function visible(target) {
|
195
|
+
return !(target.hidden || target.closest("[hidden]"))
|
196
|
+
}
|
197
|
+
|
198
|
+
function wrapAroundAccess(array, index) {
|
199
|
+
const first = 0
|
200
|
+
const last = array.length - 1
|
201
|
+
|
202
|
+
if (index < first) return array[last]
|
203
|
+
if (index > last) return array[first]
|
204
|
+
return array[index]
|
205
|
+
}
|
206
|
+
|
207
|
+
function cancel(event) {
|
208
|
+
event.stopPropagation()
|
209
|
+
event.preventDefault()
|
210
|
+
}
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module Combobox
|
2
|
+
module Helper
|
3
|
+
def combobox_tag(name, value = nil, form: nil, options: [], data: {}, input: {}, **attrs)
|
4
|
+
value_field_attrs = {}.tap do |h|
|
5
|
+
h[:id] = default_combobox_value_field_id attrs, form, name
|
6
|
+
h[:name] = default_combobox_value_field_name form, name
|
7
|
+
h[:data] = default_combobox_value_field_data
|
8
|
+
h[:value] = form&.object&.public_send(name) || value
|
9
|
+
end
|
10
|
+
|
11
|
+
attrs[:type] ||= :text
|
12
|
+
attrs[:role] = :combobox
|
13
|
+
attrs[:id] = combobox_id(value_field_attrs[:id])
|
14
|
+
attrs[:data] = default_combobox_data input.fetch(:data, {})
|
15
|
+
attrs[:aria] = default_combobox_aria value_field_attrs, input.fetch(:aria, {})
|
16
|
+
|
17
|
+
render "combobox/combobox", options: options,
|
18
|
+
attrs: attrs, value_field_attrs: value_field_attrs,
|
19
|
+
listbox_id: combobox_listbox_id(value_field_attrs[:id]),
|
20
|
+
parent_data: default_combobox_parent_data(attrs, data)
|
21
|
+
end
|
22
|
+
|
23
|
+
def value_for_listbox_option(option)
|
24
|
+
option.try(:value) || option.id
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
def default_combobox_value_field_id(attrs, form, name)
|
29
|
+
attrs.delete(:id) || form&.field_id(name)
|
30
|
+
end
|
31
|
+
|
32
|
+
def default_combobox_value_field_name(form, name)
|
33
|
+
form&.field_name(name) || name
|
34
|
+
end
|
35
|
+
|
36
|
+
def default_combobox_value_field_data
|
37
|
+
{ "combobox-target": "valueField" }
|
38
|
+
end
|
39
|
+
|
40
|
+
def default_combobox_data(data)
|
41
|
+
data.reverse_merge! \
|
42
|
+
"action": "
|
43
|
+
focus->combobox#open
|
44
|
+
input->combobox#filter
|
45
|
+
keydown->combobox#navigate
|
46
|
+
click@window->combobox#closeOnClickOutside
|
47
|
+
focusin@window->combobox#closeOnFocusOutside".squish,
|
48
|
+
"combobox-target": "combobox"
|
49
|
+
end
|
50
|
+
|
51
|
+
def default_combobox_aria(attrs, aria)
|
52
|
+
aria.reverse_merge! \
|
53
|
+
"controls": combobox_listbox_id(attrs[:id]),
|
54
|
+
"owns": combobox_listbox_id(attrs[:id]),
|
55
|
+
"haspopup": "listbox",
|
56
|
+
"autocomplete": "both"
|
57
|
+
end
|
58
|
+
|
59
|
+
def default_combobox_parent_data(attrs, data)
|
60
|
+
data.reverse_merge! \
|
61
|
+
"controller": token_list(:combobox, data.delete(:controller)),
|
62
|
+
"combobox-expanded-value": attrs.delete(:open),
|
63
|
+
"combobox-filterable-attribute-value": "data-filterable-as",
|
64
|
+
"combobox-autocompletable-attribute-value": "data-autocompletable-as"
|
65
|
+
end
|
66
|
+
|
67
|
+
def combobox_id(id)
|
68
|
+
"#{id}-combobox"
|
69
|
+
end
|
70
|
+
|
71
|
+
def combobox_listbox_id(id)
|
72
|
+
"#{id}-listbox"
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
<%= tag.div class: "hotwire-combobox", data: parent_data do %>
|
2
|
+
<%= hidden_field_tag value_field_attrs.delete(:name),
|
3
|
+
value_field_attrs.delete(:value), **value_field_attrs %>
|
4
|
+
|
5
|
+
<%= tag.input **attrs %>
|
6
|
+
|
7
|
+
<%= tag.ul id: listbox_id, hidden: "", role: :listbox,
|
8
|
+
data: { "combobox-target": "listbox" } do |ul| %>
|
9
|
+
<% options.each do |option| %>
|
10
|
+
<%= tag.li option.content, id: option.try(:id),
|
11
|
+
role: :option, style: "cursor: pointer;",
|
12
|
+
data: {
|
13
|
+
"action": "click->combobox#selectOption",
|
14
|
+
"filterable-as": option.try(:filterable_as),
|
15
|
+
"autocompletable-as": option.try(:autocompletable_as),
|
16
|
+
"value": value_for_listbox_option(option) } %>
|
17
|
+
<% end %>
|
18
|
+
<% end %>
|
19
|
+
<% end %>
|
data/config/importmap.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
pin_all_from File.expand_path("../app/assets/javascripts", __dir__)
|
data/lib/combobox/engine.rb
CHANGED
@@ -1,5 +1,15 @@
|
|
1
1
|
module Combobox
|
2
2
|
class Engine < ::Rails::Engine
|
3
3
|
isolate_namespace Combobox
|
4
|
+
|
5
|
+
initializer "combobox.view_helpers" do
|
6
|
+
ActiveSupport.on_load :action_controller do
|
7
|
+
helper Combobox::Helper
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
initializer "combobox.importmap", before: "importmap" do |app|
|
12
|
+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
|
13
|
+
end
|
4
14
|
end
|
5
15
|
end
|
data/lib/combobox/version.rb
CHANGED
data/lib/combobox.rb
CHANGED
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.
|
4
|
+
version: 0.1.2
|
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-09-
|
11
|
+
date: 2023-09-14 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -24,6 +24,34 @@ dependencies:
|
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: 7.0.7.2
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: importmap-rails
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.2'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.2'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: stimulus-rails
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.2'
|
27
55
|
description: A combobox implementation for Ruby on Rails.
|
28
56
|
email:
|
29
57
|
- jose@farias.mx
|
@@ -34,26 +62,23 @@ files:
|
|
34
62
|
- MIT-LICENSE
|
35
63
|
- README.md
|
36
64
|
- Rakefile
|
37
|
-
- app/assets/
|
38
|
-
- app/assets/
|
39
|
-
- app/controllers/
|
40
|
-
- app/
|
41
|
-
- app/
|
42
|
-
- app/
|
43
|
-
-
|
44
|
-
- app/views/layouts/combobox/application.html.erb
|
45
|
-
- config/routes.rb
|
65
|
+
- app/assets/javascripts/application.js
|
66
|
+
- app/assets/javascripts/controllers/application.js
|
67
|
+
- app/assets/javascripts/controllers/combobox_controller.js
|
68
|
+
- app/assets/javascripts/controllers/index.js
|
69
|
+
- app/helpers/combobox/helper.rb
|
70
|
+
- app/views/combobox/_combobox.html.erb
|
71
|
+
- config/importmap.rb
|
46
72
|
- lib/combobox.rb
|
47
73
|
- lib/combobox/engine.rb
|
48
74
|
- lib/combobox/version.rb
|
49
|
-
|
50
|
-
homepage: https://jose.omg.lol/
|
75
|
+
homepage: https://github.com/josefarias/hotwire_combobox
|
51
76
|
licenses:
|
52
77
|
- MIT
|
53
78
|
metadata:
|
54
|
-
homepage_uri: https://
|
55
|
-
source_code_uri: https://github.com/josefarias/
|
56
|
-
changelog_uri: https://github.com/josefarias/
|
79
|
+
homepage_uri: https://github.com/josefarias/hotwire_combobox
|
80
|
+
source_code_uri: https://github.com/josefarias/hotwire_combobox
|
81
|
+
changelog_uri: https://github.com/josefarias/hotwire_combobox
|
57
82
|
post_install_message:
|
58
83
|
rdoc_options: []
|
59
84
|
require_paths:
|
@@ -72,5 +97,5 @@ requirements: []
|
|
72
97
|
rubygems_version: 3.4.18
|
73
98
|
signing_key:
|
74
99
|
specification_version: 4
|
75
|
-
summary: A combobox implementation for Ruby on Rails
|
100
|
+
summary: A combobox implementation for Ruby on Rails
|
76
101
|
test_files: []
|
@@ -1 +0,0 @@
|
|
1
|
-
//= link_directory ../stylesheets/combobox .css
|
@@ -1,15 +0,0 @@
|
|
1
|
-
/*
|
2
|
-
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
3
|
-
* listed below.
|
4
|
-
*
|
5
|
-
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
6
|
-
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
7
|
-
*
|
8
|
-
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
9
|
-
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
10
|
-
* files in this directory. Styles in this file should be added after the last require_* statement.
|
11
|
-
* It is generally better to create a new file per style scope.
|
12
|
-
*
|
13
|
-
*= require_tree .
|
14
|
-
*= require_self
|
15
|
-
*/
|
data/config/routes.rb
DELETED