Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement suggestions preview pane like in core google plugin #1

Merged
merged 1 commit into from
Feb 27, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/Preview/KeyboardNav/focusableSelector.js
Original file line number Diff line number Diff line change
@@ -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(', ')
81 changes: 81 additions & 0 deletions src/Preview/KeyboardNav/index.js
Original file line number Diff line number Diff line change
@@ -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<DOMElement>} 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 (
<div onKeyDown={this.onKeyDown.bind(this)} ref={(el) => { this.wrapper = el }}>
{this.props.children}
</div>
)
}
}

KeyboardNav.propTypes = {
children: React.PropTypes.oneOfType([
React.PropTypes.element,
React.PropTypes.arrayOf(React.PropTypes.element),
])
}

module.exports = KeyboardNav
36 changes: 36 additions & 0 deletions src/Preview/KeyboardNavItem/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<TagName {...props} {...itemProps} />
)
}

KeyboardNavItem.propTypes = {
className: React.PropTypes.string,
tagName: React.PropTypes.string,
onSelect: React.PropTypes.func,
onKeyDown: React.PropTypes.func,
}

module.exports = KeyboardNavItem
20 changes: 20 additions & 0 deletions src/Preview/KeyboardNavItem/styles.css
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 11 additions & 0 deletions src/Preview/Loading/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react'

import styles from './styles.css'

export default () => (
<div className={styles.spinner}>
<div className={styles.bounce1}></div>
<div className={styles.bounce2}></div>
<div className={styles.bounce3}></div>
</div>
)
30 changes: 30 additions & 0 deletions src/Preview/Loading/styles.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
28 changes: 28 additions & 0 deletions src/Preview/Preload.js
Original file line number Diff line number Diff line change
@@ -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
}
}
50 changes: 50 additions & 0 deletions src/Preview/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<div style={wrapperStyles}>
<KeyboardNav>
<ul style={listStyles}>
{
suggestions.map(s => (
<KeyboardNavItem
key={s}
tagName={'li'}
onSelect={() => searchFn(s)}
>
{s}
</KeyboardNavItem>
))
}
</ul>
</KeyboardNav>
</div>
)
}
render() {
const { query, search } = this.props
return (
<Preload promise={getSuggestions(query)} loader={<Loading />}>
{(suggestions) => this.renderSuggestions(suggestions || [], search)}
</Preload>
)
}
}
22 changes: 22 additions & 0 deletions src/getSuggestions.js
Original file line number Diff line number Diff line change
@@ -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}`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@KELiON omg! I was still reading DuckDuckGo's docs and you already made this as a working feature. Incredible! 💯

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
})
7 changes: 5 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react'
import Preview from './Preview'
import icon from './icon.png'

const order = 1
Expand All @@ -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: () => <Preview query={term} key={term} search={search} />
})
}

Expand Down