├── .gitignore ├── dist ├── combobox.css └── combobox.js ├── package.json ├── LICENSE ├── gulpfile.js ├── README.md ├── src ├── combobox.scss └── combobox.js ├── .eslintrc └── example.html /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /npm-debug.log 3 | -------------------------------------------------------------------------------- /dist/combobox.css: -------------------------------------------------------------------------------- 1 | .autocomplete__label{font-size:1.5rem}.autocomplete__input{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;border:1px solid;border-radius:3px;padding:.5rem 1rem;font-size:1rem}.autocomplete__select{display:none}.autocomplete__results{position:absolute;top:100%;left:0;right:0;margin:0;padding:0;max-height:0;overflow-y:auto;-webkit-transition:all 260ms ease-in-out;transition:all 260ms ease-in-out;opacity:0;border:1px solid;border-radius:3px}.autocomplete__results--is-visible{max-height:280px;opacity:1}.autocomplete__result{list-style:none;font-size:1rem;margin:0;padding:.5rem 1rem;cursor:pointer}.autocomplete__result+.autocomplete__result{border-top:1px solid}.autocomplete__result--is-selected,.autocomplete__result:hover{background:#eee}.autocomplete__notice{position:absolute;clip:rect(1px,1px,1px,1px);padding:0;border:0;height:1px;width:1px;overflow:hidden} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "select-combobox", 3 | "version": "1.0.1", 4 | "description": "A plugin to turn a normal select element into an accesible autocomplete.", 5 | "main": "dist/combobox.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git@github.com:dfmcphee/combobox.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/dfmcphee/combobox/issues" 15 | }, 16 | "author": "Dominic McPhee (https://dfmcphee.com)", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "babel-preset-es2015": "^6.9.0", 20 | "eslint": "^3.1.1", 21 | "gulp": "^3.9.1", 22 | "gulp-autoprefixer": "^3.1.0", 23 | "gulp-babel": "^6.1.2", 24 | "gulp-clean-css": "^2.0.11", 25 | "gulp-plumber": "^1.1.0", 26 | "gulp-sass": "^2.3.2", 27 | "gulp-uglify": "^1.5.4", 28 | "gulp-webserver": "^0.9.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2016 Twitter, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const babel = require('gulp-babel'); 3 | const uglify = require('gulp-uglify'); 4 | const sass = require('gulp-sass'); 5 | const autoprefixer = require('gulp-autoprefixer'); 6 | const cleanCSS = require('gulp-clean-css'); 7 | const webserver = require('gulp-webserver'); 8 | const plumber = require('gulp-plumber'); 9 | 10 | gulp.task('sass', function () { 11 | return gulp.src('./src/combobox.scss') 12 | .pipe(sass().on('error', sass.logError)) 13 | .pipe(autoprefixer()) 14 | .pipe(cleanCSS()) 15 | .pipe(gulp.dest('./dist')); 16 | }); 17 | 18 | gulp.task('js', () => { 19 | return gulp.src('src/combobox.js') 20 | .pipe(plumber()) 21 | .pipe(babel({ 22 | presets: ['es2015'] 23 | })) 24 | .pipe(uglify()) 25 | .pipe(gulp.dest('dist')); 26 | }); 27 | 28 | gulp.task('watch', function () { 29 | gulp.watch('./src/*.js', ['js']); 30 | gulp.watch('./src/*.scss', ['sass']); 31 | }); 32 | 33 | gulp.task('serve', function() { 34 | gulp.src('./') 35 | .pipe(webserver({ 36 | livereload: true, 37 | directoryListing: true, 38 | open: '/example.html' 39 | })); 40 | }); 41 | 42 | gulp.task('default', ['watch', 'serve']); 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Combobox 2 | 3 | Combobox is a JavaScript plugin that will automatically turn a regular ol‘ `select` element into an autocomplete. It is only __6KB__ minified and doesn‘t have any dependencies. It comes with some default styles, but provides markup that can be easily customized if needed. It was built with accesibility in mind and makes full use of ARIA attributes and roles to be screen reader friendly. 4 | 5 | ## Getting started 6 | 7 | Include the styles in the head of your document. 8 | 9 | `` 10 | 11 | Include the script at the bottom of the body. 12 | 13 | `` 14 | 15 | Add a new ` 24 | 25 | 26 | 27 | 28 | 29 | ``` 30 | 31 | [View on CodePen](http://codepen.io/dfmcphee/pen/EyLbgB) 32 | 33 | ## Development 34 | `npm install && gulp` 35 | -------------------------------------------------------------------------------- /src/combobox.scss: -------------------------------------------------------------------------------- 1 | $selected-background: #eee; 2 | $border-radius: 3px; 3 | 4 | .autocomplete__label { 5 | font-size: 1.5rem; 6 | } 7 | 8 | .autocomplete__input { 9 | flex: 1 1 auto; 10 | border: solid 1px; 11 | border-radius: $border-radius; 12 | padding: 0.5rem 1rem; 13 | font-size: 1rem; 14 | } 15 | 16 | .autocomplete__select { 17 | display: none; 18 | } 19 | 20 | .autocomplete__results { 21 | position: absolute; 22 | top: 100%; 23 | left: 0; 24 | right: 0; 25 | margin: 0; 26 | padding: 0; 27 | max-height: 0; 28 | overflow-y: auto; 29 | transition: all 260ms ease-in-out; 30 | opacity: 0; 31 | border: 1px solid; 32 | border-radius: $border-radius; 33 | } 34 | 35 | .autocomplete__results--is-visible { 36 | max-height: 280px; 37 | opacity: 1; 38 | } 39 | 40 | .autocomplete__result { 41 | list-style: none; 42 | font-size: 1rem; 43 | margin: 0; 44 | padding: 0.5rem 1rem; 45 | cursor: pointer; 46 | 47 | + .autocomplete__result { 48 | border-top: 1px solid; 49 | } 50 | 51 | &:hover { 52 | background: $selected-background; 53 | } 54 | } 55 | 56 | .autocomplete__result--is-selected { 57 | background: $selected-background; 58 | } 59 | 60 | .autocomplete__notice { 61 | position: absolute; 62 | clip: rect(1px, 1px, 1px, 1px); 63 | padding: 0; 64 | border: 0; 65 | height: 1px; 66 | width: 1px; 67 | overflow: hidden; 68 | } 69 | -------------------------------------------------------------------------------- /dist/combobox.js: -------------------------------------------------------------------------------- 1 | "use strict";function _classCallCheck(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}var _createClass=function(){function t(t,e){for(var s=0;s0?this.results.forEach(function(s){var i=document.createElement("li");i.setAttribute("id",s.id),i.classList.add(e.RESULT.BASE),i.textContent=s.label,i.dataset.value=s.value,i.setAttribute("role","option"),i.addEventListener("click",function(e){t.selectOption(e.target,function(){return t.chooseOption()})}),window.requestAnimationFrame(function(){t.resultsList.appendChild(i)})}):!function(){var s=document.createElement("li");s.classList.add(e.RESULT.BASE),s.textContent="No results found",window.requestAnimationFrame(function(){t.resultsList.appendChild(s)})}(),this.showResults()}},{key:"showResults",value:function(){var t=this;this.isVisible=!0,window.requestAnimationFrame(function(){t.resultsList.classList.add(e.RESULTS.VISIBLE),t.input.setAttribute("aria-expanded","true"),0===t.results.length?t.resultsNotice.textContent="No results found":1===t.results.length?t.resultsNotice.textContent="1 result":t.resultsNotice.textContent=t.results.length+" results"})}},{key:"outputInput",value:function(t){var s=this;this.input=document.createElement("input"),this.input.type="text",this.input.setAttribute("role","combobox"),this.input.setAttribute("aria-label","Search and select an option for "+this.label.textContent),this.input.setAttribute("aria-expanded","false"),this.input.setAttribute("aria-autocomplete","list"),this.input.setAttribute("aria-owns",t),this.input.classList.add(e.INPUT),window.requestAnimationFrame(function(){s.container.appendChild(s.input)})}},{key:"outputResultsList",value:function(t){var s=this;this.resultsList=document.createElement("ul"),this.resultsList.classList.add(e.RESULTS.BASE),this.resultsList.setAttribute("id",t),this.resultsList.setAttribute("role","listbox"),window.requestAnimationFrame(function(){s.container.appendChild(s.resultsList)})}},{key:"outputResultsNotice",value:function(){var t=this;this.resultsNotice=document.createElement("div"),this.resultsNotice.classList.add(e.NOTICE),this.resultsNotice.setAttribute("role","status"),this.resultsNotice.setAttribute("aria-live","polite"),window.requestAnimationFrame(function(){t.container.appendChild(t.resultsNotice)})}},{key:"keydownEvent",value:function(t){if(this.container.contains(t.target))switch(t.keyCode){case s.ENTER:this.chooseOption();break;case s.ESC:this.hideResults(),this.input.blur();break;case s.DOWN:this.isVisible?this.selectNextOption():this.showResults(),t.preventDefault();break;case s.UP:t.preventDefault(),this.selectPreviousOption()}}}]),t}();document.addEventListener("DOMContentLoaded",function(){var s=[].slice.call(document.querySelectorAll("."+e.BASE));s.forEach(t)})}(); -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "espree", 3 | "parserOptions": { 4 | "ecmaVersion": 7, 5 | "sourceType": 'module', 6 | }, 7 | "ecmaFeatures": {}, 8 | "globals": { 9 | "window": true, 10 | "document": true, 11 | "require": true 12 | }, 13 | "rules": { 14 | "no-alert": "off", 15 | "no-array-constructor": "off", 16 | "no-bitwise": "off", 17 | "no-caller": "off", 18 | "no-case-declarations": "error", 19 | "no-catch-shadow": "off", 20 | "no-class-assign": "error", 21 | "no-cond-assign": "error", 22 | "no-confusing-arrow": "off", 23 | "no-console": "error", 24 | "no-const-assign": "error", 25 | "no-constant-condition": "error", 26 | "no-continue": "off", 27 | "no-control-regex": "error", 28 | "no-debugger": "error", 29 | "no-delete-var": "error", 30 | "no-div-regex": "off", 31 | "no-dupe-args": "error", 32 | "no-dupe-class-members": "error", 33 | "no-dupe-keys": "error", 34 | "no-duplicate-case": "error", 35 | "no-duplicate-imports": "off", 36 | "no-else-return": "off", 37 | "no-empty": "error", 38 | "no-empty-character-class": "error", 39 | "no-empty-function": "off", 40 | "no-empty-pattern": "error", 41 | "no-eq-null": "off", 42 | "no-eval": "off", 43 | "no-ex-assign": "error", 44 | "no-extend-native": "off", 45 | "no-extra-bind": "off", 46 | "no-extra-boolean-cast": "error", 47 | "no-extra-label": "off", 48 | "no-extra-parens": "off", 49 | "no-extra-semi": "error", 50 | "no-fallthrough": "error", 51 | "no-floating-decimal": "off", 52 | "no-func-assign": "error", 53 | "no-implicit-coercion": "off", 54 | "no-implicit-globals": "off", 55 | "no-implied-eval": "off", 56 | "no-inline-comments": "off", 57 | "no-inner-declarations": "error", 58 | "no-invalid-regexp": "error", 59 | "no-invalid-this": "off", 60 | "no-irregular-whitespace": "error", 61 | "no-iterator": "off", 62 | "no-label-var": "off", 63 | "no-labels": "off", 64 | "no-lone-blocks": "off", 65 | "no-lonely-if": "off", 66 | "no-loop-func": "off", 67 | "no-magic-numbers": "off", 68 | "no-mixed-operators": "off", 69 | "no-mixed-requires": "off", 70 | "no-mixed-spaces-and-tabs": "error", 71 | "no-multi-spaces": "off", 72 | "no-multi-str": "off", 73 | "no-multiple-empty-lines": "off", 74 | "no-native-reassign": "error", 75 | "no-negated-condition": "off", 76 | "no-negated-in-lhs": "error", 77 | "no-nested-ternary": "off", 78 | "no-new": "off", 79 | "no-new-func": "off", 80 | "no-new-object": "off", 81 | "no-new-require": "off", 82 | "no-new-symbol": "error", 83 | "no-new-wrappers": "off", 84 | "no-obj-calls": "error", 85 | "no-octal": "error", 86 | "no-octal-escape": "off", 87 | "no-param-reassign": "off", 88 | "no-path-concat": "off", 89 | "no-plusplus": "off", 90 | "no-process-env": "off", 91 | "no-process-exit": "off", 92 | "no-proto": "off", 93 | "no-prototype-builtins": "off", 94 | "no-redeclare": "error", 95 | "no-regex-spaces": "error", 96 | "no-restricted-globals": "off", 97 | "no-restricted-imports": "off", 98 | "no-restricted-modules": "off", 99 | "no-restricted-syntax": "off", 100 | "no-return-assign": "off", 101 | "no-script-url": "off", 102 | "no-self-assign": "error", 103 | "no-self-compare": "off", 104 | "no-sequences": "off", 105 | "no-shadow": "off", 106 | "no-shadow-restricted-names": "off", 107 | "no-whitespace-before-property": "off", 108 | "no-spaced-func": "off", 109 | "no-sparse-arrays": "error", 110 | "no-sync": "off", 111 | "no-ternary": "off", 112 | "no-trailing-spaces": "off", 113 | "no-this-before-super": "error", 114 | "no-throw-literal": "off", 115 | "no-undef": "error", 116 | "no-undef-init": "off", 117 | "no-undefined": "off", 118 | "no-unexpected-multiline": "error", 119 | "no-underscore-dangle": "off", 120 | "no-unmodified-loop-condition": "off", 121 | "no-unneeded-ternary": "off", 122 | "no-unreachable": "error", 123 | "no-unsafe-finally": "error", 124 | "no-unused-expressions": "off", 125 | "no-unused-labels": "error", 126 | "no-unused-vars": "error", 127 | "no-use-before-define": "off", 128 | "no-useless-call": "off", 129 | "no-useless-computed-key": "off", 130 | "no-useless-concat": "off", 131 | "no-useless-constructor": "off", 132 | "no-useless-escape": "off", 133 | "no-useless-rename": "off", 134 | "no-void": "off", 135 | "no-var": "off", 136 | "no-warning-comments": "off", 137 | "no-with": "off", 138 | "array-bracket-spacing": "off", 139 | "array-callback-return": "off", 140 | "arrow-body-style": "off", 141 | "arrow-parens": "off", 142 | "arrow-spacing": "off", 143 | "accessor-pairs": "off", 144 | "block-scoped-var": "off", 145 | "block-spacing": "off", 146 | "brace-style": "off", 147 | "callback-return": "off", 148 | "camelcase": "off", 149 | "comma-dangle": "off", 150 | "comma-spacing": "off", 151 | "comma-style": "off", 152 | "complexity": "off", 153 | "computed-property-spacing": "off", 154 | "consistent-return": "off", 155 | "consistent-this": "off", 156 | "constructor-super": "error", 157 | "curly": "off", 158 | "default-case": "off", 159 | "dot-location": "off", 160 | "dot-notation": "off", 161 | "eol-last": "off", 162 | "eqeqeq": "off", 163 | "func-names": "off", 164 | "func-style": "off", 165 | "generator-star-spacing": "off", 166 | "global-require": "off", 167 | "guard-for-in": "off", 168 | "handle-callback-err": "off", 169 | "id-blacklist": "off", 170 | "id-length": "off", 171 | "id-match": "off", 172 | "indent": "off", 173 | "init-declarations": "off", 174 | "jsx-quotes": "off", 175 | "key-spacing": "off", 176 | "keyword-spacing": "off", 177 | "linebreak-style": "off", 178 | "lines-around-comment": "off", 179 | "max-depth": "off", 180 | "max-len": "off", 181 | "max-lines": "off", 182 | "max-nested-callbacks": "off", 183 | "max-params": "off", 184 | "max-statements": "off", 185 | "max-statements-per-line": "off", 186 | "multiline-ternary": "off", 187 | "new-cap": "off", 188 | "new-parens": "off", 189 | "newline-after-var": "off", 190 | "newline-before-return": "off", 191 | "newline-per-chained-call": "off", 192 | "object-curly-newline": "off", 193 | "object-curly-spacing": ["off", "never"], 194 | "object-property-newline": "off", 195 | "object-shorthand": "off", 196 | "one-var": "off", 197 | "one-var-declaration-per-line": "off", 198 | "operator-assignment": "off", 199 | "operator-linebreak": "off", 200 | "padded-blocks": "off", 201 | "prefer-arrow-callback": "off", 202 | "prefer-const": "error", 203 | "prefer-reflect": "off", 204 | "prefer-rest-params": "off", 205 | "prefer-spread": "off", 206 | "prefer-template": "off", 207 | "quote-props": "off", 208 | "quotes": "off", 209 | "radix": "off", 210 | "require-jsdoc": "off", 211 | "require-yield": "error", 212 | "rest-spread-spacing": "off", 213 | "semi": "off", 214 | "semi-spacing": "off", 215 | "sort-imports": "off", 216 | "sort-vars": "off", 217 | "space-before-blocks": "off", 218 | "space-before-function-paren": "off", 219 | "space-in-parens": "off", 220 | "space-infix-ops": "off", 221 | "space-unary-ops": "off", 222 | "spaced-comment": "off", 223 | "strict": "off", 224 | "template-curly-spacing": "off", 225 | "unicode-bom": "off", 226 | "use-isnan": "error", 227 | "valid-jsdoc": "off", 228 | "valid-typeof": "error", 229 | "vars-on-top": "off", 230 | "wrap-iife": "off", 231 | "wrap-regex": "off", 232 | "yield-star-spacing": "off", 233 | "yoda": "off" 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/combobox.js: -------------------------------------------------------------------------------- 1 | (function(){ 2 | const CLASSES = { 3 | BASE: 'autocomplete', 4 | CONTAINER: 'autocomplete-container', 5 | INPUT: 'autocomplete__input', 6 | LABEL: 'autocomplete__label', 7 | RESULTS: { 8 | BASE: 'autocomplete__results', 9 | VISIBLE: 'autocomplete__results--is-visible' 10 | }, 11 | RESULT: { 12 | BASE: 'autocomplete__result', 13 | SELECTED: 'autocomplete__result--is-selected' 14 | }, 15 | NOTICE: 'autocomplete__notice', 16 | SELECT_RESULT: 'autocomplete__select-result', 17 | LIST: 'autocomplete__list' 18 | }; 19 | 20 | const KEY_CODES = { 21 | ENTER: 13, 22 | ESC: 27, 23 | UP: 38, 24 | DOWN: 40 25 | } 26 | 27 | class Autocomplete { 28 | constructor(node) { 29 | this.select = node; 30 | this.select.style.display = 'none'; 31 | this.container = this.select.parentElement; 32 | this.container.classList.add(CLASSES.CONTAINER); 33 | this.container.style.position = 'relative'; 34 | this.label = this.container.querySelector('label'); 35 | const selectOptions = [].slice.call(node.querySelectorAll('option')); 36 | const resultsId = `${this.select.id}-autocomplete`; 37 | this.isVisible = false; 38 | this.options = selectOptions.map((option, index) => { 39 | return { 40 | label: option.textContent, 41 | value: option.value, 42 | id: `${this.select.id}-autocomplete-result-${index}` 43 | }; 44 | }); 45 | 46 | this.outputInput(resultsId); 47 | this.outputResultsList(resultsId); 48 | this.outputResultsNotice(resultsId); 49 | 50 | window.requestAnimationFrame(() => { 51 | this.container.appendChild(this.input); 52 | this.container.appendChild(this.resultsList); 53 | }); 54 | 55 | this.input.addEventListener('input', (evt) => { 56 | const input = evt.target.value.toLowerCase(); 57 | this.filterResults(input); 58 | }); 59 | 60 | this.input.addEventListener('focus', () => { 61 | if (this.results) { 62 | this.showResults(); 63 | } else { 64 | this.updateResults(this.options); 65 | this.outputResults(); 66 | } 67 | }); 68 | 69 | this.input.addEventListener('blur', () => { 70 | this.hideResults(); 71 | }); 72 | 73 | document.body.addEventListener('click', (evt) => { 74 | if (!this.container.contains(evt.target)) { 75 | this.hideResults(); 76 | } 77 | }); 78 | 79 | document.addEventListener('keydown', (evt) => { 80 | this.keydownEvent(evt); 81 | }); 82 | } 83 | 84 | clearSelected() { 85 | const selected = this.resultsList.querySelector(`.${CLASSES.RESULT.SELECTED}`); 86 | if (selected) { 87 | selected.classList.remove(CLASSES.RESULT.SELECTED); 88 | } 89 | } 90 | 91 | selectPreviousOption() { 92 | const selected = this.resultsList.querySelector(`.${CLASSES.RESULT.SELECTED}`); 93 | if (selected) { 94 | if (selected === this.resultsList.firstChild) { 95 | this.selectOption(this.resultsList.lastChild); 96 | } else { 97 | this.selectOption(selected.previousSibling); 98 | } 99 | } 100 | } 101 | 102 | selectNextOption() { 103 | const selected = this.resultsList.querySelector(`.${CLASSES.RESULT.SELECTED}`); 104 | if (selected) { 105 | if (selected === this.resultsList.lastChild) { 106 | this.selectOption(this.resultsList.firstChild); 107 | } else { 108 | this.selectOption(selected.nextSibling); 109 | } 110 | } else { 111 | this.selectOption(this.resultsList.firstChild); 112 | } 113 | } 114 | 115 | selectOption(optionNode, callback) { 116 | window.requestAnimationFrame(() => { 117 | this.clearSelected(); 118 | optionNode.classList.add(CLASSES.RESULT.SELECTED); 119 | this.input.dataset.selected = optionNode.id; 120 | optionNode.scrollIntoView(false); 121 | this.resultsNotice.textContent = optionNode.textContent; 122 | if (callback) { 123 | callback(); 124 | } 125 | }); 126 | } 127 | 128 | chooseOption() { 129 | const selectedOption = document.getElementById(this.input.dataset.selected); 130 | this.input.value = selectedOption.textContent; 131 | this.select.value = selectedOption.dataset.value; 132 | this.resultsNotice.textContent = `${selectedOption.textContent} selected`; 133 | this.hideResults(); 134 | } 135 | 136 | clearResults() { 137 | window.requestAnimationFrame(() => { 138 | while (this.resultsList.hasChildNodes()) { 139 | this.resultsList.removeChild(this.resultsList.lastChild); 140 | } 141 | }); 142 | } 143 | 144 | filterResults(input) { 145 | const results = this.options.filter((result) => { 146 | return (result.value.toLowerCase().indexOf(input) != -1) || 147 | (result.label.toLowerCase().indexOf(input) != -1); 148 | }); 149 | 150 | this.updateResults(results); 151 | this.outputResults(); 152 | } 153 | 154 | hideResults() { 155 | this.isVisible = false; 156 | window.requestAnimationFrame(() => { 157 | this.resultsList.classList.remove(CLASSES.RESULTS.VISIBLE); 158 | this.input.setAttribute('aria-expanded', 'false'); 159 | }); 160 | } 161 | 162 | updateResults(results) { 163 | this.clearResults(); 164 | this.results = results; 165 | } 166 | 167 | outputResults() { 168 | if (this.results.length > 0) { 169 | this.results.forEach((result) => { 170 | const resultListItem = document.createElement('li'); 171 | resultListItem.setAttribute('id', result.id); 172 | resultListItem.classList.add(CLASSES.RESULT.BASE); 173 | resultListItem.textContent = result.label; 174 | resultListItem.dataset.value = result.value; 175 | resultListItem.setAttribute('role', 'option'); 176 | resultListItem.addEventListener('click', (evt) => { 177 | this.selectOption(evt.target, () => this.chooseOption()); 178 | }); 179 | window.requestAnimationFrame(() => { 180 | this.resultsList.appendChild(resultListItem); 181 | }); 182 | }); 183 | } else { 184 | const noResultsItem = document.createElement('li'); 185 | noResultsItem.classList.add(CLASSES.RESULT.BASE); 186 | noResultsItem.textContent = 'No results found'; 187 | window.requestAnimationFrame(() => { 188 | this.resultsList.appendChild(noResultsItem); 189 | }); 190 | } 191 | this.showResults(); 192 | } 193 | 194 | showResults() { 195 | this.isVisible = true; 196 | window.requestAnimationFrame(() => { 197 | this.resultsList.classList.add(CLASSES.RESULTS.VISIBLE); 198 | this.input.setAttribute('aria-expanded', 'true'); 199 | if (this.results.length === 0) { 200 | this.resultsNotice.textContent = `No results found`; 201 | } else if (this.results.length === 1) { 202 | this.resultsNotice.textContent = '1 result'; 203 | } else { 204 | this.resultsNotice.textContent = `${this.results.length} results`; 205 | } 206 | }); 207 | } 208 | 209 | outputInput(resultsId) { 210 | this.input = document.createElement('input'); 211 | this.input.type = 'text'; 212 | this.input.setAttribute('role', 'combobox'); 213 | this.input.setAttribute('aria-label', `Search and select an option for ${this.label.textContent}`); 214 | this.input.setAttribute('aria-expanded', 'false'); 215 | this.input.setAttribute('aria-autocomplete', 'list'); 216 | this.input.setAttribute('aria-owns', resultsId); 217 | this.input.classList.add(CLASSES.INPUT); 218 | 219 | window.requestAnimationFrame(() => { 220 | this.container.appendChild(this.input); 221 | }); 222 | } 223 | 224 | outputResultsList(resultsId) { 225 | this.resultsList = document.createElement('ul'); 226 | this.resultsList.classList.add(CLASSES.RESULTS.BASE); 227 | this.resultsList.setAttribute('id', resultsId); 228 | this.resultsList.setAttribute('role', 'listbox'); 229 | 230 | window.requestAnimationFrame(() => { 231 | this.container.appendChild(this.resultsList); 232 | }); 233 | } 234 | 235 | outputResultsNotice() { 236 | this.resultsNotice = document.createElement('div'); 237 | this.resultsNotice.classList.add(CLASSES.NOTICE); 238 | this.resultsNotice.setAttribute('role', 'status'); 239 | this.resultsNotice.setAttribute('aria-live', 'polite'); 240 | 241 | window.requestAnimationFrame(() => { 242 | this.container.appendChild(this.resultsNotice); 243 | }); 244 | } 245 | 246 | keydownEvent(evt) { 247 | if (!this.container.contains(evt.target)) { 248 | return; 249 | } 250 | switch (evt.keyCode) { 251 | case KEY_CODES.ENTER: 252 | this.chooseOption(); 253 | break; 254 | case KEY_CODES.ESC: 255 | this.hideResults(); 256 | this.input.blur(); 257 | break; 258 | case KEY_CODES.DOWN: 259 | if (!this.isVisible) { 260 | this.showResults(); 261 | } else { 262 | this.selectNextOption(); 263 | } 264 | evt.preventDefault(); 265 | break; 266 | case KEY_CODES.UP: 267 | evt.preventDefault(); 268 | this.selectPreviousOption(); 269 | break; 270 | } 271 | } 272 | } 273 | 274 | function createAutocomplete(node) { 275 | return new Autocomplete(node); 276 | } 277 | 278 | document.addEventListener('DOMContentLoaded', function() { 279 | const autocompletes = [].slice.call(document.querySelectorAll(`.${CLASSES.BASE}`)); 280 | autocompletes.forEach(createAutocomplete); 281 | }); 282 | })(); 283 | -------------------------------------------------------------------------------- /example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Combobox example 6 | 7 | 28 | 29 | 30 |
31 |

Custom select autocomplete

32 |
33 | 34 | 276 |
277 |
278 | 279 | 280 | 281 | --------------------------------------------------------------------------------