From 5b68ef7d8e563403c34e42f0368dc3ba1bf0a103 Mon Sep 17 00:00:00 2001 From: Alexandr Subbotin Date: Mon, 27 Feb 2017 18:43:56 +0100 Subject: [PATCH] Implement suggestions preview pane like in core google plugin --- src/Preview/KeyboardNav/focusableSelector.js | 18 +++++ src/Preview/KeyboardNav/index.js | 81 ++++++++++++++++++++ src/Preview/KeyboardNavItem/index.js | 36 +++++++++ src/Preview/KeyboardNavItem/styles.css | 20 +++++ src/Preview/Loading/index.js | 11 +++ src/Preview/Loading/styles.css | 30 ++++++++ src/Preview/Preload.js | 28 +++++++ src/Preview/index.js | 50 ++++++++++++ src/getSuggestions.js | 22 ++++++ src/index.js | 7 +- 10 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/Preview/KeyboardNav/focusableSelector.js create mode 100644 src/Preview/KeyboardNav/index.js create mode 100644 src/Preview/KeyboardNavItem/index.js create mode 100644 src/Preview/KeyboardNavItem/styles.css create mode 100644 src/Preview/Loading/index.js create mode 100644 src/Preview/Loading/styles.css create mode 100644 src/Preview/Preload.js create mode 100644 src/Preview/index.js create mode 100644 src/getSuggestions.js diff --git a/src/Preview/KeyboardNav/focusableSelector.js b/src/Preview/KeyboardNav/focusableSelector.js new file mode 100644 index 0000000..03d32b8 --- /dev/null +++ b/src/Preview/KeyboardNav/focusableSelector.js @@ -0,0 +1,18 @@ +/** + * DOM selector for all focusable elements + * @type {Array} + */ +module.exports = [ + // Links & areas with href attribute + 'a[href]', + 'area[href]', + + // Not disabled form elements + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + + // All elements with tabindex >= 0 + '[tabindex]:not([tabindex^="-"])' +].join(', ') diff --git a/src/Preview/KeyboardNav/index.js b/src/Preview/KeyboardNav/index.js new file mode 100644 index 0000000..e24b128 --- /dev/null +++ b/src/Preview/KeyboardNav/index.js @@ -0,0 +1,81 @@ +const React = require('react') +const focusableSelector = require('./focusableSelector') + +/** + * Focus element with index from elements array. + * If `index` >= `elements.length` then first element is selected; + * If `index` <= 0 then last element is selected. + * + * @param {Array} elements + * @param {Integer} index + */ +const moveSelectionTo = (elements, index) => { + let nextIndex = index + if (index < 0) { + nextIndex = elements.length - 1 + } else if (index >= elements.length) { + nextIndex = 0 + } + elements[nextIndex].focus() +} + +/** + * Handler keydown in keyboard navigation component + * + * @param {DOMElement} wrapper + * @param {KeyboardEvent} event + */ +const onKeyDown = (wrapper, event) => { + const { target, keyCode } = event + if (keyCode === 37) { + // Move control back to main list when ← is clicked + const mainInput = document.querySelector('#main-input') + const position = mainInput.value.length + mainInput.focus() + mainInput.setSelectionRange(position, position) + event.preventDefault() + event.stopPropagation() + return false + } + if (keyCode !== 40 && keyCode !== 38) { + return false + } + + // Get all focusable element in element + const focusable = wrapper.querySelectorAll(focusableSelector) + + // Get index of currently focused element + const index = Array.prototype.findIndex.call(focusable, (el) => el === target) + + if (keyCode === 40) { + // Select next focusable element when arrow down clicked + moveSelectionTo(focusable, index + 1) + event.stopPropagation() + } else if (keyCode === 38) { + // Select previous focusable element when arrow down clicked + moveSelectionTo(focusable, index - 1) + event.stopPropagation() + } +} + +class KeyboardNav extends React.Component { + onKeyDown(event) { + onKeyDown(this.wrapper, event) + } + render() { + return ( +
{ this.wrapper = el }}> + {this.props.children} +
+ ) + } +} + +KeyboardNav.propTypes = { + children: React.PropTypes.oneOfType([ + React.PropTypes.element, + React.PropTypes.arrayOf(React.PropTypes.element), + ]) +} + +module.exports = KeyboardNav diff --git a/src/Preview/KeyboardNavItem/index.js b/src/Preview/KeyboardNavItem/index.js new file mode 100644 index 0000000..000c240 --- /dev/null +++ b/src/Preview/KeyboardNavItem/index.js @@ -0,0 +1,36 @@ +const React = require('react') +const styles = require('./styles.css') + +const KeyboardNavItem = (props) => { + let className = styles.item + className += props.className ? ` ${props.className}` : '' + const onSelect = props.onSelect || (() => {}) + const onClick = onSelect + const onKeyDown = (event) => { + if (props.onKeyDown) { + props.onKeyDown(event) + } + if (!event.defaultPrevented && event.keyCode === 13) { + onSelect() + } + } + const itemProps = { + className, + onClick, + onKeyDown, + tabIndex: 0, + } + const TagName = props.tagName ? props.tagName : 'div' + return ( + + ) +} + +KeyboardNavItem.propTypes = { + className: React.PropTypes.string, + tagName: React.PropTypes.string, + onSelect: React.PropTypes.func, + onKeyDown: React.PropTypes.func, +} + +module.exports = KeyboardNavItem diff --git a/src/Preview/KeyboardNavItem/styles.css b/src/Preview/KeyboardNavItem/styles.css new file mode 100644 index 0000000..e78b144 --- /dev/null +++ b/src/Preview/KeyboardNavItem/styles.css @@ -0,0 +1,20 @@ +.item { + cursor: pointer; + border-bottom: var(--main-border); + background: var(--result-background); + color: var(--result-title-color); + padding: 0 5px; + height: 35px; + display: flex; + align-items: center; +} + +.item:last-child { + border-bottom: 0; +} + +.item:focus { + outline: none; + background: var(--selected-result-background); + color: var(--selected-result-title-color); +} diff --git a/src/Preview/Loading/index.js b/src/Preview/Loading/index.js new file mode 100644 index 0000000..7411d7a --- /dev/null +++ b/src/Preview/Loading/index.js @@ -0,0 +1,11 @@ +import React from 'react' + +import styles from './styles.css' + +export default () => ( +
+
+
+
+
+) diff --git a/src/Preview/Loading/styles.css b/src/Preview/Loading/styles.css new file mode 100644 index 0000000..575e9f1 --- /dev/null +++ b/src/Preview/Loading/styles.css @@ -0,0 +1,30 @@ +.spinner { + width: 70px; + text-align: center; + + div { + width: 18px; + height: 18px; + background-color: var(--secondary-font-color); + + border-radius: 100%; + display: inline-block; + animation: sk-bouncedelay 1.4s infinite ease-in-out both; + } + + .bounce1 { + animation-delay: -0.32s; + } + + .bounce2 { + animation-delay: -0.16s; + } +} + +@keyframes sk-bouncedelay { + 0%, 80%, 100% { + transform: scale(0); + } 40% { + transform: scale(1.0); + } +} diff --git a/src/Preview/Preload.js b/src/Preview/Preload.js new file mode 100644 index 0000000..4e06488 --- /dev/null +++ b/src/Preview/Preload.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' + +/** + * Component that renders child function only after props.promise is resolved or rejected + * You can provide props.loader that will be rendered before + */ +export default class Preload extends Component { + constructor(props) { + super(props) + this.state = { + result: null, + error: null + } + } + componentDidMount() { + this.props.promise + .then(result => this.setState({ result })) + .catch(error => this.setState({ error })) + } + render() { + const { loader, children } = this.props + const { result, error } = this.state + if (result || error) { + return children(result, error) + } + return loader || null + } +} diff --git a/src/Preview/index.js b/src/Preview/index.js new file mode 100644 index 0000000..8fae571 --- /dev/null +++ b/src/Preview/index.js @@ -0,0 +1,50 @@ +import React, { Component, PropTypes } from 'react' +import Loading from './Loading' +import Preload from './Preload' +import KeyboardNav from './KeyboardNav' +import KeyboardNavItem from './KeyboardNavItem' +import getSuggestions from '../getSuggestions' + + +const wrapperStyles = { + alignSelf: 'flex-start', + width: '100%', + margin: '-10px' +} + +const listStyles = { + margin: 0, + padding: 0 +} + +export default class Preview extends Component { + renderSuggestions(suggestions, searchFn) { + return ( +
+ +
    + { + suggestions.map(s => ( + searchFn(s)} + > + {s} + + )) + } +
+
+
+ ) + } + render() { + const { query, search } = this.props + return ( + }> + {(suggestions) => this.renderSuggestions(suggestions || [], search)} + + ) + } +} diff --git a/src/getSuggestions.js b/src/getSuggestions.js new file mode 100644 index 0000000..8c27730 --- /dev/null +++ b/src/getSuggestions.js @@ -0,0 +1,22 @@ +import { memoize } from 'cerebro-tools' + +/** + * Get google suggestions for entered query + * @param {String} query + * @return {Promise} + */ +const getSuggestions = (query) => { + const url = `https://duckduckgo.com/ac/?q=${query}` + return fetch(url) + .then(response => response.json()) + .then(items => items.map(i => i.phrase)) +} + + +export default memoize(getSuggestions, { + length: false, + promise: 'then', + // Expire translation cache in 30 minutes + maxAge: 30 * 60 * 1000, + preFetch: true +}) diff --git a/src/index.js b/src/index.js index c040e7c..6a6ff6d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,5 @@ +import React from 'react' +import Preview from './Preview' import icon from './icon.png' const order = 1 @@ -12,8 +14,9 @@ const plugin = ({ term, actions, display }) => { display({ icon: icon, order: order, // High priority - title: `Search DuckDuckGo for ${term}`, - onSelect: () => search(term) + title: `Search DuckDuckGo For ${term}`, + onSelect: () => search(term), + getPreview: () => }) }