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 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