diff --git a/packages/clay-autocomplete/LICENSE.md b/packages/clay-autocomplete/LICENSE.md new file mode 100644 index 0000000000..70f93e9462 --- /dev/null +++ b/packages/clay-autocomplete/LICENSE.md @@ -0,0 +1,29 @@ +# Software License Agreement (BSD License) + +Copyright (c) 2014, Liferay Inc. +All rights reserved. + +Redistribution and use of this software in source and binary forms, with or without modification, are +permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other + materials provided with the distribution. + +* The name of Liferay Inc. may not be used to endorse or promote products + derived from this software without specific prior + written permission of Liferay Inc. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/clay-autocomplete/README.md b/packages/clay-autocomplete/README.md new file mode 100644 index 0000000000..053ec0c0bc --- /dev/null +++ b/packages/clay-autocomplete/README.md @@ -0,0 +1,26 @@ +# clay-autocomplete + +Autocomplete textarea + +## Setup + +1. Install NodeJS >= v0.12.0 and NPM >= v3.0.0, if you don't have it yet. You +can find it [here](https://nodejs.org). + +2. Install local dependencies: + + ``` + npm install + ``` + +3. Build the code: + + ``` + npm run build + ``` + +4. Open the demo at demos/index.html on your browser. + +## Contribute + +We'd love to get contributions from you! Please, check our [Contributing Guidelines](CONTRIBUTING.md) to see how you can help us improve. diff --git a/packages/clay-autocomplete/demos/a11y.html b/packages/clay-autocomplete/demos/a11y.html new file mode 100644 index 0000000000..f9f17b0e7b --- /dev/null +++ b/packages/clay-autocomplete/demos/a11y.html @@ -0,0 +1,35 @@ + + + + + + Demo: ClayAutocomplete + + + + + + + + +

+ ClayAutocomplete +

+ +
+ + + + diff --git a/packages/clay-autocomplete/demos/data.json b/packages/clay-autocomplete/demos/data.json new file mode 100644 index 0000000000..77fe659347 --- /dev/null +++ b/packages/clay-autocomplete/demos/data.json @@ -0,0 +1,52 @@ +[ + "json el0", + "json el1", + "json el2", + "json el3", + "json el4", + "json el5", + "json el6", + "json el7", + "json el8", + "json el9", + "json el10", + "json el11", + "json el12", + "json el13", + "json el14", + "json el15", + "json el16", + "json el17", + "json el18", + "json el19", + "json el20", + "json el21", + "json el22", + "json el23", + "json el24", + "json el25", + "json el26", + "json el27", + "json el28", + "json el29", + "json el30", + "json el31", + "json el32", + "json el33", + "json el34", + "json el35", + "json el36", + "json el37", + "json el38", + "json el39", + "json el40", + "json el41", + "json el42", + "json el43", + "json el44", + "json el45", + "json el46", + "json el47", + "json el48", + "json el49" +] diff --git a/packages/clay-autocomplete/demos/index.html b/packages/clay-autocomplete/demos/index.html new file mode 100644 index 0000000000..a0a84428db --- /dev/null +++ b/packages/clay-autocomplete/demos/index.html @@ -0,0 +1,50 @@ + + + + + + + Demo: ClayAutocomplete + + + + + + + + + +

+ ClayAutocomplete +

+ +
+ +
+ + + + + \ No newline at end of file diff --git a/packages/clay-autocomplete/package.json b/packages/clay-autocomplete/package.json new file mode 100644 index 0000000000..d2eeb687a7 --- /dev/null +++ b/packages/clay-autocomplete/package.json @@ -0,0 +1,56 @@ +{ + "name": "clay-autocomplete", + "version": "1.0.0-alpha.8", + "description": "Metal ClayAutocomplete component", + "license": "BSD", + "repository": "https://github.com/liferay/clay/tree/master/packages/clay-autocomplete", + "engines": { + "node": ">=0.12.0", + "npm": ">=3.0.0" + }, + "main": "lib/ClayAutocomplete.js", + "esnext:main": "src/ClayAutocomplete.js", + "jsnext:main": "src/ClayAutocomplete.js", + "files": [ + "lib", + "src", + "test" + ], + "scripts": { + "build": "npm run soy && webpack", + "dev": "webpack-dev-server --inline --hot", + "compile": "babel -d lib/ src/ -s --ignore src/__tests__", + "prepublish": "npm run soy && npm run compile", + "soy": "metalsoy" + }, + "keywords": [ + "clay", + "metal" + ], + "dependencies": { + "fuzzy": "^0.1.3", + "metal": "^2.16.0", + "metal-component": "^2.16.0", + "metal-soy": "^2.16.0", + "metal-state": "^2.16.0", + "metal-uri": "^3.1.1", + "metal-web-component": "^2.16.0" + }, + "devDependencies": { + "babel-cli": "^6.24.1", + "babel-core": "^6.25.0", + "babel-loader": "^7.0.0", + "babel-plugin-transform-node-env-inline": "^0.1.1", + "babel-preset-env": "^1.6.0", + "browserslist-config-clay-components": "^1.0.0-alpha.2", + "clay": "^2.0.0-beta.4", + "metal-dom": "^2.13.2", + "metal-soy-loader": "^1.0.0-alpha.4", + "metal-tools-soy": "^6.0.0", + "webpack": "^3.0.0", + "webpack-dev-server": "^2.11.2" + }, + "browserslist": [ + "extends browserslist-config-clay-components" + ] +} diff --git a/packages/clay-autocomplete/src/ClayAutocomplete.js b/packages/clay-autocomplete/src/ClayAutocomplete.js new file mode 100644 index 0000000000..8d6327c925 --- /dev/null +++ b/packages/clay-autocomplete/src/ClayAutocomplete.js @@ -0,0 +1,135 @@ +import Component from 'metal-component'; +import defineWebComponent from 'metal-web-component'; +import Soy from 'metal-soy'; +import {Config} from 'metal-state'; + +import DataProvider from './DataProvider'; +import templates from './ClayAutocomplete.soy'; + +/* eslint-disable require-jsdoc */ + +/** + * Metal ClayAutocomplete component. + */ +class ClayAutocomplete extends Component { + created() { + this._query = this.value; + this.provider = new DataProvider(this.data, { + elements: this.maxSuggestions, + paramName: this.paramName, + }); + this.provider.on( + 'suggestionsUpdated', + this._updateSuggestions.bind(this) + ); + } + + _clearSuggestions() { + if (this._suggestions.length > 0) { + this._updateSuggestions([]); + } + } + + _updateSuggestions(list) { + this._suggestions = list; + } + + _handleClick(event) { + this.submit(event); + } + + _handleSearch(event) { + this.value = event.delegateTarget.value.toLowerCase(); + if (this.value.length >= 3) { + this._query = this.value; + this.provider.updateSuggestions(this._query); + } else { + this._clearSuggestions(); + } + this._selectedSuggestionIndex = 0; + } + + _handleFocus() { + this._hasFocus = true; + } + + _handleBlur() { + this._hasFocus = false; + } + + _handleHover(e) { + this.selectSuggestion(parseInt(e.target.dataset.index, 10) + 1); + } + + _handleKeydown(event) { + this._hasFocus = true; + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.selectSuggestion(this._selectedSuggestionIndex + 1); + break; + + case 'ArrowUp': + event.preventDefault(); + this.selectSuggestion(this._selectedSuggestionIndex - 1); + break; + + case 'Enter': + event.target.blur(); + this.submit(event); + break; + + case 'Escape': + this._hasFocus = false; + this.selectSuggestion(0); + break; + } + } + + selectSuggestion(pos) { + const total = this._suggestions.length + 1; + + this._selectedSuggestionIndex = (pos + total) % total; + + this.value = + this._selectedSuggestionIndex > 0 + ? this._suggestions[this._selectedSuggestionIndex - 1] + : this._query; + } + + submit(event) { + this.emit('submitQuery', { + query: this.value, + selectedItem: this._suggestions[this._selectedSuggestionIndex - 1], + originalEvent: event || null, + }); + } +} + +ClayAutocomplete.STATE = { + _hasFocus: Config.bool() + .value(false) + .internal(), + _query: Config.string() + .value('') + .internal(), + _selectedSuggestionIndex: Config.number() + .value(0) + .internal(), + _suggestions: Config.array() + .value([]) + .internal(), + data: Config.oneOfType([Config.array(), Config.string()]), + elementClasses: Config.string(), + id: Config.string(), + maxSuggestions: Config.number().value(5), + paramName: Config.string().value('query'), + value: Config.string().value(''), +}; + +defineWebComponent('clay-autocomplete', ClayAutocomplete); + +Soy.register(ClayAutocomplete, templates); + +export {ClayAutocomplete}; +export default ClayAutocomplete; diff --git a/packages/clay-autocomplete/src/ClayAutocomplete.soy b/packages/clay-autocomplete/src/ClayAutocomplete.soy new file mode 100644 index 0000000000..79d75f25a3 --- /dev/null +++ b/packages/clay-autocomplete/src/ClayAutocomplete.soy @@ -0,0 +1,86 @@ +{namespace ClayAutocomplete} + +/** + * This renders the component's whole content. + */ +{template .render} + {@param _suggestions: list} + {@param? _handleBlur: any} + {@param? _handleClick: any} + {@param? _handleFocus: any} + {@param? _handleHover: any} + {@param? _handleKeydown: any} + {@param? _handleSearch: any} + {@param? _hasFocus: bool} + {@param? _selectedSuggestionIndex: int} + {@param? elementClasses: string} + {@param? id: string} + {@param? value: string} + + {let $expanded: $_hasFocus and $value and length($_suggestions) /} + + {let $attributes kind="attributes"} + class="clay-autocomplete dropdown-full form-group + {if $elementClasses} + {sp}{$elementClasses} + {/if} + " + + {if $id} + id="{$id}" + {/if} + {/let} + + {let $listAttributes kind="attributes"} + class="dropdown-menu + {if $expanded} + {sp}show + {/if} + " + {/let} + +
+ + +
+{/template} + +{template .item} + {@param item: string} + {@param itemIndex: int} + {@param? _selectedSuggestionIndex: int} + {@param _handleClick: any} + {@param _handleHover: any} + + {let $elementAttributes kind="attributes"} + class="dropdown-item + {if $_selectedSuggestionIndex - 1 == $itemIndex} + {sp}active + {/if} + " + data-onmousedown="{$_handleClick}" + data-onmouseover="{$_handleHover}" + data-index="{$itemIndex}" + {/let} + {$item} +{/template} \ No newline at end of file diff --git a/packages/clay-autocomplete/src/DataProvider.js b/packages/clay-autocomplete/src/DataProvider.js new file mode 100644 index 0000000000..86361561d5 --- /dev/null +++ b/packages/clay-autocomplete/src/DataProvider.js @@ -0,0 +1,57 @@ +import {isString} from 'metal'; +import Uri from 'metal-uri'; +import fuzzy from 'fuzzy'; +import EventEmitter from 'metal-events'; + +/* eslint-disable require-jsdoc */ + +function getDataFromArray(query, data, cb) { + cb( + fuzzy + .filter(query, data, { + extract: el => el, + }) + .map(e => e.string) + ); +} + +function getDataFromURL(query, url, paramName = 'q', cb) { + fetch(new Uri(url).setParameterValue(paramName, query)) + .then(response => response.json()) + .then(json => cb(json)); +} + +export default class DataProvider extends EventEmitter { + constructor(dataSource, options) { + super(); + this.options = Object.assign({}, this.defaults, options); + this.dataSource = dataSource; + this.setSuggestions = this.setSuggestions.bind(this); + } + + get defaults() { + return {elements: 5, paramName: 'q'}; + } + + updateSuggestions(query) { + const {dataSource, setSuggestions, options} = this; + + if (this.lock) return false; + this.lock = true; + if (Array.isArray(dataSource)) { + getDataFromArray(query, dataSource, setSuggestions); + } else if (isString(dataSource)) { + getDataFromURL( + query, + dataSource, + options.paramName, + setSuggestions + ); + } + } + + setSuggestions(data) { + this.emit('suggestionsUpdated', data.slice(0, this.options.elements)); + this.lock = false; + } +} diff --git a/packages/clay-autocomplete/src/__tests__/ClayAutocomplete.js b/packages/clay-autocomplete/src/__tests__/ClayAutocomplete.js new file mode 100644 index 0000000000..a9c435c14f --- /dev/null +++ b/packages/clay-autocomplete/src/__tests__/ClayAutocomplete.js @@ -0,0 +1,34 @@ +import ClayAutocomplete from '../ClayAutocomplete'; + +let component; + +describe('ClayAutocomplete', function() { + afterEach(() => { + if (component) { + component.dispose(); + } + }); + + it('should render the default markup', () => { + component = new ClayAutocomplete(); + + expect(component).toMatchSnapshot(); + }); + + it('should render a ClayAutocomplete with classes', () => { + component = new ClayAutocomplete({ + elementClasses: 'my-custom-class', + }); + + expect(component).toMatchSnapshot(); + }); + + it('should render a ClayAutocomplete with id', () => { + component = new ClayAutocomplete({ + id: 'myId', + }); + + expect(component).toMatchSnapshot(); + }); + +}); diff --git a/packages/clay-autocomplete/src/__tests__/__snapshots__/ClayAutocomplete.js.snap b/packages/clay-autocomplete/src/__tests__/__snapshots__/ClayAutocomplete.js.snap new file mode 100644 index 0000000000..d1e5c85a7a --- /dev/null +++ b/packages/clay-autocomplete/src/__tests__/__snapshots__/ClayAutocomplete.js.snap @@ -0,0 +1 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP diff --git a/packages/clay-autocomplete/webpack.config.js b/packages/clay-autocomplete/webpack.config.js new file mode 100644 index 0000000000..447ee336af --- /dev/null +++ b/packages/clay-autocomplete/webpack.config.js @@ -0,0 +1,40 @@ +const path = require('path'); +const webpack = require('webpack'); + +module.exports = { + entry: './src/ClayAutocomplete.js', + module: { + rules: [ + { + test: /\.js$/, + exclude: /(node_modules|bower_components)/, + use: { + loader: 'babel-loader', + options: { + compact: false, + presets: ['babel-preset-env'], + plugins: ['babel-plugin-transform-node-env-inline'], + }, + }, + }, + { + test: /\.soy$/, + loader: 'metal-soy-loader', + }, + ], + }, + devtool: 'cheap-module-source-map', + output: { + library: 'metal', + libraryTarget: 'this', + filename: './build/globals/clay-autocomplete.js', + publicPath: '/packages/clay-autocomplete/', + }, + plugins: [new webpack.optimize.ModuleConcatenationPlugin()], + resolve: { + mainFields: ['esnext:main', 'main'], + }, + devServer: { + contentBase: path.join(__dirname, '..', '..'), + }, +};