funicular-autocomplete 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8955416147c543a0f581fcaea002aab8f6b105b183039e758f83aac2bf902380
4
+ data.tar.gz: d6df1a2d20a1f02b71eaf19d6cd677d5213c083a088d27dccfc66f6b07e91b23
5
+ SHA512:
6
+ metadata.gz: 54a6ce9085642618ef757838db1b2609f449eb55bfd679a93569f4eb1d8334549da8cf93a23f37c35ca218c924bbe18f37a1ba36d7cd6f139f3a55d8f2e77fc9
7
+ data.tar.gz: 5da71a7df70f6dfa8af524a2e054ee4c6c173518d395ba31554ffc8caba0de85426f530d8293c4b7cf8889b9753c138ae67147beefe7cc29ba75679590c4d653
data/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # funicular-autocomplete
2
+
3
+ An incremental-search combobox component for
4
+ [Funicular](https://github.com/picoruby/funicular), packaged as a
5
+ plugin gem.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ group :funicular do
12
+ gem "funicular-autocomplete"
13
+ end
14
+ ```
15
+
16
+ The component sources are prepended to your client bundle by
17
+ `funicular:compile`, and `funicular_plugin_include_tags` serves the
18
+ stylesheet.
19
+
20
+ ## Usage
21
+
22
+ ```ruby
23
+ component Funicular::Plugins::Autocomplete::Component,
24
+ key: "brewery-#{record_id}",
25
+ options: [{ "id" => 1, "label" => "Masuizumi" }],
26
+ value: selected_id,
27
+ input_id: "brewery-search",
28
+ input_class: "border rounded px-3 py-2",
29
+ placeholder: "Search breweries...",
30
+ on_select: ->(option) { @brewery_id = option["id"] }
31
+ ```
32
+
33
+ - `options` — Array of Hashes with `"id"` and `"label"`; the parent
34
+ owns the list. Filtering is a case-insensitive substring match on
35
+ `"label"`.
36
+ - `value` — optional preselected id, read once at mount to prefill the
37
+ input. Give the component a `key` that changes when the selection
38
+ context changes so it remounts.
39
+ - `on_select` — called with the chosen option Hash.
40
+ - Keyboard: ArrowDown / ArrowUp move the highlight (opening the panel
41
+ if closed), Enter selects the highlighted match, Escape closes.
42
+ Mouse selection uses mousedown so it wins over the input's blur.
43
+ - `limit` (default 20), `empty_label`, `placeholder`, `input_id`,
44
+ `input_class` — presentation knobs.
45
+
46
+ ## Test
47
+
48
+ ```console
49
+ bundle install && npm install
50
+ bundle exec rake test
51
+ ```
@@ -0,0 +1,46 @@
1
+ .funicular-autocomplete {
2
+ position: relative;
3
+ display: block;
4
+ }
5
+
6
+ /* Fill the wrapper so the input tracks the width of sibling form
7
+ fields; without this the input keeps its intrinsic size. */
8
+ .funicular-autocomplete__input {
9
+ width: 100%;
10
+ box-sizing: border-box;
11
+ }
12
+
13
+ .funicular-autocomplete__panel {
14
+ position: absolute;
15
+ top: 100%;
16
+ left: 0;
17
+ right: 0;
18
+ z-index: 40;
19
+ margin-top: 0.25rem;
20
+ max-height: 16rem;
21
+ overflow-y: auto;
22
+ background: #ffffff;
23
+ border: 1px solid #d1d5db;
24
+ border-radius: 0.375rem;
25
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
26
+ }
27
+
28
+ .funicular-autocomplete__option {
29
+ padding: 0.5rem 0.75rem;
30
+ cursor: pointer;
31
+ font-size: 0.875rem;
32
+ }
33
+
34
+ .funicular-autocomplete__option:hover {
35
+ background: #eff6ff;
36
+ }
37
+
38
+ .funicular-autocomplete__option--active {
39
+ background: #dbeafe;
40
+ }
41
+
42
+ .funicular-autocomplete__empty {
43
+ padding: 0.5rem 0.75rem;
44
+ color: #9ca3af;
45
+ font-size: 0.875rem;
46
+ }
@@ -0,0 +1,7 @@
1
+ module Funicular
2
+ module Plugins
3
+ module Autocomplete
4
+ Component = AutocompleteComponent
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,169 @@
1
+ # Incremental-search combobox over a fixed option list.
2
+ #
3
+ # component Funicular::Plugins::Autocomplete::Component,
4
+ # key: "brewery-#{record_id}", # remount to re-read value:
5
+ # options: [{ "id" => 1, "label" => "Masuizumi" }, ...],
6
+ # value: selected_id, # optional preselection
7
+ # input_id: "brewery-search",
8
+ # input_class: "...",
9
+ # placeholder: "Search...",
10
+ # empty_label: "No matches",
11
+ # limit: 20,
12
+ # on_select: ->(option) { ... } # receives the chosen Hash
13
+ #
14
+ # The parent owns the option list; filtering is a client-side
15
+ # case-insensitive substring match on "label". The `value` prop is read
16
+ # once at mount to prefill the input, so give the component a key that
17
+ # changes when the selection context changes.
18
+ class AutocompleteComponent < Funicular::Component
19
+ def initialize_state
20
+ { query: nil, open: false, highlight: 0 }
21
+ end
22
+
23
+ def component_mounted
24
+ prefill = selected_label
25
+ set_input(prefill) if prefill
26
+ end
27
+
28
+ def handle_input(_event)
29
+ node = @refs[:query_input]
30
+ patch(query: node ? node[:value].to_s : "", open: true, highlight: 0)
31
+ end
32
+
33
+ def handle_focus(_event)
34
+ patch(open: true)
35
+ end
36
+
37
+ def handle_blur(_event)
38
+ # Option mousedown fires before blur, so selection still wins.
39
+ patch(open: false)
40
+ end
41
+
42
+ def handle_key(event)
43
+ key = event[:key].to_s
44
+ if key == "Escape"
45
+ patch(open: false)
46
+ elsif key == "ArrowDown"
47
+ move_highlight(1)
48
+ elsif key == "ArrowUp"
49
+ move_highlight(-1)
50
+ elsif key == "Enter" && state[:open]
51
+ shown = shown_options
52
+ option = shown[state[:highlight]] || shown[0]
53
+ select_option(option) if option
54
+ end
55
+ end
56
+
57
+ def select_option(option)
58
+ patch(query: nil, open: false, highlight: 0)
59
+ set_input(option["label"].to_s)
60
+ callback = props[:on_select]
61
+ callback.call(option) if callback
62
+ end
63
+
64
+ def render
65
+ div(class: "funicular-autocomplete") do
66
+ input(
67
+ type: "text",
68
+ ref: :query_input,
69
+ id: props[:input_id],
70
+ class: "funicular-autocomplete__input #{props[:input_class]}",
71
+ placeholder: props[:placeholder].to_s,
72
+ autocomplete: "off",
73
+ oninput: :handle_input,
74
+ onfocus: :handle_focus,
75
+ onblur: :handle_blur,
76
+ onkeydown: :handle_key
77
+ )
78
+ if state[:open]
79
+ render_panel
80
+ end
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def move_highlight(delta)
87
+ unless state[:open]
88
+ patch(open: true, highlight: 0)
89
+ return
90
+ end
91
+ shown_size = shown_options.size
92
+ return if shown_size == 0
93
+ index = state[:highlight] + delta
94
+ index = 0 if index < 0
95
+ index = shown_size - 1 if shown_size <= index
96
+ patch(highlight: index)
97
+ scroll_highlight_into_view
98
+ end
99
+
100
+ def scroll_highlight_into_view
101
+ JS.eval(
102
+ "document.querySelectorAll('.funicular-autocomplete__option--active')" \
103
+ ".forEach(function(el){ el.scrollIntoView({block: 'nearest'}); })"
104
+ )
105
+ end
106
+
107
+ def shown_options
108
+ limit = props[:limit] || 20
109
+ filtered_options[0, limit] || []
110
+ end
111
+
112
+ def render_panel
113
+ shown = shown_options
114
+ div(class: "funicular-autocomplete__panel") do
115
+ if shown.empty?
116
+ div(class: "funicular-autocomplete__empty") do
117
+ props[:empty_label] || "No matches"
118
+ end
119
+ else
120
+ i = 0
121
+ shown_size = shown.size
122
+ while i < shown_size
123
+ option = shown[i]
124
+ option_class = "funicular-autocomplete__option"
125
+ option_class += " funicular-autocomplete__option--active" if i == state[:highlight]
126
+ div(
127
+ class: option_class,
128
+ key: option["id"].to_s,
129
+ onmousedown: -> { select_option(option) }
130
+ ) { option["label"].to_s }
131
+ i += 1
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ def filtered_options
138
+ options = props[:options] || []
139
+ query = state[:query].to_s.downcase
140
+ return options if query.empty?
141
+ result = []
142
+ i = 0
143
+ options_size = options.size
144
+ while i < options_size
145
+ option = options[i]
146
+ result << option if option["label"].to_s.downcase.include?(query)
147
+ i += 1
148
+ end
149
+ result
150
+ end
151
+
152
+ def selected_label
153
+ value = props[:value]
154
+ return nil if value.nil? || value.to_s.empty?
155
+ options = props[:options] || []
156
+ i = 0
157
+ options_size = options.size
158
+ while i < options_size
159
+ return options[i]["label"].to_s if options[i]["id"].to_s == value.to_s
160
+ i += 1
161
+ end
162
+ nil
163
+ end
164
+
165
+ def set_input(text)
166
+ node = @refs[:query_input]
167
+ node[:value] = text if node
168
+ end
169
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: funicular-autocomplete
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - HASUMI Hitoshi
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: funicular
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.4.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.4.0
26
+ description: A Funicular autocomplete (combobox) component packaged as a Ruby-script
27
+ CRubygem.
28
+ email:
29
+ - hasumikin@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - README.md
35
+ - assets/autocomplete.css
36
+ - lib/autocomplete.rb
37
+ - lib/components/autocomplete_component.rb
38
+ homepage: https://github.com/hasumikin/funicular-autocomplete
39
+ licenses:
40
+ - MIT
41
+ metadata: {}
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 3.2.0
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 4.0.10
57
+ specification_version: 4
58
+ summary: Incremental-search combobox component for Funicular
59
+ test_files: []