diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..d3fd8dd --- /dev/null +++ b/.babelrc @@ -0,0 +1,41 @@ +{ + "presets": [ + "babel-preset-niksy/next", + [ + "babel-preset-niksy", + { + "@babel/preset-env": { + "loose": true + } + } + ] + ], + "plugins": ["@babel/plugin-transform-object-assign"], + "env": { + "test": { + "presets": [ + [ + "babel-preset-niksy", + { + "@babel/preset-env": { + "loose": true, + "useBuiltIns": "usage", + "corejs": 2 + } + } + ] + ], + "plugins": [ + [ + "@babel/plugin-transform-runtime", + { + "corejs": false, + "helpers": true, + "regenerator": false, + "useESModules": true + } + ] + ] + } + } +} diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000..d5e9fec --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,2 @@ +last 2 versions +ie >= 11 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..11cdada --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[package.json] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..288afac --- /dev/null +++ b/.eslintrc @@ -0,0 +1,34 @@ +{ + "root": true, + "extends": [ + "niksy", + "niksy/next", + "niksy/browser", + "prettier" + ], + "plugins": [ + "prettier", + "html" + ], + "settings": { + "html/html-extensions": [".svelte"], + "html/indent": "0" + }, + "rules": { + "prettier/prettier": 1 + }, + "overrides": [ + { + "files": [ + "karma.conf.js" + ], + "env": { + "node": true, + "es6": true + }, + "rules": { + "no-console": 0 + } + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dcf59d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store +node_modules/ +package-lock.json +yarn.lock +npm-debug.log +local.log +coverage/ +index.cjs.js +index.esm.js +index.cjs.js.map +index.esm.js.map diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e43310d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,18 @@ +{ + "printWidth": 80, + "tabWidth": 4, + "useTabs": true, + "semi": true, + "singleQuote": true, + "quoteProps": "preserve", + "jsxSingleQuote": true, + "trailingComma": "none", + "bracketSpacing": true, + "jsxBracketSameLine": false, + "arrowParens": "always", + "requirePragma": false, + "insertPragma": false, + "proseWrap": "always", + "htmlWhitespaceSensitivity": "css", + "endOfLine": "lf" +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b3be97a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: node_js +node_js: + - '8' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bd9400a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## [Unreleased][] + +### Added + +- Initial implementation diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4df738d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright (c) Ivan Nikolić + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdea55c --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# x-autosuggest + +[![Build Status][ci-img]][ci] +[![BrowserStack Status][browserstack-img]][browserstack] + +Autosuggest results based on input. + +Features: + +- Best accessibility practices baked in +- Flexible styling + +**[Try it now!](#demo)** + +## Install + +```sh +npm install x-autosuggest --save +``` + +## Usage + +Following example will decorate existing `input` element with autosuggest +functionality. + +When user inputs query, autosuggest first checks if query has less than 2 +characters. If it does, no results are returned, otherwise it fetches list of +countries and maps names to content and value which will be used for input value. + +When user chooses option, closest `form` element submit event will be triggered. + +```js +import autosuggest from 'x-autosuggest'; + +const element = document.querySelector('input[type="text"]'); + +const instance = autosuggest(element, { + onOptionSelect() { + element.closest('form').submit(); + }, + async onQueryInput(query) { + if (query.trim().length < 2) { + return []; + } + const response = await fetch( + `https://restcountries.eu/rest/v2/name/${query}` + ); + const countries = await response.json(); + return countries.map(({ name }) => { + return { + content: `${name}`, + value: name + }; + }); + } +}); +``` + +## API + +### autosuggest(element, options) + +Returns: `Object` + +Decorates `element` with autosuggest functionality. + +#### element + +Type: `HTMLInputElement` + +Input element to decorate. + +#### options + +Type: `Object` + +##### onQueryInput + +Type: `Function` +Default: `async (query) => []` + +Callback to run when query changes. You can perform actions such as testing +query, fetching data and mapping results to valid format here. + +Return value should either be empty array (when you don’t want to display +results or don’t have results to display) or array of objects, where each object +contains two properties: + +###### content + +Type: `string` + +Content for option. Can be regular string or HTML string as markup. + +###### value + +Type: `*` + +Value used for `input` element value. + +If it’s `null` option element will be considered as placeholder element and +won’t be used as option. Useful if you want to have dividers between your +options, or if you need to group option elements with headings. + +##### decorateOption + +Type: `Function` +Default: `(node) => {}` + +Decorate autosuggest option. Callback receives one argument which is option +`Element` node. Useful if you want to add additional functionality to option +such as attach event listeners or add HTML classes. + +If return value is function, it will be used as cleanup callback for when +autosuggest instance is removed or option is rerendered. You can perform actions +such as custom event handlers removal for option inside this callback. + +##### decorateInputEvent + +Type: `Function` +Default: `(listener) => {}` + +Decorate `input` event of input element. Callback receives one argument which is +default listener for `input` event. useful if you want to add additional +functionality such as debounce or throttle of events. + +##### onOptionSelect + +Type: `Function` +Default: `(event, value) => {}` + +Callback to run when option is selected. It receives two arguments: event object +of triggered event and value of triggered option. + +This callback is useful for performing actions such as triggering form submit. + +### instance.destroy() + +Destroy instance. + +## FAQ + +### How do I cache results? + +By default, results are not cached, but it can be achieved with techniques such +as memoization. + +```js +import autosuggest from 'x-autosuggest'; +import memoize from '@f/memoize'; + +// We cache fetch results based on URL +const cachedFetch = memoize((url) => { + return fetch(url); +}); + +// And then in autosuggest instance options… +const options = { + onQueryInput(query) { + return cachedFetch(`https://restcountries.eu/rest/v2/name/${query}`); + // … + } +}; +``` + +## Browser support + +Tested in IE11+ and all modern browsers, assuming `Promise` is available. + +## Test + +For automated tests, run `npm run test:automated` (append `:watch` for watcher +support). + +## License + +MIT © [Ivan Nikolić](http://ivannikolic.com) + + + +[ci]: https://travis-ci.com/niksy/x-autosuggest +[ci-img]: https://travis-ci.com/niksy/x-autosuggest.svg?branch=master +[browserstack]: https://www.browserstack.com/ +[browserstack-img]: https://www.browserstack.com/automate/badge.svg?badge_key= + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..c790c93 --- /dev/null +++ b/index.js @@ -0,0 +1,27 @@ +import Component from './lib/index.svelte'; + +export default (element, options = {}) => { + const { + decorateOption = (node) => {}, + decorateInputEvent = (listener) => listener, + onOptionSelect = (event, value) => {}, + onQueryInput = (query) => Promise.resolve([]) + } = options; + + const instance = new Component({ + target: element.parentElement, + elementToHandle: element, + data: { + decorateOption, + decorateInputEvent, + onOptionSelect, + onQueryInput + } + }); + return { + destroy: () => { + instance.set({ isComponentActive: false }); + instance.destroy(); + } + }; +}; diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..d6caa76 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,151 @@ +'use strict'; + +const path = require('path'); +const resolve = require('rollup-plugin-node-resolve'); +const commonjs = require('rollup-plugin-commonjs'); +const nodeBuiltins = require('rollup-plugin-node-builtins'); +const globals = require('rollup-plugin-node-globals'); +const babel = require('rollup-plugin-babel'); +const istanbul = require('rollup-plugin-istanbul'); +const rollupConfig = require('./rollup.config'); + +let config; + +const local = typeof process.env.CI === 'undefined' || process.env.CI === 'false'; +const port = 9001; + +const rollupConfigAliasPlugin = rollupConfig.plugins.find(({ name }, index) => index === 0); + +if ( local ) { + config = { + browsers: ['Chrome'], + }; +} else { + config = { + hostname: 'bs-local.com', + browserStack: { + username: process.env.BROWSER_STACK_USERNAME, + accessKey: process.env.BROWSER_STACK_ACCESS_KEY, + startTunnel: true, + project: 'x-autosuggest', + name: 'Automated (Karma)', + build: 'Automated (Karma)' + }, + customLaunchers: { + 'BS-Chrome': { + base: 'BrowserStack', + browser: 'Chrome', + os: 'Windows', + 'os_version': '7', + project: 'x-autosuggest', + build: 'Automated (Karma)', + name: 'Chrome' + }, + 'BS-Firefox': { + base: 'BrowserStack', + browser: 'Firefox', + os: 'Windows', + 'os_version': '7', + project: 'x-autosuggest', + build: 'Automated (Karma)', + name: 'Firefox' + }, + 'BS-IE11': { + base: 'BrowserStack', + browser: 'IE', + 'browser_version': '11', + os: 'Windows', + 'os_version': '7', + project: 'x-autosuggest', + build: 'Automated (Karma)', + name: 'IE11' + }, + }, + browsers: ['BS-Chrome', 'BS-Firefox', 'BS-IE11'] + }; +} + +module.exports = function ( baseConfig ) { + + baseConfig.set(Object.assign({ + basePath: '', + frameworks: ['mocha', 'fixture'], + files: [ + 'test/**/*.html', + { pattern: 'test/index.js', watched: false } + ], + exclude: [], + preprocessors: { + 'test/**/*.html': ['html2js'], + 'test/index.js': ['rollup', 'sourcemap'] + }, + reporters: ['mocha', 'coverage-istanbul'], + port: port, + colors: true, + logLevel: baseConfig.LOG_INFO, + autoWatch: false, + client: { + captureConsole: true + }, + browserConsoleLogOptions: { + level: 'log', + format: '%b %T: %m', + terminal: true + }, + rollupPreprocessor: { + plugins: [ + rollupConfigAliasPlugin, + nodeBuiltins(), + babel({ + exclude: 'node_modules/**', + runtimeHelpers: true + }), + resolve({ + preferBuiltins: true + }), + commonjs(), + babel({ + include: 'node_modules/{has-flag,supports-color}/**', + runtimeHelpers: true, + babelrc: false, + configFile: path.resolve(__dirname, '.babelrc') + }), + globals(), + ...rollupConfig.plugins.filter(({ name }) => !['babel'].includes(name)), + babel({ + exclude: 'node_modules/**', + extensions: ['.js', '.svelte'], + runtimeHelpers: true + }), + babel({ + include: 'node_modules/svelte/shared.js', + runtimeHelpers: true, + babelrc: false, + configFile: path.resolve(__dirname, '.babelrc') + }), + istanbul({ + exclude: ['test/**/*.js', 'node_modules/**/*'] + }) + ], + output: { + format: 'iife', + name: 'xAutosuggest', + sourcemap: baseConfig.autoWatch ? false : 'inline', // Source map support has weird behavior in watch mode + intro: 'window.TYPED_ARRAY_SUPPORT = false;' // IE9 + } + }, + coverageIstanbulReporter: { + dir: path.join(__dirname, 'coverage/%browser%'), + fixWebpackSourcePaths: true, + reports: ['html', 'text'], + thresholds: { + global: { + statements: 80 + } + } + }, + singleRun: true, + concurrency: Infinity + }, config)); + +}; diff --git a/lib/index.svelte b/lib/index.svelte new file mode 100644 index 0000000..c633323 --- /dev/null +++ b/lib/index.svelte @@ -0,0 +1,362 @@ + + + +
+ + {#if hasResults} +
+
    + {#each preparedResults as result, index (result.id)} +
+
+ {/if} +
diff --git a/lib/option.svelte b/lib/option.svelte new file mode 100644 index 0000000..aa29807 --- /dev/null +++ b/lib/option.svelte @@ -0,0 +1,40 @@ + + +{#if value !== null} +
  • + {@html content} +
  • +{:else} +
  • + {@html content} +
  • +{/if} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2379e4d --- /dev/null +++ b/package.json @@ -0,0 +1,103 @@ +{ + "name": "x-autosuggest", + "version": "0.0.0", + "description": "Autosuggest results based on input.", + "main": "index.cjs.js", + "module": "index.esm.js", + "author": "Ivan Nikolić (http://ivannikolic.com)", + "license": "MIT", + "files": [ + "index.cjs.{js,js.map}", + "index.esm.{js,js.map}", + "CHANGELOG.md", + "LICENSE.md", + "README.md" + ], + "sideEffects": false, + "directories": { + "test": "test" + }, + "scripts": { + "build": "rollup --config rollup.config.js", + "lint": "eslint '{index,lib/**/*,test/**/*}.js'", + "postpublish": "GITHUB_TOKEN=$GITHUB_RELEASE_TOKEN github-release-from-changelog", + "prepublishOnly": "npm run build", + "release": "np", + "test": "npm run lint && npm run test:automated", + "test:automated": "BABEL_ENV=test karma start", + "test:automated:watch": "npm run test:automated -- --auto-watch --no-single-run", + "version": "version-changelog CHANGELOG.md && changelog-verify CHANGELOG.md && git add CHANGELOG.md" + }, + "dependencies": { + "keycode-js": "^1.0.4", + "manage-side-effects": "^1.1.0" + }, + "devDependencies": { + "@babel/cli": "^7.2.3", + "@babel/core": "^7.2.2", + "@babel/plugin-transform-object-assign": "^7.2.0", + "@babel/plugin-transform-runtime": "^7.2.0", + "@babel/runtime": "^7.2.0", + "babel-preset-niksy": "^4.1.0", + "changelog-verify": "^1.1.2", + "core-js": "^2.6.5", + "eslint": "^5.16.0", + "eslint-config-niksy": "^7.0.0", + "eslint-config-prettier": "^4.1.0", + "eslint-plugin-extend": "^0.1.1", + "eslint-plugin-html": "^6.0.0", + "eslint-plugin-import": "^2.17.1", + "eslint-plugin-jsdoc": "^4.8.3", + "eslint-plugin-mocha": "^5.3.0", + "eslint-plugin-node": "^8.0.1", + "eslint-plugin-prettier": "^3.0.1", + "eslint-plugin-promise": "^4.1.1", + "eslint-plugin-unicorn": "^8.0.2", + "esm": "^3.0.51", + "github-release-from-changelog": "^1.3.2", + "karma": "^4.0.1", + "karma-browserstack-launcher": "^1.0.0", + "karma-chrome-launcher": "^2.2.0", + "karma-coverage-istanbul-reporter": "^2.0.1", + "karma-fixture": "^0.2.6", + "karma-html2js-preprocessor": "^1.0.0", + "karma-mocha": "^1.3.0", + "karma-mocha-reporter": "^2.2.5", + "karma-rollup-preprocessor": "^7.0.0", + "karma-sourcemap-loader": "^0.3.7", + "mocha": "^4.1.0", + "np": "^3.0.4", + "prettier": "^1.17.0", + "rollup": "^1.0.0", + "rollup-plugin-alias": "^1.5.2", + "rollup-plugin-babel": "^4.2.0", + "rollup-plugin-commonjs": "^9.2.0", + "rollup-plugin-istanbul": "^2.0.1", + "rollup-plugin-node-builtins": "^2.1.2", + "rollup-plugin-node-globals": "^1.4.0", + "rollup-plugin-node-resolve": "^4.0.0", + "rollup-plugin-svelte": "^5.1.0", + "simulant": "^0.2.2", + "sinon": "^2.4.1", + "svelte": "^2.16.1", + "version-changelog": "^3.1.1" + }, + "engines": { + "node": ">=8" + }, + "keywords": [ + "autocomplete", + "autosuggest", + "input", + "typeahead", + "vanilla" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/niksy/x-autosuggest.git" + }, + "bugs": { + "url": "https://github.com/niksy/x-autosuggest/issues" + }, + "homepage": "https://github.com/niksy/x-autosuggest#readme" +} diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..ef07324 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,128 @@ +'use strict'; + +const path = require('path'); +const babel = require('rollup-plugin-babel'); +const svelte = require('rollup-plugin-svelte'); +const alias = require('rollup-plugin-alias'); +const babelCore = require('@babel/core'); +const resolve = require('rollup-plugin-node-resolve'); +const commonjs = require('rollup-plugin-commonjs'); + +module.exports = { + input: 'index.js', + output: [ + { + file: 'index.cjs.js', + format: 'cjs', + sourcemap: true + }, + { + file: 'index.esm.js', + format: 'esm', + sourcemap: true + } + ], + plugins: [ + alias({ + 'keycode-js': require.resolve('keycode-js/lib/KeyCode') + }), + svelte({ + legacy: true + }), + { + async transform(code, id) { + if (!id.includes('lib/index.svelte')) { + return; + } + const result = await babelCore.transformAsync(code, { + sourceMaps: true, + plugins: [useMountedNodePlugin()] + }); + return { + code: result.code, + map: result.map + }; + } + }, + babel({ + exclude: 'node_modules/**', + extensions: ['.js', '.svelte'] + }), + babel({ + include: 'node_modules/svelte/shared.js', + runtimeHelpers: true, + babelrc: false, + configFile: path.resolve(__dirname, '.babelrc') + }), + resolve(), + commonjs() + ], + external: ['manage-side-effects'] +}; + +function useMountedNodePlugin() { + return babelCore.createConfigItem(({ types: t }) => { + return { + visitor: { + Identifier(path, state) { + if ( + path.node.name === 'createElement' && + path.findParent( + (path) => + path.isCallExpression() && + path.get('arguments')[0].isStringLiteral() && + path.get('arguments')[0].get('value').node === + 'input' + ) && + path.findParent( + (path) => + path.isFunctionDeclaration() && + path.get('id.name').node === + 'create_main_fragment' + ) + ) { + path.parentPath.replaceWith( + t.memberExpression( + t.memberExpression( + t.identifier('component'), + t.identifier('options') + ), + t.identifier('elementToHandle') + ) + ); + } + if ( + path.node.name === 'd' && + path.parentPath.isObjectMethod() && + path.findParent( + (path) => + path.isFunctionDeclaration() && + path.get('id.name').node === + 'create_main_fragment' + ) + ) { + path.parentPath + .get('body') + .unshiftContainer('body', [ + t.expressionStatement( + t.callExpression( + t.memberExpression( + t.memberExpression( + t.memberExpression( + t.identifier('component'), + t.identifier('options') + ), + t.identifier('target') + ), + t.identifier('appendChild') + ), + [t.identifier('input')] + ) + ) + ]); + } + } + } + }; + }); +} diff --git a/test/.eslintrc b/test/.eslintrc new file mode 100644 index 0000000..2a48f13 --- /dev/null +++ b/test/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "niksy/tests" + ] +} diff --git a/test/fixtures/index.html b/test/fixtures/index.html new file mode 100644 index 0000000..ff60a8d --- /dev/null +++ b/test/fixtures/index.html @@ -0,0 +1 @@ + diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..29a7e5c --- /dev/null +++ b/test/index.js @@ -0,0 +1,327 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import fn from '../index'; +import { + nextFrame, + inputCharacter, + goDown, + goUp, + pressEscape, + pressEnter, + mouseClick, + nodesExist +} from './util'; + +function fetchData() { + const data = ['bonnie', 'brutus', 'elvis'].map((value) => { + return { + content: `${value}`, + value: value + }; + }); + return Promise.resolve(data); +} + +before(function() { + window.fixture.load('/test/fixtures/index.html'); +}); + +after(function() { + window.fixture.cleanup(); +}); + +it('should display results', async function() { + const element = document.querySelector('.Input'); + + const instance = fn(element, { + onQueryInput() { + return fetchData(); + } + }); + + await inputCharacter(element); + + assert.ok( + nodesExist([ + '.x-Autosuggest-input[autocomplete="off"][aria-autocomplete="list"][aria-owns="x-Autosuggest-results-0"]:not(aria-activedescendant)', + '#x-Autosuggest-results-0[role="listbox"][aria-expanded="true"]', + '#x-Autosuggest-item-0-0[role="option"][aria-selected="false"] a[href="bonnie"]', + '#x-Autosuggest-item-0-1[role="option"][aria-selected="false"] a[href="brutus"]', + '#x-Autosuggest-item-0-2[role="option"][aria-selected="false"] a[href="elvis"]' + ]) + ); + + instance.destroy(); +}); + +it('should handle keyboard movement', async function() { + const element = document.querySelector('.Input'); + + const instance = fn(element, { + onQueryInput() { + return fetchData(); + } + }); + + await inputCharacter(element); + + goDown(element); + goDown(element); + + assert.ok( + nodesExist([ + '.x-Autosuggest-input[aria-activedescendant="x-Autosuggest-item-1-1"]', + '#x-Autosuggest-item-1-0[aria-selected="false"]', + '#x-Autosuggest-item-1-1[aria-selected="true"]', + '#x-Autosuggest-item-1-2[aria-selected="false"]', + ['.x-Autosuggest-input', (node) => node.value === 'brutus'] + ]) + ); + + goDown(element); + + assert.ok( + nodesExist([ + '.x-Autosuggest-input[aria-activedescendant="x-Autosuggest-item-1-2"]', + '#x-Autosuggest-item-1-0[aria-selected="false"]', + '#x-Autosuggest-item-1-1[aria-selected="false"]', + '#x-Autosuggest-item-1-2[aria-selected="true"]', + ['.x-Autosuggest-input', (node) => node.value === 'elvis'] + ]) + ); + + goUp(element); + goUp(element); + + assert.ok( + nodesExist([ + '.x-Autosuggest-input[aria-activedescendant="x-Autosuggest-item-1-0"]', + '#x-Autosuggest-item-1-0[aria-selected="true"]', + '#x-Autosuggest-item-1-1[aria-selected="false"]', + '#x-Autosuggest-item-1-2[aria-selected="false"]', + ['.x-Autosuggest-input', (node) => node.value === 'bonnie'] + ]) + ); + + goUp(element); + + assert.ok( + nodesExist([ + '.x-Autosuggest-input:not(aria-activedescendant)', + '#x-Autosuggest-item-1-0[aria-selected="false"]', + '#x-Autosuggest-item-1-1[aria-selected="false"]', + '#x-Autosuggest-item-1-2[aria-selected="false"]', + ['.x-Autosuggest-input', (node) => node.value === 'a'] + ]) + ); + + goUp(element); + goDown(element); + + assert.ok( + nodesExist([ + '.x-Autosuggest-input:not(aria-activedescendant)', + '#x-Autosuggest-item-1-0[aria-selected="false"]', + '#x-Autosuggest-item-1-1[aria-selected="false"]', + '#x-Autosuggest-item-1-2[aria-selected="false"]', + ['.x-Autosuggest-input', (node) => node.value === 'a'] + ]) + ); + + instance.destroy(); +}); + +it('should handle keyboard events for closing', async function() { + const element = document.querySelector('.Input'); + + const instance = fn(element, { + onQueryInput() { + return fetchData(); + } + }); + + await inputCharacter(element); + + goDown(element); + + assert.ok(nodesExist(['#x-Autosuggest-results-2[aria-expanded="true"]'])); + + pressEscape(element); + + assert.ok(nodesExist(['#x-Autosuggest-results-2[aria-expanded="false"]'])); + + instance.destroy(); +}); + +it('should handle mouse events for closing', async function() { + const element = document.querySelector('.Input'); + + const instance = fn(element, { + onQueryInput() { + return fetchData(); + } + }); + + await inputCharacter(element); + + goDown(element); + + assert.ok(nodesExist(['#x-Autosuggest-results-3[aria-expanded="true"]'])); + + mouseClick(document.body); + + assert.ok(nodesExist(['#x-Autosuggest-results-3[aria-expanded="false"]'])); + + instance.destroy(); +}); + +it('should handle option select', async function() { + const element = document.querySelector('.Input'); + const spy = sinon.spy(); + + const instance = fn(element, { + onOptionSelect: spy, + onQueryInput() { + return fetchData(); + } + }); + + await inputCharacter(element); + + goDown(element); + pressEnter(element); + + assert.ok( + nodesExist([ + '#x-Autosuggest-results-4[aria-expanded="false"]', + ['.x-Autosuggest-input', (node) => node.value === 'bonnie'] + ]) + ); + assert.ok(spy.called); + assert.equal(spy.firstCall.args[1], 'bonnie'); + + instance.destroy(); +}); + +it('should handle decorated option', async function() { + const element = document.querySelector('.Input'); + + const instance = fn(element, { + decorateOption(node) { + node.classList.add('is-decorated'); + }, + onQueryInput() { + return fetchData(); + } + }); + + await inputCharacter(element); + + goDown(element); + + assert.ok( + nodesExist([ + '#x-Autosuggest-item-5-0.is-decorated[aria-selected="true"]' + ]) + ); + + instance.destroy(); +}); + +it('should handle decorated input event', async function() { + const element = document.querySelector('.Input'); + const spy = sinon.spy(); + + const instance = fn(element, { + decorateInputEvent(listener) { + return (e) => { + spy(); + listener(e); + }; + }, + onQueryInput() { + return fetchData(); + } + }); + + await inputCharacter(element); + + assert.ok(spy.called); + + instance.destroy(); +}); + +it('should hide results if input is disabled', async function() { + const element = document.querySelector('.Input'); + + const instance = fn(element, { + onQueryInput() { + return fetchData(); + } + }); + + await inputCharacter(element); + + goDown(element); + goDown(element); + + assert.ok( + nodesExist([ + '#x-Autosuggest-results-7[role="listbox"][aria-expanded="true"]' + ]) + ); + + element.disabled = true; + + await nextFrame(); + + assert.ok( + nodesExist([ + '#x-Autosuggest-results-7[role="listbox"][aria-expanded="false"]' + ]) + ); + + instance.destroy(); +}); + +it('should display placeholder elements', async function() { + const element = document.querySelector('.Input'); + + const instance = fn(element, { + async onQueryInput() { + const data = await fetchData(); + return [].concat(data, { + content: '
    ', + value: null + }); + } + }); + + await inputCharacter(element); + + goDown(element); + + assert.ok( + nodesExist([ + '#x-Autosuggest-item-8-0[aria-selected="true"]', + '#x-Autosuggest-item-8-1[aria-selected="false"]', + '#x-Autosuggest-item-8-2[aria-selected="false"]', + '#x-Autosuggest-results-8 li:last-child hr' + ]) + ); + + instance.destroy(); +}); + +it('should handle destroy and element reusability', function() { + const element = document.querySelector('.Input'); + + const instance = fn(element); + + assert.ok(nodesExist(['.x-Autosuggest-input'])); + assert.ok(element === document.querySelector('.x-Autosuggest-input')); + + instance.destroy(); + + assert.ok(nodesExist(['input:not(.x-Autosuggest-input)'])); +}); diff --git a/test/util.js b/test/util.js new file mode 100644 index 0000000..a639f8d --- /dev/null +++ b/test/util.js @@ -0,0 +1,57 @@ +import simulant from 'simulant'; + +function nextFrame() { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function inputCharacter(element) { + element.focus(); + element.value = 'a'; + simulant.fire(element, 'input', { which: 65 }); + await nextFrame(); +} + +function goDown(element) { + simulant.fire(element, 'keydown', { which: 40 }); +} + +function goUp(element) { + simulant.fire(element, 'keydown', { which: 38 }); +} + +function pressEscape(element) { + simulant.fire(element, 'keydown', { which: 27 }); +} + +function pressEnter(element) { + simulant.fire(element, 'keydown', { which: 13 }); +} + +function mouseClick(element) { + simulant.fire(element, 'click', { button: 1 }); +} + +function nodesExist(selectors) { + return selectors + .map((selector) => { + if (Array.isArray(selector)) { + const node = document.querySelector(selector[0]); + return selector[1](node); + } + return document.querySelector(selector); + }) + .every(Boolean); +} + +export { + nextFrame, + inputCharacter, + goDown, + goUp, + pressEscape, + pressEnter, + mouseClick, + nodesExist +};