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 +7 -0
- data/README.md +51 -0
- data/assets/autocomplete.css +46 -0
- data/lib/autocomplete.rb +7 -0
- data/lib/components/autocomplete_component.rb +169 -0
- metadata +59 -0
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
|
+
}
|
data/lib/autocomplete.rb
ADDED
|
@@ -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: []
|