hotwire_combobox 0.1.1 → 0.1.2

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: 8042d01773c03b2a7eac8b6e1d85e2fedb7b7ca71ea36d0d7324b89ddbd72391
4
- data.tar.gz: ee22875bacb07cdc0cdc6b5911e9205d552a0f1b87f0f3a6afb3ea7305ea4575
3
+ metadata.gz: 49ff77cf8ae3aa9bcfdf0d5a76c0f58ba930e66ac7959c7fb52e592809658534
4
+ data.tar.gz: ea4b9062507631d96711d3c4cc1ea87bba7cad5ed935490aff4c863c33b6c640
5
5
  SHA512:
6
- metadata.gz: 24061168660543ad7f57605fbc91d581660e1cc28d39acd776295edec0acb6992949eff1f894f5cc3902b02dcbf53b23329670542a2f8ab5b6b3c2ec23736be0
7
- data.tar.gz: 8d6baa4a96de4ffc353ff922bcab526f338fcd4bebc36bd1ee9645fe6681294605e977d51c25a75482c9033fe4b406dad8bec7b2c356cd0e3ec8100be4276c2f
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
- Contribution directions go here.
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,9 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ // Configure Stimulus development experience
6
+ application.debug = false
7
+ window.Stimulus = application
8
+
9
+ export { application }
@@ -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,3 @@
1
+ import { application } from "controllers/application"
2
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
3
+ eagerLoadControllersFrom("controllers", application)
@@ -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 %>
@@ -0,0 +1 @@
1
+ pin_all_from File.expand_path("../app/assets/javascripts", __dir__)
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Combobox
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.2"
3
3
  end
data/lib/combobox.rb CHANGED
@@ -2,5 +2,4 @@ require "combobox/version"
2
2
  require "combobox/engine"
3
3
 
4
4
  module Combobox
5
- # Your code goes here...
6
5
  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.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-01 00:00:00.000000000 Z
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/config/combobox_manifest.js
38
- - app/assets/stylesheets/combobox/application.css
39
- - app/controllers/combobox/application_controller.rb
40
- - app/helpers/combobox/application_helper.rb
41
- - app/jobs/combobox/application_job.rb
42
- - app/mailers/combobox/application_mailer.rb
43
- - app/models/combobox/application_record.rb
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
- - lib/tasks/combobox_tasks.rake
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://jose.omg.lol/
55
- source_code_uri: https://github.com/josefarias/combobox
56
- changelog_uri: https://github.com/josefarias/combobox
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
- */
@@ -1,4 +0,0 @@
1
- module Combobox
2
- class ApplicationController < ActionController::Base
3
- end
4
- end
@@ -1,4 +0,0 @@
1
- module Combobox
2
- module ApplicationHelper
3
- end
4
- end
@@ -1,4 +0,0 @@
1
- module Combobox
2
- class ApplicationJob < ActiveJob::Base
3
- end
4
- end
@@ -1,6 +0,0 @@
1
- module Combobox
2
- class ApplicationMailer < ActionMailer::Base
3
- default from: "from@example.com"
4
- layout "mailer"
5
- end
6
- end
@@ -1,5 +0,0 @@
1
- module Combobox
2
- class ApplicationRecord < ActiveRecord::Base
3
- self.abstract_class = true
4
- end
5
- end
@@ -1,15 +0,0 @@
1
- <!DOCTYPE html>
2
- <html>
3
- <head>
4
- <title>Combobox</title>
5
- <%= csrf_meta_tags %>
6
- <%= csp_meta_tag %>
7
-
8
- <%= stylesheet_link_tag "combobox/application", media: "all" %>
9
- </head>
10
- <body>
11
-
12
- <%= yield %>
13
-
14
- </body>
15
- </html>
data/config/routes.rb DELETED
@@ -1,2 +0,0 @@
1
- Combobox::Engine.routes.draw do
2
- end
@@ -1,4 +0,0 @@
1
- # desc "Explaining what the task does"
2
- # task :combobox do
3
- # # Task goes here
4
- # end