Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 4646e10
Showing
10 changed files
with
4,497 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
/node_modules | ||
yarn-error.log | ||
.DS_Store |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
# Stimulus Autocomplete controller | ||
|
||
This is a stimulus controller to make a selection from a list that is too large | ||
to load in the browser. | ||
|
||
![Demo](https://media.giphy.com/media/5dYbYLVX4fSbbdyN84/giphy.gif) | ||
|
||
## Usage | ||
|
||
```html | ||
<div data-controller="autocomplete" data-autocomplete-url="/birds/search"> | ||
<input type="text" data-target="autocomplete.input" placeholder="search a bird"/> | ||
<input type="hidden" name="bird_id" data-target="autocomplete.hidden"/> | ||
<ul data-target="autocomplete.results"></ul> | ||
</div> | ||
``` | ||
|
||
The component makes a request to the `data-autocomplete-url` to fetch results for | ||
the contents of the input field. The server must answer with an html fragment: | ||
|
||
```html | ||
<li class="list-group-item" role="option" data-autocomplete-value="1">Blackbird</li> | ||
<li class="list-group-item" role="option" data-autocomplete-value="2">Bluebird</li> | ||
<li class="list-group-item" role="option" data-autocomplete-value="3">Mockingbird</li> | ||
``` | ||
|
||
If the controller has a `hidden` target, that field will be updated with the value | ||
of the selected option. Otherwise, the search text field will be updated. | ||
|
||
## Credits | ||
|
||
Heavily inspired on [github's autocomplete element](https://github.com/github/auto-complete-element). | ||
|
||
## Contributing | ||
|
||
Bug reports and pull requests are welcome on GitHub at <https://github.com/afcapel/stimulus-autocomplete>. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct. | ||
|
||
## License | ||
|
||
This package is available as open source under the terms of the MIT License. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { Application } from 'stimulus' | ||
import { AutocompleteController } from '../index' | ||
|
||
const application = Application.start() | ||
application.register('autocomplete', AutocompleteController) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<!doctype html> | ||
<html lang="en"> | ||
|
||
<head> | ||
<meta charset="utf-8"> | ||
<title>Stimulus Autocomplete</title> | ||
|
||
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> | ||
|
||
<style> | ||
.form-group { | ||
max-width: 300px; | ||
} | ||
</style> | ||
|
||
<script src="../assets/app.js"></script> | ||
</head> | ||
|
||
<body> | ||
<div class="container"> | ||
<h1 class="container my-5">Stimulus autocomplete example</h1> | ||
|
||
<div class="form-group"> | ||
<label for="exampleInputEmail1">Bird</label> | ||
<div data-controller="autocomplete" data-autocomplete-url="/examples/results.html"> | ||
<input type="text" class="form-control" data-target="autocomplete.input" placeholder="search a bird"/> | ||
<input type="hidden" name="bird_id" data-target="autocomplete.hidden"/> | ||
<ul data-target="autocomplete.results" class="list-group"></ul> | ||
</div> | ||
</div> | ||
</div> | ||
</body> | ||
|
||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
<li class="list-group-item" role="option" data-autocomplete-value="1">Blackbird</li> | ||
<li class="list-group-item" role="option" data-autocomplete-value="2">Bluebird</li> | ||
<li class="list-group-item" role="option" data-autocomplete-value="3">Mockingbird</li> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import AutocompleteController from './src/autocomplete_controller' | ||
|
||
export { AutocompleteController } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
{ | ||
"name": "stimulus-autocomplete", | ||
"version": "1.0.0", | ||
"description": "StimulusJS autocomplete component", | ||
"main": "index.js", | ||
"author": "Alberto Fernández-Capel <afcapel@gmail.com>", | ||
"license": "MIT", | ||
"private": false, | ||
"keywords": [ | ||
"stimulus", | ||
"stimulusjs", | ||
"controller", | ||
"components" | ||
], | ||
"dependencies": { | ||
"lodash.debounce": "^4.0.8", | ||
"stimulus": "^1.1.0" | ||
}, | ||
"babel": { | ||
"plugins": [ | ||
"transform-class-properties", | ||
"transform-runtime" | ||
], | ||
"presets": [ | ||
[ | ||
"env", | ||
{ | ||
"modules": false | ||
} | ||
] | ||
] | ||
}, | ||
"scripts": { | ||
"build": "webpack --env dev && webpack --env build", | ||
"prepublish": "yarn run build", | ||
"serve": "webpack-dev-server -d" | ||
}, | ||
"devDependencies": { | ||
"babel-core": "^6.26.3", | ||
"babel-eslint": "^8.2.3", | ||
"babel-loader": "^7.1.4", | ||
"babel-plugin-syntax-async-functions": "^6.13.0", | ||
"babel-plugin-transform-class-properties": "^6.24.1", | ||
"babel-plugin-transform-runtime": "^6.23.0", | ||
"babel-preset-env": "^1.6.1", | ||
"uglifyjs-webpack-plugin": "^1.2.5", | ||
"webpack": "^4.20.2", | ||
"webpack-cli": "^3.1.1", | ||
"webpack-dev-middleware": "^3.1.3", | ||
"webpack-dev-server": "^3.1.10" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,219 @@ | ||
import { Controller } from 'stimulus' | ||
import { debounce } from 'lodash' | ||
|
||
export default class extends Controller { | ||
static targets = [ 'input', 'hidden', 'results' ] | ||
|
||
connect() { | ||
this.resultsTarget.hidden = true | ||
|
||
this.inputTarget.setAttribute('autocomplete', 'off') | ||
this.inputTarget.setAttribute('spellcheck', 'false') | ||
|
||
this.mouseDown = false | ||
|
||
this.onInputChange = debounce(this.onInputChange.bind(this), 300) | ||
this.onResultsClick = this.onResultsClick.bind(this) | ||
this.onResultsMouseDown = this.onResultsMouseDown.bind(this) | ||
this.onInputBlur = this.onInputBlur.bind(this) | ||
this.onInputFocus = this.onInputFocus.bind(this) | ||
this.onKeydown = this.onKeydown.bind(this) | ||
|
||
this.inputTarget.addEventListener('keydown', this.onKeydown) | ||
this.inputTarget.addEventListener('focus', this.onInputFocus) | ||
this.inputTarget.addEventListener('blur', this.onInputBlur) | ||
this.inputTarget.addEventListener('input', this.onInputChange) | ||
this.resultsTarget.addEventListener('mousedown', this.onResultsMouseDown) | ||
this.resultsTarget.addEventListener('click', this.onResultsClick) | ||
} | ||
|
||
disconnect() { | ||
this.inputTarget.removeEventListener('keydown', this.onKeydown) | ||
this.inputTarget.removeEventListener('focus', this.onInputFocus) | ||
this.inputTarget.removeEventListener('blur', this.onInputBlur) | ||
this.inputTarget.removeEventListener('input', this.onInputChange) | ||
this.resultsTarget.removeEventListener('mousedown', this.onResultsMouseDown) | ||
this.resultsTarget.removeEventListener('click', this.onResultsClick) | ||
} | ||
|
||
sibling(next) { | ||
const options = Array.from(this.resultsTarget.querySelectorAll('[role="option"]')) | ||
const selected = this.resultsTarget.querySelector('[aria-selected="true"]') | ||
const index = options.indexOf(selected) | ||
const sibling = next ? options[index + 1] : options[index - 1] | ||
const def = next ? options[0] : options[options.length - 1] | ||
return sibling || def | ||
} | ||
|
||
select(target) { | ||
for (const el of this.resultsTarget.querySelectorAll('[aria-selected="true"]')) { | ||
el.removeAttribute('aria-selected') | ||
el.classList.remove('active') | ||
} | ||
target.setAttribute('aria-selected', 'true') | ||
target.classList.add('active') | ||
this.inputTarget.setAttribute('aria-activedescendant', target.id) | ||
scrollTo(this.results, target) | ||
} | ||
|
||
onKeydown(event) { | ||
switch (event.key) { | ||
case 'Escape': | ||
if (!this.resultsTarget.hidden) { | ||
this.resultsTarget.hidden = true | ||
event.stopPropagation() | ||
event.preventDefault() | ||
} | ||
break | ||
case 'ArrowDown': | ||
{ | ||
const item = this.sibling(true) | ||
if (item) this.select(item) | ||
event.preventDefault() | ||
} | ||
break | ||
case 'ArrowUp': | ||
{ | ||
const item = this.sibling(false) | ||
if (item) this.select(item) | ||
event.preventDefault() | ||
} | ||
break | ||
case 'n': | ||
if (ctrlBindings && event.ctrlKey) { | ||
const item = this.sibling(true) | ||
if (item) this.select(item) | ||
event.preventDefault() | ||
} | ||
break | ||
case 'p': | ||
if (ctrlBindings && event.ctrlKey) { | ||
const item = this.sibling(false) | ||
if (item) this.select(item) | ||
event.preventDefault() | ||
} | ||
break | ||
case 'Tab': | ||
{ | ||
const selected = this.resultsTarget.querySelector('[aria-selected="true"]') | ||
if (selected) { | ||
this.commit(selected) | ||
} | ||
} | ||
break | ||
case 'Enter': | ||
{ | ||
const selected = this.resultsTarget.querySelector('[aria-selected="true"]') | ||
if (selected && !this.resultsTarget.hidden) { | ||
this.commit(selected) | ||
event.preventDefault() | ||
} | ||
} | ||
break | ||
} | ||
} | ||
|
||
onInputFocus() { | ||
this.fetchResults() | ||
} | ||
|
||
onInputBlur() { | ||
if (this.mouseDown) return | ||
this.resultsTarget.hidden = true | ||
} | ||
|
||
commit(selected) { | ||
if (selected.getAttribute('aria-disabled') === 'true') return | ||
|
||
if (selected instanceof HTMLAnchorElement) { | ||
selected.click() | ||
this.resultsTarget.hidden = true | ||
return | ||
} | ||
|
||
const value = selected.getAttribute('data-autocomplete-value') || selected.textContent | ||
this.inputTarget.value = selected.textContent | ||
|
||
if ( this.hiddenTarget ) { | ||
this.hiddenTarget.value = value | ||
} else { | ||
this.inputTarget.value = value | ||
} | ||
|
||
this.resultsTarget.hidden = true | ||
} | ||
|
||
onResultsClick(event) { | ||
if (!(event.target instanceof Element)) return | ||
const selected = event.target.closest('[role="option"]') | ||
if (selected) this.commit(selected) | ||
} | ||
|
||
onResultsMouseDown() { | ||
this.mouseDown = true | ||
this.resultsTarget.addEventListener('mouseup', () => (this.mouseDown = false), {once: true}) | ||
} | ||
|
||
onInputChange() { | ||
this.element.removeAttribute('value') | ||
this.fetchResults() | ||
} | ||
|
||
identifyOptions() { | ||
let id = 0 | ||
for (const el of this.resultsTarget.querySelectorAll('[role="option"]:not([id])')) { | ||
el.id = `${this.resultsTarget.id}-option-${id++}` | ||
} | ||
} | ||
|
||
fetchResults() { | ||
const query = this.inputTarget.value.trim() | ||
if (!query) { | ||
this.resultsTarget.hidden = true | ||
return | ||
} | ||
|
||
if (!this.src) return | ||
|
||
const url = new URL(this.src, window.location.href) | ||
const params = new URLSearchParams(url.search.slice(1)) | ||
params.append('q', query) | ||
url.search = params.toString() | ||
|
||
this.element.dispatchEvent(new CustomEvent('loadstart')) | ||
|
||
fetch(url.toString()) | ||
.then(response => response.text()) | ||
.then(html => { | ||
this.resultsTarget.innerHTML = html | ||
this.identifyOptions() | ||
const hasResults = !!this.resultsTarget.querySelector('[role="option"]') | ||
this.resultsTarget.hidden = !hasResults | ||
this.element.dispatchEvent(new CustomEvent('load')) | ||
this.element.dispatchEvent(new CustomEvent('loadend')) | ||
}) | ||
.catch(() => { | ||
this.element.dispatchEvent(new CustomEvent('error')) | ||
this.element.dispatchEvent(new CustomEvent('loadend')) | ||
}) | ||
} | ||
|
||
open() { | ||
if (!this.resultsTarget.hidden) return | ||
this.resultsTarget.hidden = false | ||
this.element.setAttribute('aria-expanded', 'true') | ||
this.element.dispatchEvent(new CustomEvent('toggle', {detail: {input: this.input, results: this.results}})) | ||
} | ||
|
||
close() { | ||
if (this.resultsTarget.hidden) return | ||
this.resultsTarget.hidden = true | ||
this.inputTarget.removeAttribute('aria-activedescendant') | ||
this.element.setAttribute('aria-expanded', 'false') | ||
this.element.dispatchEvent(new CustomEvent('toggle', {detail: {input: this.input, results: this.results}})) | ||
} | ||
|
||
get src() { | ||
return this.data.get("url") | ||
} | ||
} |
Oops, something went wrong.