Skip to content

Commit

Permalink
first commit
Browse files Browse the repository at this point in the history
  • Loading branch information
afcapel committed Dec 17, 2018
0 parents commit 4646e10
Show file tree
Hide file tree
Showing 10 changed files with 4,497 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
@@ -0,0 +1,3 @@
/node_modules
yarn-error.log
.DS_Store
40 changes: 40 additions & 0 deletions README.md
@@ -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.
5 changes: 5 additions & 0 deletions examples/app.js
@@ -0,0 +1,5 @@
import { Application } from 'stimulus'
import { AutocompleteController } from '../index'

const application = Application.start()
application.register('autocomplete', AutocompleteController)
34 changes: 34 additions & 0 deletions examples/index.html
@@ -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>
3 changes: 3 additions & 0 deletions examples/results.html
@@ -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>
3 changes: 3 additions & 0 deletions index.js
@@ -0,0 +1,3 @@
import AutocompleteController from './src/autocomplete_controller'

export { AutocompleteController }
52 changes: 52 additions & 0 deletions package.json
@@ -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"
}
}
219 changes: 219 additions & 0 deletions src/autocomplete_controller.js
@@ -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")
}
}

0 comments on commit 4646e10

Please sign in to comment.