bard-tag_field 0.4.2 → 0.5.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 +4 -4
- data/Appraisals +5 -1
- data/CLAUDE.md +109 -0
- data/README.md +16 -16
- data/Rakefile +3 -3
- data/app/assets/javascripts/input-tag.js +453 -453
- data/gemfiles/rails_8.1.gemfile +7 -0
- data/{bard-tag → input-tag}/bun.lockb +0 -0
- data/{bard-tag → input-tag}/package.json +2 -1
- data/lib/bard/tag_field/form_builder.rb +4 -4
- data/lib/bard/tag_field/version.rb +1 -1
- metadata +9 -7
- /data/{bard-tag → input-tag}/.gitignore +0 -0
- /data/{bard-tag → input-tag}/index.js +0 -0
- /data/{bard-tag → input-tag}/rollup.config.js +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41638b348c696d0f3f95ae6c6c485c53d83c24df043c705cbfb52045410eee6f
|
|
4
|
+
data.tar.gz: 581a77505a56ee7c545ec14f1fb856a99af95346f02af98ddddb4703fbbbcf3f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7056e3b6f96f40a4f4b704e10b626012e5d6df73b8c6f91e189e9238bf3a69260f768e2bbe2cdcde18b9a9c493f036e3e6b5b9b999be35e098ab617308e8681
|
|
7
|
+
data.tar.gz: ea50ae8634d86c7f796fbf636475dd6805e4446abdb0b486ad2b4e63b5d8c20b63d0fc1db76e6fed23f2c48a75733aa18ddc62ded5698676929e5d799b4db75b
|
data/Appraisals
CHANGED
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
This is a Rails form helper gem that provides `tag_field` for creating interactive tag input fields. The gem wraps the [@botandrose/input-tag](https://github.com/botandrose/input-tag) custom element and integrates it with Rails form builders.
|
|
8
|
+
|
|
9
|
+
**Key Architecture:**
|
|
10
|
+
- `lib/bard/tag_field/form_builder.rb` - Rails form builder integration that handles method signature variants (like Rails' `select` helper)
|
|
11
|
+
- `lib/bard/tag_field/field.rb` - Core field rendering logic, extends `ActionView::Helpers::Tags::TextField`
|
|
12
|
+
- `lib/bard/tag_field.rb` - Rails Engine that auto-registers the form builder and precompiles JavaScript assets
|
|
13
|
+
- `input-tag/` - JavaScript build directory using Rollup to bundle the `@botandrose/input-tag` package with Bun
|
|
14
|
+
- `app/assets/javascripts/input-tag.js` - Compiled JavaScript output for Rails asset pipeline
|
|
15
|
+
|
|
16
|
+
## Development Commands
|
|
17
|
+
|
|
18
|
+
### Testing
|
|
19
|
+
```bash
|
|
20
|
+
# Run all tests
|
|
21
|
+
bundle exec rspec
|
|
22
|
+
|
|
23
|
+
# Run tests for specific Rails version
|
|
24
|
+
bundle exec appraisal rails-7.1 rspec
|
|
25
|
+
bundle exec appraisal rails-7.2 rspec
|
|
26
|
+
bundle exec appraisal rails-8.0 rspec
|
|
27
|
+
|
|
28
|
+
# Generate appraisal gemfiles after updating Appraisals
|
|
29
|
+
bundle exec appraisal install
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### JavaScript Assets
|
|
33
|
+
```bash
|
|
34
|
+
# Build JavaScript assets (required before running tests or releasing)
|
|
35
|
+
cd input-tag && bun run build
|
|
36
|
+
|
|
37
|
+
# Install Bun dependencies
|
|
38
|
+
cd input-tag && bun install
|
|
39
|
+
|
|
40
|
+
# Clean compiled assets
|
|
41
|
+
cd input-tag && bun run clean
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Gem Management
|
|
45
|
+
```bash
|
|
46
|
+
# Install the gem locally for testing
|
|
47
|
+
bundle exec rake install
|
|
48
|
+
|
|
49
|
+
# Build gem package
|
|
50
|
+
bundle exec rake build
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Form Builder Method Signatures
|
|
54
|
+
|
|
55
|
+
The `tag_field` method supports multiple signatures to match Rails conventions:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# Basic usage
|
|
59
|
+
form.tag_field :tags
|
|
60
|
+
|
|
61
|
+
# With HTML options only
|
|
62
|
+
form.tag_field :tags, class: "form-control"
|
|
63
|
+
|
|
64
|
+
# With choices (like form.select)
|
|
65
|
+
form.tag_field :tags, ["ruby", "rails", "javascript"]
|
|
66
|
+
|
|
67
|
+
# With choices and HTML options
|
|
68
|
+
form.tag_field :tags, ["ruby", "rails"], {}, { class: "form-control" }
|
|
69
|
+
|
|
70
|
+
# With nested choices [display, value]
|
|
71
|
+
form.tag_field :categories, [["Web Dev", "web"], ["ML", "ml"]]
|
|
72
|
+
|
|
73
|
+
# With block for custom rendering
|
|
74
|
+
form.tag_field :tags do |options|
|
|
75
|
+
# Custom tag-option rendering
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The FormBuilder handles signature detection in lib/bard/tag_field/form_builder.rb:6-21.
|
|
80
|
+
|
|
81
|
+
## Rendering Logic
|
|
82
|
+
|
|
83
|
+
The Field class (lib/bard/tag_field/field.rb) handles three rendering scenarios:
|
|
84
|
+
|
|
85
|
+
1. **Object values only** - Renders current object's tags as `<tag-option>` elements
|
|
86
|
+
2. **With choices** - Renders object values as `<tag-option>` and choices in a nested `<datalist>`
|
|
87
|
+
3. **With block** - Delegates content rendering to the provided block
|
|
88
|
+
|
|
89
|
+
The `build_choice_map` method (lib/bard/tag_field/field.rb:58-74) maps choice values to display labels for proper tag rendering.
|
|
90
|
+
|
|
91
|
+
## Testing
|
|
92
|
+
|
|
93
|
+
Tests use RSpec with a custom HTML matcher that supports wildcards (`...`) for flexible HTML comparison. The matcher is defined in spec/spec_helper.rb:55-152 and allows testing HTML structure without exact whitespace or attribute order matching.
|
|
94
|
+
|
|
95
|
+
Test setup includes a mock Rails application (TestApp) initialized in spec/spec_helper.rb:12-17.
|
|
96
|
+
|
|
97
|
+
## JavaScript Build Process
|
|
98
|
+
|
|
99
|
+
The gem bundles the `@botandrose/input-tag` package using Rollup with Bun:
|
|
100
|
+
1. Source: `input-tag/index.js` imports from `@botandrose/input-tag`
|
|
101
|
+
2. Build: `cd input-tag && bun run build` runs Rollup
|
|
102
|
+
3. Output: `app/assets/javascripts/input-tag.js` for Rails asset pipeline
|
|
103
|
+
4. The Engine precompiles this asset (lib/bard/tag_field.rb:11)
|
|
104
|
+
|
|
105
|
+
**Important:** Always rebuild JavaScript assets after updating the `@botandrose/input-tag` dependency.
|
|
106
|
+
|
|
107
|
+
## Multi-Rails Version Support
|
|
108
|
+
|
|
109
|
+
Uses Appraisal gem to test against Rails 7.1, 7.2, and 8.0. Gemfiles are in `gemfiles/` directory. CI tests all combinations of Ruby 3.2/3.3/3.4 with each Rails version.
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.ruby-lang.org)
|
|
5
5
|
[](https://rubyonrails.org)
|
|
6
6
|
|
|
7
|
-
A Rails form helper gem that adds `
|
|
7
|
+
A Rails form helper gem that adds `tag_field` to your forms, creating interactive tag input fields using the [@botandrose/input-tag](https://github.com/botandrose/input-tag) custom element.
|
|
8
8
|
|
|
9
9
|
Perfect for adding tag functionality to your Rails forms with a clean, modern interface that works seamlessly with your existing Rails form helpers.
|
|
10
10
|
|
|
@@ -22,11 +22,11 @@ Perfect for adding tag functionality to your Rails forms with a clean, modern in
|
|
|
22
22
|
|
|
23
23
|
### Basic Usage
|
|
24
24
|
|
|
25
|
-
After installing and requiring the gem, Use `
|
|
25
|
+
After installing and requiring the gem, Use `tag_field` in your Rails forms just like any other form helper:
|
|
26
26
|
|
|
27
27
|
```erb
|
|
28
28
|
<%= form_with model: @post do |form| %>
|
|
29
|
-
<%= form.
|
|
29
|
+
<%= form.tag_field :tags %>
|
|
30
30
|
<% end %>
|
|
31
31
|
```
|
|
32
32
|
|
|
@@ -37,7 +37,7 @@ This generates an interactive tag field that binds to your model's `tags` attrib
|
|
|
37
37
|
Add CSS classes, data attributes, and other HTML options:
|
|
38
38
|
|
|
39
39
|
```erb
|
|
40
|
-
<%= form.
|
|
40
|
+
<%= form.tag_field :tags,
|
|
41
41
|
class: "form-control",
|
|
42
42
|
id: "post-tags",
|
|
43
43
|
data: { placeholder: "Add tags..." } %>
|
|
@@ -54,7 +54,7 @@ The field automatically displays existing tags from your model:
|
|
|
54
54
|
|
|
55
55
|
```erb
|
|
56
56
|
<!-- Tags will be pre-populated in the form -->
|
|
57
|
-
<%= form.
|
|
57
|
+
<%= form.tag_field :tags %>
|
|
58
58
|
```
|
|
59
59
|
|
|
60
60
|
### With Predefined Choices (Rails select-style)
|
|
@@ -62,13 +62,13 @@ The field automatically displays existing tags from your model:
|
|
|
62
62
|
Like `form.select`, you can provide predefined choices for users to select from:
|
|
63
63
|
|
|
64
64
|
```erb
|
|
65
|
-
<%= form.
|
|
65
|
+
<%= form.tag_field :tags, ["ruby", "rails", "javascript", "css"] %>
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
Or use nested arrays for display vs submit values:
|
|
69
69
|
|
|
70
70
|
```erb
|
|
71
|
-
<%= form.
|
|
71
|
+
<%= form.tag_field :categories, [
|
|
72
72
|
["Web Development", "web-dev"],
|
|
73
73
|
["Machine Learning", "ml"],
|
|
74
74
|
["Database Design", "db"]
|
|
@@ -82,7 +82,7 @@ This creates a datalist with available options while still showing current objec
|
|
|
82
82
|
Use blocks for custom tag rendering:
|
|
83
83
|
|
|
84
84
|
```erb
|
|
85
|
-
<%= form.
|
|
85
|
+
<%= form.tag_field :tags do |options| %>
|
|
86
86
|
<% @post.tags.each do |tag| %>
|
|
87
87
|
<tag-option value="<%= tag %>" class="custom-tag"><%= tag %></tag-option>
|
|
88
88
|
<% end %>
|
|
@@ -128,7 +128,7 @@ end
|
|
|
128
128
|
```
|
|
129
129
|
|
|
130
130
|
```erb
|
|
131
|
-
<%= form.
|
|
131
|
+
<%= form.tag_field :tags_array %>
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
## Generated HTML
|
|
@@ -155,7 +155,7 @@ The gem generates semantic HTML using custom elements:
|
|
|
155
155
|
|
|
156
156
|
## JavaScript Integration
|
|
157
157
|
|
|
158
|
-
This gem works with the [@botandrose/input-tag](https://github.com/botandrose/input-tag) custom element.
|
|
158
|
+
This gem works with the [@botandrose/input-tag](https://github.com/botandrose/input-tag) custom element.
|
|
159
159
|
|
|
160
160
|
```javascript
|
|
161
161
|
// In your application.js or wherever you manage JS
|
|
@@ -176,7 +176,7 @@ Or include the precompiled asset (automatically added by this gem):
|
|
|
176
176
|
|
|
177
177
|
## API Reference
|
|
178
178
|
|
|
179
|
-
### `
|
|
179
|
+
### `tag_field(method, choices = nil, options = {}, html_options = {}, &block)`
|
|
180
180
|
|
|
181
181
|
**Parameters:**
|
|
182
182
|
- `method` - The attribute name (symbol)
|
|
@@ -190,19 +190,19 @@ Or include the precompiled asset (automatically added by this gem):
|
|
|
190
190
|
**Examples:**
|
|
191
191
|
```ruby
|
|
192
192
|
# Basic usage
|
|
193
|
-
form.
|
|
193
|
+
form.tag_field :tags
|
|
194
194
|
|
|
195
195
|
# With choices
|
|
196
|
-
form.
|
|
196
|
+
form.tag_field :tags, ["ruby", "rails", "javascript"]
|
|
197
197
|
|
|
198
198
|
# With nested choices (display vs value)
|
|
199
|
-
form.
|
|
199
|
+
form.tag_field :categories, [["Web Dev", "web"], ["ML", "ml"]]
|
|
200
200
|
|
|
201
201
|
# With HTML options
|
|
202
|
-
form.
|
|
202
|
+
form.tag_field :tags, class: "form-control", data: { max_tags: 5 }
|
|
203
203
|
|
|
204
204
|
# With choices and HTML options
|
|
205
|
-
form.
|
|
205
|
+
form.tag_field :tags, ["ruby", "rails"], {}, { class: "form-control" }
|
|
206
206
|
```
|
|
207
207
|
|
|
208
208
|
## Development
|
data/Rakefile
CHANGED
|
@@ -9,10 +9,10 @@ task default: :spec
|
|
|
9
9
|
|
|
10
10
|
desc "Build JavaScript assets"
|
|
11
11
|
task :build_js do
|
|
12
|
-
sh "cd
|
|
12
|
+
sh "cd input-tag && bun run build"
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
desc "Install
|
|
15
|
+
desc "Install Bun dependencies"
|
|
16
16
|
task :install_deps do
|
|
17
|
-
sh "cd
|
|
17
|
+
sh "cd input-tag && bun install"
|
|
18
18
|
end
|
|
@@ -543,459 +543,459 @@ class Taggle {
|
|
|
543
543
|
}
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
-
/**
|
|
547
|
-
* Copyright (c) 2016 Denis Taran
|
|
548
|
-
*
|
|
549
|
-
* Homepage: https://smartscheduling.com/en/documentation/autocomplete
|
|
550
|
-
* Source: https://github.com/denis-taran/autocomplete
|
|
551
|
-
*
|
|
552
|
-
* MIT License
|
|
553
|
-
*/
|
|
554
|
-
function autocomplete(settings) {
|
|
555
|
-
// just an alias to minimize JS file size
|
|
556
|
-
var doc = document;
|
|
557
|
-
var container = settings.container || doc.createElement('div');
|
|
558
|
-
var preventSubmit = settings.preventSubmit || 0 /* Never */;
|
|
559
|
-
container.id = container.id || 'autocomplete-' + uid();
|
|
560
|
-
var containerStyle = container.style;
|
|
561
|
-
var debounceWaitMs = settings.debounceWaitMs || 0;
|
|
562
|
-
var disableAutoSelect = settings.disableAutoSelect || false;
|
|
563
|
-
var customContainerParent = container.parentElement;
|
|
564
|
-
var items = [];
|
|
565
|
-
var inputValue = '';
|
|
566
|
-
var minLen = 2;
|
|
567
|
-
var showOnFocus = settings.showOnFocus;
|
|
568
|
-
var selected;
|
|
569
|
-
var fetchCounter = 0;
|
|
570
|
-
var debounceTimer;
|
|
571
|
-
var destroyed = false;
|
|
572
|
-
// Fixes #104: autocomplete selection is broken on Firefox for Android
|
|
573
|
-
var suppressAutocomplete = false;
|
|
574
|
-
if (settings.minLength !== undefined) {
|
|
575
|
-
minLen = settings.minLength;
|
|
576
|
-
}
|
|
577
|
-
if (!settings.input) {
|
|
578
|
-
throw new Error('input undefined');
|
|
579
|
-
}
|
|
580
|
-
var input = settings.input;
|
|
581
|
-
container.className = [container.className, 'autocomplete', settings.className || ''].join(' ').trim();
|
|
582
|
-
container.setAttribute('role', 'listbox');
|
|
583
|
-
input.setAttribute('role', 'combobox');
|
|
584
|
-
input.setAttribute('aria-expanded', 'false');
|
|
585
|
-
input.setAttribute('aria-autocomplete', 'list');
|
|
586
|
-
input.setAttribute('aria-controls', container.id);
|
|
587
|
-
input.setAttribute('aria-owns', container.id);
|
|
588
|
-
input.setAttribute('aria-activedescendant', '');
|
|
589
|
-
input.setAttribute('aria-haspopup', 'listbox');
|
|
590
|
-
// IOS implementation for fixed positioning has many bugs, so we will use absolute positioning
|
|
591
|
-
containerStyle.position = 'absolute';
|
|
592
|
-
/**
|
|
593
|
-
* Generate a very complex textual ID that greatly reduces the chance of a collision with another ID or text.
|
|
594
|
-
*/
|
|
595
|
-
function uid() {
|
|
596
|
-
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
|
597
|
-
}
|
|
598
|
-
/**
|
|
599
|
-
* Detach the container from DOM
|
|
600
|
-
*/
|
|
601
|
-
function detach() {
|
|
602
|
-
var parent = container.parentNode;
|
|
603
|
-
if (parent) {
|
|
604
|
-
parent.removeChild(container);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
/**
|
|
608
|
-
* Clear debouncing timer if assigned
|
|
609
|
-
*/
|
|
610
|
-
function clearDebounceTimer() {
|
|
611
|
-
if (debounceTimer) {
|
|
612
|
-
window.clearTimeout(debounceTimer);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
/**
|
|
616
|
-
* Attach the container to DOM
|
|
617
|
-
*/
|
|
618
|
-
function attach() {
|
|
619
|
-
if (!container.parentNode) {
|
|
620
|
-
(customContainerParent || doc.body).appendChild(container);
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* Check if container for autocomplete is displayed
|
|
625
|
-
*/
|
|
626
|
-
function containerDisplayed() {
|
|
627
|
-
return !!container.parentNode;
|
|
628
|
-
}
|
|
629
|
-
/**
|
|
630
|
-
* Clear autocomplete state and hide container
|
|
631
|
-
*/
|
|
632
|
-
function clear() {
|
|
633
|
-
// prevent the update call if there are pending AJAX requests
|
|
634
|
-
fetchCounter++;
|
|
635
|
-
items = [];
|
|
636
|
-
inputValue = '';
|
|
637
|
-
selected = undefined;
|
|
638
|
-
input.setAttribute('aria-activedescendant', '');
|
|
639
|
-
input.setAttribute('aria-expanded', 'false');
|
|
640
|
-
detach();
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* Update autocomplete position
|
|
644
|
-
*/
|
|
645
|
-
function updatePosition() {
|
|
646
|
-
if (!containerDisplayed()) {
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
input.setAttribute('aria-expanded', 'true');
|
|
650
|
-
containerStyle.height = 'auto';
|
|
651
|
-
containerStyle.width = input.offsetWidth + 'px';
|
|
652
|
-
var maxHeight = 0;
|
|
653
|
-
var inputRect;
|
|
654
|
-
function calc() {
|
|
655
|
-
var docEl = doc.documentElement;
|
|
656
|
-
var clientTop = docEl.clientTop || doc.body.clientTop || 0;
|
|
657
|
-
var clientLeft = docEl.clientLeft || doc.body.clientLeft || 0;
|
|
658
|
-
var scrollTop = window.pageYOffset || docEl.scrollTop;
|
|
659
|
-
var scrollLeft = window.pageXOffset || docEl.scrollLeft;
|
|
660
|
-
inputRect = input.getBoundingClientRect();
|
|
661
|
-
var top = inputRect.top + input.offsetHeight + scrollTop - clientTop;
|
|
662
|
-
var left = inputRect.left + scrollLeft - clientLeft;
|
|
663
|
-
containerStyle.top = top + 'px';
|
|
664
|
-
containerStyle.left = left + 'px';
|
|
665
|
-
maxHeight = window.innerHeight - (inputRect.top + input.offsetHeight);
|
|
666
|
-
if (maxHeight < 0) {
|
|
667
|
-
maxHeight = 0;
|
|
668
|
-
}
|
|
669
|
-
containerStyle.top = top + 'px';
|
|
670
|
-
containerStyle.bottom = '';
|
|
671
|
-
containerStyle.left = left + 'px';
|
|
672
|
-
containerStyle.maxHeight = maxHeight + 'px';
|
|
673
|
-
}
|
|
674
|
-
// the calc method must be called twice, otherwise the calculation may be wrong on resize event (chrome browser)
|
|
675
|
-
calc();
|
|
676
|
-
calc();
|
|
677
|
-
if (settings.customize && inputRect) {
|
|
678
|
-
settings.customize(input, inputRect, container, maxHeight);
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
/**
|
|
682
|
-
* Redraw the autocomplete div element with suggestions
|
|
683
|
-
*/
|
|
684
|
-
function update() {
|
|
685
|
-
container.textContent = '';
|
|
686
|
-
input.setAttribute('aria-activedescendant', '');
|
|
687
|
-
// function for rendering autocomplete suggestions
|
|
688
|
-
var render = function (item, _, __) {
|
|
689
|
-
var itemElement = doc.createElement('div');
|
|
690
|
-
itemElement.textContent = item.label || '';
|
|
691
|
-
return itemElement;
|
|
692
|
-
};
|
|
693
|
-
if (settings.render) {
|
|
694
|
-
render = settings.render;
|
|
695
|
-
}
|
|
696
|
-
// function to render autocomplete groups
|
|
697
|
-
var renderGroup = function (groupName, _) {
|
|
698
|
-
var groupDiv = doc.createElement('div');
|
|
699
|
-
groupDiv.textContent = groupName;
|
|
700
|
-
return groupDiv;
|
|
701
|
-
};
|
|
702
|
-
if (settings.renderGroup) {
|
|
703
|
-
renderGroup = settings.renderGroup;
|
|
704
|
-
}
|
|
705
|
-
var fragment = doc.createDocumentFragment();
|
|
706
|
-
var prevGroup = uid();
|
|
707
|
-
items.forEach(function (item, index) {
|
|
708
|
-
if (item.group && item.group !== prevGroup) {
|
|
709
|
-
prevGroup = item.group;
|
|
710
|
-
var groupDiv = renderGroup(item.group, inputValue);
|
|
711
|
-
if (groupDiv) {
|
|
712
|
-
groupDiv.className += ' group';
|
|
713
|
-
fragment.appendChild(groupDiv);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
var div = render(item, inputValue, index);
|
|
717
|
-
if (div) {
|
|
718
|
-
div.id = container.id + "_" + index;
|
|
719
|
-
div.setAttribute('role', 'option');
|
|
720
|
-
div.addEventListener('click', function (ev) {
|
|
721
|
-
suppressAutocomplete = true;
|
|
722
|
-
try {
|
|
723
|
-
settings.onSelect(item, input);
|
|
724
|
-
}
|
|
725
|
-
finally {
|
|
726
|
-
suppressAutocomplete = false;
|
|
727
|
-
}
|
|
728
|
-
clear();
|
|
729
|
-
ev.preventDefault();
|
|
730
|
-
ev.stopPropagation();
|
|
731
|
-
});
|
|
732
|
-
if (item === selected) {
|
|
733
|
-
div.className += ' selected';
|
|
734
|
-
div.setAttribute('aria-selected', 'true');
|
|
735
|
-
input.setAttribute('aria-activedescendant', div.id);
|
|
736
|
-
}
|
|
737
|
-
fragment.appendChild(div);
|
|
738
|
-
}
|
|
739
|
-
});
|
|
740
|
-
container.appendChild(fragment);
|
|
741
|
-
if (items.length < 1) {
|
|
742
|
-
if (settings.emptyMsg) {
|
|
743
|
-
var empty = doc.createElement('div');
|
|
744
|
-
empty.id = container.id + "_" + uid();
|
|
745
|
-
empty.className = 'empty';
|
|
746
|
-
empty.textContent = settings.emptyMsg;
|
|
747
|
-
container.appendChild(empty);
|
|
748
|
-
input.setAttribute('aria-activedescendant', empty.id);
|
|
749
|
-
}
|
|
750
|
-
else {
|
|
751
|
-
clear();
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
attach();
|
|
756
|
-
updatePosition();
|
|
757
|
-
updateScroll();
|
|
758
|
-
}
|
|
759
|
-
function updateIfDisplayed() {
|
|
760
|
-
if (containerDisplayed()) {
|
|
761
|
-
update();
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
function resizeEventHandler() {
|
|
765
|
-
updateIfDisplayed();
|
|
766
|
-
}
|
|
767
|
-
function scrollEventHandler(e) {
|
|
768
|
-
if (e.target !== container) {
|
|
769
|
-
updateIfDisplayed();
|
|
770
|
-
}
|
|
771
|
-
else {
|
|
772
|
-
e.preventDefault();
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
function inputEventHandler() {
|
|
776
|
-
if (!suppressAutocomplete) {
|
|
777
|
-
fetch(0 /* Keyboard */);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
/**
|
|
781
|
-
* Automatically move scroll bar if selected item is not visible
|
|
782
|
-
*/
|
|
783
|
-
function updateScroll() {
|
|
784
|
-
var elements = container.getElementsByClassName('selected');
|
|
785
|
-
if (elements.length > 0) {
|
|
786
|
-
var element = elements[0];
|
|
787
|
-
// make group visible
|
|
788
|
-
var previous = element.previousElementSibling;
|
|
789
|
-
if (previous && previous.className.indexOf('group') !== -1 && !previous.previousElementSibling) {
|
|
790
|
-
element = previous;
|
|
791
|
-
}
|
|
792
|
-
if (element.offsetTop < container.scrollTop) {
|
|
793
|
-
container.scrollTop = element.offsetTop;
|
|
794
|
-
}
|
|
795
|
-
else {
|
|
796
|
-
var selectBottom = element.offsetTop + element.offsetHeight;
|
|
797
|
-
var containerBottom = container.scrollTop + container.offsetHeight;
|
|
798
|
-
if (selectBottom > containerBottom) {
|
|
799
|
-
container.scrollTop += selectBottom - containerBottom;
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
function selectPreviousSuggestion() {
|
|
805
|
-
var index = items.indexOf(selected);
|
|
806
|
-
selected = index === -1
|
|
807
|
-
? undefined
|
|
808
|
-
: items[(index + items.length - 1) % items.length];
|
|
809
|
-
updateSelectedSuggestion(index);
|
|
810
|
-
}
|
|
811
|
-
function selectNextSuggestion() {
|
|
812
|
-
var index = items.indexOf(selected);
|
|
813
|
-
selected = items.length < 1
|
|
814
|
-
? undefined
|
|
815
|
-
: index === -1
|
|
816
|
-
? items[0]
|
|
817
|
-
: items[(index + 1) % items.length];
|
|
818
|
-
updateSelectedSuggestion(index);
|
|
819
|
-
}
|
|
820
|
-
function updateSelectedSuggestion(index) {
|
|
821
|
-
if (items.length > 0) {
|
|
822
|
-
unselectSuggestion(index);
|
|
823
|
-
selectSuggestion(items.indexOf(selected));
|
|
824
|
-
updateScroll();
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
function selectSuggestion(index) {
|
|
828
|
-
var element = doc.getElementById(container.id + "_" + index);
|
|
829
|
-
if (element) {
|
|
830
|
-
element.classList.add('selected');
|
|
831
|
-
element.setAttribute('aria-selected', 'true');
|
|
832
|
-
input.setAttribute('aria-activedescendant', element.id);
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
function unselectSuggestion(index) {
|
|
836
|
-
var element = doc.getElementById(container.id + "_" + index);
|
|
837
|
-
if (element) {
|
|
838
|
-
element.classList.remove('selected');
|
|
839
|
-
element.removeAttribute('aria-selected');
|
|
840
|
-
input.removeAttribute('aria-activedescendant');
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
function handleArrowAndEscapeKeys(ev, key) {
|
|
844
|
-
var containerIsDisplayed = containerDisplayed();
|
|
845
|
-
if (key === 'Escape') {
|
|
846
|
-
clear();
|
|
847
|
-
}
|
|
848
|
-
else {
|
|
849
|
-
if (!containerIsDisplayed || items.length < 1) {
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
key === 'ArrowUp'
|
|
853
|
-
? selectPreviousSuggestion()
|
|
854
|
-
: selectNextSuggestion();
|
|
855
|
-
}
|
|
856
|
-
ev.preventDefault();
|
|
857
|
-
if (containerIsDisplayed) {
|
|
858
|
-
ev.stopPropagation();
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
function handleEnterKey(ev) {
|
|
862
|
-
if (selected) {
|
|
863
|
-
if (preventSubmit === 2 /* OnSelect */) {
|
|
864
|
-
ev.preventDefault();
|
|
865
|
-
}
|
|
866
|
-
suppressAutocomplete = true;
|
|
867
|
-
try {
|
|
868
|
-
settings.onSelect(selected, input);
|
|
869
|
-
}
|
|
870
|
-
finally {
|
|
871
|
-
suppressAutocomplete = false;
|
|
872
|
-
}
|
|
873
|
-
clear();
|
|
874
|
-
}
|
|
875
|
-
if (preventSubmit === 1 /* Always */) {
|
|
876
|
-
ev.preventDefault();
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
function keydownEventHandler(ev) {
|
|
880
|
-
var key = ev.key;
|
|
881
|
-
switch (key) {
|
|
882
|
-
case 'ArrowUp':
|
|
883
|
-
case 'ArrowDown':
|
|
884
|
-
case 'Escape':
|
|
885
|
-
handleArrowAndEscapeKeys(ev, key);
|
|
886
|
-
break;
|
|
887
|
-
case 'Enter':
|
|
888
|
-
handleEnterKey(ev);
|
|
889
|
-
break;
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
function focusEventHandler() {
|
|
893
|
-
if (showOnFocus) {
|
|
894
|
-
fetch(1 /* Focus */);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
function fetch(trigger) {
|
|
898
|
-
if (input.value.length >= minLen || trigger === 1 /* Focus */) {
|
|
899
|
-
clearDebounceTimer();
|
|
900
|
-
debounceTimer = window.setTimeout(function () { return startFetch(input.value, trigger, input.selectionStart || 0); }, trigger === 0 /* Keyboard */ || trigger === 2 /* Mouse */ ? debounceWaitMs : 0);
|
|
901
|
-
}
|
|
902
|
-
else {
|
|
903
|
-
clear();
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
function startFetch(inputText, trigger, cursorPos) {
|
|
907
|
-
if (destroyed)
|
|
908
|
-
return;
|
|
909
|
-
var savedFetchCounter = ++fetchCounter;
|
|
910
|
-
settings.fetch(inputText, function (elements) {
|
|
911
|
-
if (fetchCounter === savedFetchCounter && elements) {
|
|
912
|
-
items = elements;
|
|
913
|
-
inputValue = inputText;
|
|
914
|
-
selected = (items.length < 1 || disableAutoSelect) ? undefined : items[0];
|
|
915
|
-
update();
|
|
916
|
-
}
|
|
917
|
-
}, trigger, cursorPos);
|
|
918
|
-
}
|
|
919
|
-
function keyupEventHandler(e) {
|
|
920
|
-
if (settings.keyup) {
|
|
921
|
-
settings.keyup({
|
|
922
|
-
event: e,
|
|
923
|
-
fetch: function () { return fetch(0 /* Keyboard */); }
|
|
924
|
-
});
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
if (!containerDisplayed() && e.key === 'ArrowDown') {
|
|
928
|
-
fetch(0 /* Keyboard */);
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
function clickEventHandler(e) {
|
|
932
|
-
settings.click && settings.click({
|
|
933
|
-
event: e,
|
|
934
|
-
fetch: function () { return fetch(2 /* Mouse */); }
|
|
935
|
-
});
|
|
936
|
-
}
|
|
937
|
-
function blurEventHandler() {
|
|
938
|
-
// when an item is selected by mouse click, the blur event will be initiated before the click event and remove DOM elements,
|
|
939
|
-
// so that the click event will never be triggered. In order to avoid this issue, DOM removal should be delayed.
|
|
940
|
-
setTimeout(function () {
|
|
941
|
-
if (doc.activeElement !== input) {
|
|
942
|
-
clear();
|
|
943
|
-
}
|
|
944
|
-
}, 200);
|
|
945
|
-
}
|
|
946
|
-
function manualFetch() {
|
|
947
|
-
startFetch(input.value, 3 /* Manual */, input.selectionStart || 0);
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* Fixes #26: on long clicks focus will be lost and onSelect method will not be called
|
|
951
|
-
*/
|
|
952
|
-
container.addEventListener('mousedown', function (evt) {
|
|
953
|
-
evt.stopPropagation();
|
|
954
|
-
evt.preventDefault();
|
|
955
|
-
});
|
|
956
|
-
/**
|
|
957
|
-
* Fixes #30: autocomplete closes when scrollbar is clicked in IE
|
|
958
|
-
* See: https://stackoverflow.com/a/9210267/13172349
|
|
959
|
-
*/
|
|
960
|
-
container.addEventListener('focus', function () { return input.focus(); });
|
|
961
|
-
// If the custom autocomplete container is already appended to the DOM during widget initialization, detach it.
|
|
962
|
-
detach();
|
|
963
|
-
/**
|
|
964
|
-
* This function will remove DOM elements and clear event handlers
|
|
965
|
-
*/
|
|
966
|
-
function destroy() {
|
|
967
|
-
input.removeEventListener('focus', focusEventHandler);
|
|
968
|
-
input.removeEventListener('keyup', keyupEventHandler);
|
|
969
|
-
input.removeEventListener('click', clickEventHandler);
|
|
970
|
-
input.removeEventListener('keydown', keydownEventHandler);
|
|
971
|
-
input.removeEventListener('input', inputEventHandler);
|
|
972
|
-
input.removeEventListener('blur', blurEventHandler);
|
|
973
|
-
window.removeEventListener('resize', resizeEventHandler);
|
|
974
|
-
doc.removeEventListener('scroll', scrollEventHandler, true);
|
|
975
|
-
input.removeAttribute('role');
|
|
976
|
-
input.removeAttribute('aria-expanded');
|
|
977
|
-
input.removeAttribute('aria-autocomplete');
|
|
978
|
-
input.removeAttribute('aria-controls');
|
|
979
|
-
input.removeAttribute('aria-activedescendant');
|
|
980
|
-
input.removeAttribute('aria-owns');
|
|
981
|
-
input.removeAttribute('aria-haspopup');
|
|
982
|
-
clearDebounceTimer();
|
|
983
|
-
clear();
|
|
984
|
-
destroyed = true;
|
|
985
|
-
}
|
|
986
|
-
// setup event handlers
|
|
987
|
-
input.addEventListener('keyup', keyupEventHandler);
|
|
988
|
-
input.addEventListener('click', clickEventHandler);
|
|
989
|
-
input.addEventListener('keydown', keydownEventHandler);
|
|
990
|
-
input.addEventListener('input', inputEventHandler);
|
|
991
|
-
input.addEventListener('blur', blurEventHandler);
|
|
992
|
-
input.addEventListener('focus', focusEventHandler);
|
|
993
|
-
window.addEventListener('resize', resizeEventHandler);
|
|
994
|
-
doc.addEventListener('scroll', scrollEventHandler, true);
|
|
995
|
-
return {
|
|
996
|
-
destroy: destroy,
|
|
997
|
-
fetch: manualFetch
|
|
998
|
-
};
|
|
546
|
+
/**
|
|
547
|
+
* Copyright (c) 2016 Denis Taran
|
|
548
|
+
*
|
|
549
|
+
* Homepage: https://smartscheduling.com/en/documentation/autocomplete
|
|
550
|
+
* Source: https://github.com/denis-taran/autocomplete
|
|
551
|
+
*
|
|
552
|
+
* MIT License
|
|
553
|
+
*/
|
|
554
|
+
function autocomplete(settings) {
|
|
555
|
+
// just an alias to minimize JS file size
|
|
556
|
+
var doc = document;
|
|
557
|
+
var container = settings.container || doc.createElement('div');
|
|
558
|
+
var preventSubmit = settings.preventSubmit || 0 /* Never */;
|
|
559
|
+
container.id = container.id || 'autocomplete-' + uid();
|
|
560
|
+
var containerStyle = container.style;
|
|
561
|
+
var debounceWaitMs = settings.debounceWaitMs || 0;
|
|
562
|
+
var disableAutoSelect = settings.disableAutoSelect || false;
|
|
563
|
+
var customContainerParent = container.parentElement;
|
|
564
|
+
var items = [];
|
|
565
|
+
var inputValue = '';
|
|
566
|
+
var minLen = 2;
|
|
567
|
+
var showOnFocus = settings.showOnFocus;
|
|
568
|
+
var selected;
|
|
569
|
+
var fetchCounter = 0;
|
|
570
|
+
var debounceTimer;
|
|
571
|
+
var destroyed = false;
|
|
572
|
+
// Fixes #104: autocomplete selection is broken on Firefox for Android
|
|
573
|
+
var suppressAutocomplete = false;
|
|
574
|
+
if (settings.minLength !== undefined) {
|
|
575
|
+
minLen = settings.minLength;
|
|
576
|
+
}
|
|
577
|
+
if (!settings.input) {
|
|
578
|
+
throw new Error('input undefined');
|
|
579
|
+
}
|
|
580
|
+
var input = settings.input;
|
|
581
|
+
container.className = [container.className, 'autocomplete', settings.className || ''].join(' ').trim();
|
|
582
|
+
container.setAttribute('role', 'listbox');
|
|
583
|
+
input.setAttribute('role', 'combobox');
|
|
584
|
+
input.setAttribute('aria-expanded', 'false');
|
|
585
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
586
|
+
input.setAttribute('aria-controls', container.id);
|
|
587
|
+
input.setAttribute('aria-owns', container.id);
|
|
588
|
+
input.setAttribute('aria-activedescendant', '');
|
|
589
|
+
input.setAttribute('aria-haspopup', 'listbox');
|
|
590
|
+
// IOS implementation for fixed positioning has many bugs, so we will use absolute positioning
|
|
591
|
+
containerStyle.position = 'absolute';
|
|
592
|
+
/**
|
|
593
|
+
* Generate a very complex textual ID that greatly reduces the chance of a collision with another ID or text.
|
|
594
|
+
*/
|
|
595
|
+
function uid() {
|
|
596
|
+
return Date.now().toString(36) + Math.random().toString(36).substring(2);
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Detach the container from DOM
|
|
600
|
+
*/
|
|
601
|
+
function detach() {
|
|
602
|
+
var parent = container.parentNode;
|
|
603
|
+
if (parent) {
|
|
604
|
+
parent.removeChild(container);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Clear debouncing timer if assigned
|
|
609
|
+
*/
|
|
610
|
+
function clearDebounceTimer() {
|
|
611
|
+
if (debounceTimer) {
|
|
612
|
+
window.clearTimeout(debounceTimer);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Attach the container to DOM
|
|
617
|
+
*/
|
|
618
|
+
function attach() {
|
|
619
|
+
if (!container.parentNode) {
|
|
620
|
+
(customContainerParent || doc.body).appendChild(container);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Check if container for autocomplete is displayed
|
|
625
|
+
*/
|
|
626
|
+
function containerDisplayed() {
|
|
627
|
+
return !!container.parentNode;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Clear autocomplete state and hide container
|
|
631
|
+
*/
|
|
632
|
+
function clear() {
|
|
633
|
+
// prevent the update call if there are pending AJAX requests
|
|
634
|
+
fetchCounter++;
|
|
635
|
+
items = [];
|
|
636
|
+
inputValue = '';
|
|
637
|
+
selected = undefined;
|
|
638
|
+
input.setAttribute('aria-activedescendant', '');
|
|
639
|
+
input.setAttribute('aria-expanded', 'false');
|
|
640
|
+
detach();
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Update autocomplete position
|
|
644
|
+
*/
|
|
645
|
+
function updatePosition() {
|
|
646
|
+
if (!containerDisplayed()) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
input.setAttribute('aria-expanded', 'true');
|
|
650
|
+
containerStyle.height = 'auto';
|
|
651
|
+
containerStyle.width = input.offsetWidth + 'px';
|
|
652
|
+
var maxHeight = 0;
|
|
653
|
+
var inputRect;
|
|
654
|
+
function calc() {
|
|
655
|
+
var docEl = doc.documentElement;
|
|
656
|
+
var clientTop = docEl.clientTop || doc.body.clientTop || 0;
|
|
657
|
+
var clientLeft = docEl.clientLeft || doc.body.clientLeft || 0;
|
|
658
|
+
var scrollTop = window.pageYOffset || docEl.scrollTop;
|
|
659
|
+
var scrollLeft = window.pageXOffset || docEl.scrollLeft;
|
|
660
|
+
inputRect = input.getBoundingClientRect();
|
|
661
|
+
var top = inputRect.top + input.offsetHeight + scrollTop - clientTop;
|
|
662
|
+
var left = inputRect.left + scrollLeft - clientLeft;
|
|
663
|
+
containerStyle.top = top + 'px';
|
|
664
|
+
containerStyle.left = left + 'px';
|
|
665
|
+
maxHeight = window.innerHeight - (inputRect.top + input.offsetHeight);
|
|
666
|
+
if (maxHeight < 0) {
|
|
667
|
+
maxHeight = 0;
|
|
668
|
+
}
|
|
669
|
+
containerStyle.top = top + 'px';
|
|
670
|
+
containerStyle.bottom = '';
|
|
671
|
+
containerStyle.left = left + 'px';
|
|
672
|
+
containerStyle.maxHeight = maxHeight + 'px';
|
|
673
|
+
}
|
|
674
|
+
// the calc method must be called twice, otherwise the calculation may be wrong on resize event (chrome browser)
|
|
675
|
+
calc();
|
|
676
|
+
calc();
|
|
677
|
+
if (settings.customize && inputRect) {
|
|
678
|
+
settings.customize(input, inputRect, container, maxHeight);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Redraw the autocomplete div element with suggestions
|
|
683
|
+
*/
|
|
684
|
+
function update() {
|
|
685
|
+
container.textContent = '';
|
|
686
|
+
input.setAttribute('aria-activedescendant', '');
|
|
687
|
+
// function for rendering autocomplete suggestions
|
|
688
|
+
var render = function (item, _, __) {
|
|
689
|
+
var itemElement = doc.createElement('div');
|
|
690
|
+
itemElement.textContent = item.label || '';
|
|
691
|
+
return itemElement;
|
|
692
|
+
};
|
|
693
|
+
if (settings.render) {
|
|
694
|
+
render = settings.render;
|
|
695
|
+
}
|
|
696
|
+
// function to render autocomplete groups
|
|
697
|
+
var renderGroup = function (groupName, _) {
|
|
698
|
+
var groupDiv = doc.createElement('div');
|
|
699
|
+
groupDiv.textContent = groupName;
|
|
700
|
+
return groupDiv;
|
|
701
|
+
};
|
|
702
|
+
if (settings.renderGroup) {
|
|
703
|
+
renderGroup = settings.renderGroup;
|
|
704
|
+
}
|
|
705
|
+
var fragment = doc.createDocumentFragment();
|
|
706
|
+
var prevGroup = uid();
|
|
707
|
+
items.forEach(function (item, index) {
|
|
708
|
+
if (item.group && item.group !== prevGroup) {
|
|
709
|
+
prevGroup = item.group;
|
|
710
|
+
var groupDiv = renderGroup(item.group, inputValue);
|
|
711
|
+
if (groupDiv) {
|
|
712
|
+
groupDiv.className += ' group';
|
|
713
|
+
fragment.appendChild(groupDiv);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
var div = render(item, inputValue, index);
|
|
717
|
+
if (div) {
|
|
718
|
+
div.id = container.id + "_" + index;
|
|
719
|
+
div.setAttribute('role', 'option');
|
|
720
|
+
div.addEventListener('click', function (ev) {
|
|
721
|
+
suppressAutocomplete = true;
|
|
722
|
+
try {
|
|
723
|
+
settings.onSelect(item, input);
|
|
724
|
+
}
|
|
725
|
+
finally {
|
|
726
|
+
suppressAutocomplete = false;
|
|
727
|
+
}
|
|
728
|
+
clear();
|
|
729
|
+
ev.preventDefault();
|
|
730
|
+
ev.stopPropagation();
|
|
731
|
+
});
|
|
732
|
+
if (item === selected) {
|
|
733
|
+
div.className += ' selected';
|
|
734
|
+
div.setAttribute('aria-selected', 'true');
|
|
735
|
+
input.setAttribute('aria-activedescendant', div.id);
|
|
736
|
+
}
|
|
737
|
+
fragment.appendChild(div);
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
container.appendChild(fragment);
|
|
741
|
+
if (items.length < 1) {
|
|
742
|
+
if (settings.emptyMsg) {
|
|
743
|
+
var empty = doc.createElement('div');
|
|
744
|
+
empty.id = container.id + "_" + uid();
|
|
745
|
+
empty.className = 'empty';
|
|
746
|
+
empty.textContent = settings.emptyMsg;
|
|
747
|
+
container.appendChild(empty);
|
|
748
|
+
input.setAttribute('aria-activedescendant', empty.id);
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
clear();
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
attach();
|
|
756
|
+
updatePosition();
|
|
757
|
+
updateScroll();
|
|
758
|
+
}
|
|
759
|
+
function updateIfDisplayed() {
|
|
760
|
+
if (containerDisplayed()) {
|
|
761
|
+
update();
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function resizeEventHandler() {
|
|
765
|
+
updateIfDisplayed();
|
|
766
|
+
}
|
|
767
|
+
function scrollEventHandler(e) {
|
|
768
|
+
if (e.target !== container) {
|
|
769
|
+
updateIfDisplayed();
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
e.preventDefault();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function inputEventHandler() {
|
|
776
|
+
if (!suppressAutocomplete) {
|
|
777
|
+
fetch(0 /* Keyboard */);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Automatically move scroll bar if selected item is not visible
|
|
782
|
+
*/
|
|
783
|
+
function updateScroll() {
|
|
784
|
+
var elements = container.getElementsByClassName('selected');
|
|
785
|
+
if (elements.length > 0) {
|
|
786
|
+
var element = elements[0];
|
|
787
|
+
// make group visible
|
|
788
|
+
var previous = element.previousElementSibling;
|
|
789
|
+
if (previous && previous.className.indexOf('group') !== -1 && !previous.previousElementSibling) {
|
|
790
|
+
element = previous;
|
|
791
|
+
}
|
|
792
|
+
if (element.offsetTop < container.scrollTop) {
|
|
793
|
+
container.scrollTop = element.offsetTop;
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
var selectBottom = element.offsetTop + element.offsetHeight;
|
|
797
|
+
var containerBottom = container.scrollTop + container.offsetHeight;
|
|
798
|
+
if (selectBottom > containerBottom) {
|
|
799
|
+
container.scrollTop += selectBottom - containerBottom;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function selectPreviousSuggestion() {
|
|
805
|
+
var index = items.indexOf(selected);
|
|
806
|
+
selected = index === -1
|
|
807
|
+
? undefined
|
|
808
|
+
: items[(index + items.length - 1) % items.length];
|
|
809
|
+
updateSelectedSuggestion(index);
|
|
810
|
+
}
|
|
811
|
+
function selectNextSuggestion() {
|
|
812
|
+
var index = items.indexOf(selected);
|
|
813
|
+
selected = items.length < 1
|
|
814
|
+
? undefined
|
|
815
|
+
: index === -1
|
|
816
|
+
? items[0]
|
|
817
|
+
: items[(index + 1) % items.length];
|
|
818
|
+
updateSelectedSuggestion(index);
|
|
819
|
+
}
|
|
820
|
+
function updateSelectedSuggestion(index) {
|
|
821
|
+
if (items.length > 0) {
|
|
822
|
+
unselectSuggestion(index);
|
|
823
|
+
selectSuggestion(items.indexOf(selected));
|
|
824
|
+
updateScroll();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
function selectSuggestion(index) {
|
|
828
|
+
var element = doc.getElementById(container.id + "_" + index);
|
|
829
|
+
if (element) {
|
|
830
|
+
element.classList.add('selected');
|
|
831
|
+
element.setAttribute('aria-selected', 'true');
|
|
832
|
+
input.setAttribute('aria-activedescendant', element.id);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
function unselectSuggestion(index) {
|
|
836
|
+
var element = doc.getElementById(container.id + "_" + index);
|
|
837
|
+
if (element) {
|
|
838
|
+
element.classList.remove('selected');
|
|
839
|
+
element.removeAttribute('aria-selected');
|
|
840
|
+
input.removeAttribute('aria-activedescendant');
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function handleArrowAndEscapeKeys(ev, key) {
|
|
844
|
+
var containerIsDisplayed = containerDisplayed();
|
|
845
|
+
if (key === 'Escape') {
|
|
846
|
+
clear();
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
if (!containerIsDisplayed || items.length < 1) {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
key === 'ArrowUp'
|
|
853
|
+
? selectPreviousSuggestion()
|
|
854
|
+
: selectNextSuggestion();
|
|
855
|
+
}
|
|
856
|
+
ev.preventDefault();
|
|
857
|
+
if (containerIsDisplayed) {
|
|
858
|
+
ev.stopPropagation();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
function handleEnterKey(ev) {
|
|
862
|
+
if (selected) {
|
|
863
|
+
if (preventSubmit === 2 /* OnSelect */) {
|
|
864
|
+
ev.preventDefault();
|
|
865
|
+
}
|
|
866
|
+
suppressAutocomplete = true;
|
|
867
|
+
try {
|
|
868
|
+
settings.onSelect(selected, input);
|
|
869
|
+
}
|
|
870
|
+
finally {
|
|
871
|
+
suppressAutocomplete = false;
|
|
872
|
+
}
|
|
873
|
+
clear();
|
|
874
|
+
}
|
|
875
|
+
if (preventSubmit === 1 /* Always */) {
|
|
876
|
+
ev.preventDefault();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
function keydownEventHandler(ev) {
|
|
880
|
+
var key = ev.key;
|
|
881
|
+
switch (key) {
|
|
882
|
+
case 'ArrowUp':
|
|
883
|
+
case 'ArrowDown':
|
|
884
|
+
case 'Escape':
|
|
885
|
+
handleArrowAndEscapeKeys(ev, key);
|
|
886
|
+
break;
|
|
887
|
+
case 'Enter':
|
|
888
|
+
handleEnterKey(ev);
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function focusEventHandler() {
|
|
893
|
+
if (showOnFocus) {
|
|
894
|
+
fetch(1 /* Focus */);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function fetch(trigger) {
|
|
898
|
+
if (input.value.length >= minLen || trigger === 1 /* Focus */) {
|
|
899
|
+
clearDebounceTimer();
|
|
900
|
+
debounceTimer = window.setTimeout(function () { return startFetch(input.value, trigger, input.selectionStart || 0); }, trigger === 0 /* Keyboard */ || trigger === 2 /* Mouse */ ? debounceWaitMs : 0);
|
|
901
|
+
}
|
|
902
|
+
else {
|
|
903
|
+
clear();
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function startFetch(inputText, trigger, cursorPos) {
|
|
907
|
+
if (destroyed)
|
|
908
|
+
return;
|
|
909
|
+
var savedFetchCounter = ++fetchCounter;
|
|
910
|
+
settings.fetch(inputText, function (elements) {
|
|
911
|
+
if (fetchCounter === savedFetchCounter && elements) {
|
|
912
|
+
items = elements;
|
|
913
|
+
inputValue = inputText;
|
|
914
|
+
selected = (items.length < 1 || disableAutoSelect) ? undefined : items[0];
|
|
915
|
+
update();
|
|
916
|
+
}
|
|
917
|
+
}, trigger, cursorPos);
|
|
918
|
+
}
|
|
919
|
+
function keyupEventHandler(e) {
|
|
920
|
+
if (settings.keyup) {
|
|
921
|
+
settings.keyup({
|
|
922
|
+
event: e,
|
|
923
|
+
fetch: function () { return fetch(0 /* Keyboard */); }
|
|
924
|
+
});
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (!containerDisplayed() && e.key === 'ArrowDown') {
|
|
928
|
+
fetch(0 /* Keyboard */);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function clickEventHandler(e) {
|
|
932
|
+
settings.click && settings.click({
|
|
933
|
+
event: e,
|
|
934
|
+
fetch: function () { return fetch(2 /* Mouse */); }
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
function blurEventHandler() {
|
|
938
|
+
// when an item is selected by mouse click, the blur event will be initiated before the click event and remove DOM elements,
|
|
939
|
+
// so that the click event will never be triggered. In order to avoid this issue, DOM removal should be delayed.
|
|
940
|
+
setTimeout(function () {
|
|
941
|
+
if (doc.activeElement !== input) {
|
|
942
|
+
clear();
|
|
943
|
+
}
|
|
944
|
+
}, 200);
|
|
945
|
+
}
|
|
946
|
+
function manualFetch() {
|
|
947
|
+
startFetch(input.value, 3 /* Manual */, input.selectionStart || 0);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Fixes #26: on long clicks focus will be lost and onSelect method will not be called
|
|
951
|
+
*/
|
|
952
|
+
container.addEventListener('mousedown', function (evt) {
|
|
953
|
+
evt.stopPropagation();
|
|
954
|
+
evt.preventDefault();
|
|
955
|
+
});
|
|
956
|
+
/**
|
|
957
|
+
* Fixes #30: autocomplete closes when scrollbar is clicked in IE
|
|
958
|
+
* See: https://stackoverflow.com/a/9210267/13172349
|
|
959
|
+
*/
|
|
960
|
+
container.addEventListener('focus', function () { return input.focus(); });
|
|
961
|
+
// If the custom autocomplete container is already appended to the DOM during widget initialization, detach it.
|
|
962
|
+
detach();
|
|
963
|
+
/**
|
|
964
|
+
* This function will remove DOM elements and clear event handlers
|
|
965
|
+
*/
|
|
966
|
+
function destroy() {
|
|
967
|
+
input.removeEventListener('focus', focusEventHandler);
|
|
968
|
+
input.removeEventListener('keyup', keyupEventHandler);
|
|
969
|
+
input.removeEventListener('click', clickEventHandler);
|
|
970
|
+
input.removeEventListener('keydown', keydownEventHandler);
|
|
971
|
+
input.removeEventListener('input', inputEventHandler);
|
|
972
|
+
input.removeEventListener('blur', blurEventHandler);
|
|
973
|
+
window.removeEventListener('resize', resizeEventHandler);
|
|
974
|
+
doc.removeEventListener('scroll', scrollEventHandler, true);
|
|
975
|
+
input.removeAttribute('role');
|
|
976
|
+
input.removeAttribute('aria-expanded');
|
|
977
|
+
input.removeAttribute('aria-autocomplete');
|
|
978
|
+
input.removeAttribute('aria-controls');
|
|
979
|
+
input.removeAttribute('aria-activedescendant');
|
|
980
|
+
input.removeAttribute('aria-owns');
|
|
981
|
+
input.removeAttribute('aria-haspopup');
|
|
982
|
+
clearDebounceTimer();
|
|
983
|
+
clear();
|
|
984
|
+
destroyed = true;
|
|
985
|
+
}
|
|
986
|
+
// setup event handlers
|
|
987
|
+
input.addEventListener('keyup', keyupEventHandler);
|
|
988
|
+
input.addEventListener('click', clickEventHandler);
|
|
989
|
+
input.addEventListener('keydown', keydownEventHandler);
|
|
990
|
+
input.addEventListener('input', inputEventHandler);
|
|
991
|
+
input.addEventListener('blur', blurEventHandler);
|
|
992
|
+
input.addEventListener('focus', focusEventHandler);
|
|
993
|
+
window.addEventListener('resize', resizeEventHandler);
|
|
994
|
+
doc.addEventListener('scroll', scrollEventHandler, true);
|
|
995
|
+
return {
|
|
996
|
+
destroy: destroy,
|
|
997
|
+
fetch: manualFetch
|
|
998
|
+
};
|
|
999
999
|
}
|
|
1000
1000
|
|
|
1001
1001
|
class TagOption extends HTMLElement {
|
|
Binary file
|
|
@@ -3,19 +3,19 @@ require_relative "field"
|
|
|
3
3
|
module Bard
|
|
4
4
|
module TagField
|
|
5
5
|
module FormBuilder
|
|
6
|
-
def
|
|
6
|
+
def tag_field method, choices = nil, options = {}, html_options = {}, &block
|
|
7
7
|
# Handle different method signatures to match Rails select helper
|
|
8
8
|
case choices
|
|
9
9
|
when Hash
|
|
10
|
-
#
|
|
10
|
+
# tag_field(:method, { class: "form-control" })
|
|
11
11
|
html_options = options
|
|
12
12
|
options = choices
|
|
13
13
|
choices = nil
|
|
14
14
|
when Array
|
|
15
|
-
#
|
|
15
|
+
# tag_field(:method, choices_array, { class: "form-control" })
|
|
16
16
|
html_options = options if options.is_a?(Hash)
|
|
17
17
|
when NilClass
|
|
18
|
-
#
|
|
18
|
+
# tag_field(:method)
|
|
19
19
|
html_options = options
|
|
20
20
|
options = {}
|
|
21
21
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bard-tag_field
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Micah Geisel
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2025-10-
|
|
11
|
+
date: 2025-10-28 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -75,22 +75,24 @@ extra_rdoc_files: []
|
|
|
75
75
|
files:
|
|
76
76
|
- ".rspec"
|
|
77
77
|
- Appraisals
|
|
78
|
+
- CLAUDE.md
|
|
78
79
|
- Gemfile
|
|
79
80
|
- LICENSE
|
|
80
81
|
- README.md
|
|
81
82
|
- Rakefile
|
|
82
83
|
- app/assets/javascripts/input-tag.js
|
|
83
|
-
- bard-tag/.gitignore
|
|
84
|
-
- bard-tag/bun.lockb
|
|
85
|
-
- bard-tag/index.js
|
|
86
|
-
- bard-tag/package.json
|
|
87
|
-
- bard-tag/rollup.config.js
|
|
88
84
|
- bard-tag_field.gemspec
|
|
89
85
|
- bin/console
|
|
90
86
|
- bin/setup
|
|
91
87
|
- gemfiles/rails_7.1.gemfile
|
|
92
88
|
- gemfiles/rails_7.2.gemfile
|
|
93
89
|
- gemfiles/rails_8.0.gemfile
|
|
90
|
+
- gemfiles/rails_8.1.gemfile
|
|
91
|
+
- input-tag/.gitignore
|
|
92
|
+
- input-tag/bun.lockb
|
|
93
|
+
- input-tag/index.js
|
|
94
|
+
- input-tag/package.json
|
|
95
|
+
- input-tag/rollup.config.js
|
|
94
96
|
- lib/bard/tag_field.rb
|
|
95
97
|
- lib/bard/tag_field/cucumber.rb
|
|
96
98
|
- lib/bard/tag_field/field.rb
|
|
File without changes
|
|
File without changes
|
|
File without changes
|