Moved findBar to components and decopuled it from redux as much as possible.
Removed stories from containers. These aren't possible to write since containers depend on redux.
This commit is contained in:
@ -3,7 +3,7 @@ import pluralize from 'pluralize';
|
||||
import { IndexLink } from 'react-router';
|
||||
import { Menu, MenuItem, Button } from 'react-toolbox';
|
||||
import AppBar from 'react-toolbox/lib/app_bar';
|
||||
import FindBar from '../../../containers/FindBar';
|
||||
import FindBar from '../FindBar/FindBar';
|
||||
import styles from './AppHeader.css';
|
||||
|
||||
export default class AppHeader extends React.Component {
|
||||
@ -38,7 +38,7 @@ export default class AppHeader extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collections, commands, defaultCommands } = this.props;
|
||||
const { collections, commands, defaultCommands, runCommand } = this.props;
|
||||
const { createMenuActive } = this.state;
|
||||
|
||||
return (
|
||||
@ -52,6 +52,7 @@ export default class AppHeader extends React.Component {
|
||||
<FindBar
|
||||
commands={commands}
|
||||
defaultCommands={defaultCommands}
|
||||
runCommand={runCommand}
|
||||
/>
|
||||
<Button
|
||||
className={styles.createBtn}
|
||||
|
98
src/components/UI/FindBar/FindBar.css
Normal file
98
src/components/UI/FindBar/FindBar.css
Normal file
@ -0,0 +1,98 @@
|
||||
:root {
|
||||
--foregroundColor: #fff;
|
||||
--backgroundColor: #272e30;
|
||||
--textFieldBorderColor: #e7e7e7;
|
||||
--highlightFGColor: #fff;
|
||||
--highlightBGColor: #3ab7a5;
|
||||
}
|
||||
|
||||
.root {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background-color: var(--backgroundColor);
|
||||
padding: 5px;
|
||||
}
|
||||
.inputArea {
|
||||
display: table;
|
||||
width: 100%;
|
||||
color: var(--foregroundColor);
|
||||
background-color: #fff;
|
||||
border: 1px solid var(--textFieldBorderColor);
|
||||
}
|
||||
|
||||
.inputScope {
|
||||
display: table-cell;
|
||||
width: 1%;
|
||||
padding: 0 6px 0 8px;
|
||||
color: #767676;
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
border-right: 1px solid var(--textFieldBorderColor);
|
||||
margin:0;
|
||||
}
|
||||
|
||||
.inputField {
|
||||
display: table-cell;
|
||||
width: 99%;
|
||||
padding: 6px;
|
||||
font-size: 16px;
|
||||
background: none transparent;
|
||||
border: 0 none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
display: table;
|
||||
width: 100%;
|
||||
background: var(--backgroundColor);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
padding: 5px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: table-cell;
|
||||
width: 50%;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.history {
|
||||
display: table-cell;
|
||||
width: 50%;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.command {
|
||||
padding: 6px;
|
||||
cursor: default;
|
||||
color: var(--foregroundColor);
|
||||
}
|
||||
|
||||
.command strong, .highlightedCommand strong {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.command .faded {
|
||||
font-weight: 300;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.highlightedCommand {
|
||||
color: var(--highlightFGColor);
|
||||
background: var(--highlightBGColor);
|
||||
padding: 6px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.highlightedCommand .faded {
|
||||
font-weight: 300;
|
||||
color: #282c34;
|
||||
}
|
379
src/components/UI/FindBar/FindBar.js
Normal file
379
src/components/UI/FindBar/FindBar.js
Normal file
@ -0,0 +1,379 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import fuzzy from 'fuzzy';
|
||||
import _ from 'lodash';
|
||||
import { Icon } from '../index';
|
||||
import styles from './FindBar.css';
|
||||
|
||||
export const SEARCH = 'SEARCH';
|
||||
const PLACEHOLDER = 'Search or enter a command';
|
||||
|
||||
class FindBar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._compiledCommands = [];
|
||||
this._searchCommand = {
|
||||
search: true,
|
||||
regexp: `(?:${SEARCH})?(.*)`,
|
||||
param: { name: 'searchTerm', display: '' },
|
||||
token: SEARCH
|
||||
};
|
||||
this.state = {
|
||||
value: '',
|
||||
placeholder: PLACEHOLDER,
|
||||
activeScope: null,
|
||||
isOpen: false,
|
||||
highlightedIndex: 0,
|
||||
};
|
||||
|
||||
this._getSuggestions = _.memoize(this._getSuggestions, (value, activeScope) => value + activeScope);
|
||||
this.compileCommand = this.compileCommand.bind(this);
|
||||
this.matchCommand = this.matchCommand.bind(this);
|
||||
this.maybeRemoveActiveScope = this.maybeRemoveActiveScope.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
this.handleInputBlur = this.handleInputBlur.bind(this);
|
||||
this.handleInputFocus = this.handleInputFocus.bind(this);
|
||||
this.handleInputClick = this.handleInputClick.bind(this);
|
||||
this.getSuggestions = this.getSuggestions.bind(this);
|
||||
this.highlightCommandFromMouse = this.highlightCommandFromMouse.bind(this);
|
||||
this.selectCommandFromMouse = this.selectCommandFromMouse.bind(this);
|
||||
this.setIgnoreBlur = this.setIgnoreBlur.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this._ignoreBlur = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._compiledCommands = this.props.commands.map(this.compileCommand);
|
||||
}
|
||||
|
||||
_escapeRegExp(string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
_camelCaseToSpace(string) {
|
||||
const result = string.replace(/([A-Z])/g, ' $1');
|
||||
return result.charAt(0).toUpperCase() + result.slice(1);
|
||||
}
|
||||
|
||||
// Generates a regexp and splits a token and param details for a command
|
||||
compileCommand(command) {
|
||||
let regexp = '';
|
||||
let param = null;
|
||||
|
||||
const matcher = /\(:([a-zA-Z_$][a-zA-Z0-9_$]*)(?:(?: as )(.*))?\)/g;
|
||||
const match = matcher.exec(command.pattern);
|
||||
const matchIndex = match ? match.index : command.pattern.length;
|
||||
|
||||
const token = command.pattern.slice(0, matchIndex) || command.token;
|
||||
regexp += this._escapeRegExp(command.pattern.slice(0, matchIndex));
|
||||
|
||||
if (match && match[1]) {
|
||||
regexp += '(.*)';
|
||||
param = { name: match[1], display: match[2] || this._camelCaseToSpace(match[1]) };
|
||||
}
|
||||
|
||||
return Object.assign({}, command, {
|
||||
regexp,
|
||||
token,
|
||||
param
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the entered string matches any command.
|
||||
// adds a scope (so user can type param value) and dispatches action for fully matched commands
|
||||
matchCommand() {
|
||||
const string = this.state.activeScope ? this.state.activeScope + this.state.value : this.state.value;
|
||||
let match;
|
||||
let command = this._compiledCommands.find(command => {
|
||||
match = string.match(RegExp(`^${command.regexp}`, 'i'));
|
||||
return match;
|
||||
});
|
||||
|
||||
// If no command was found, trigger a search command
|
||||
if (!command) {
|
||||
command = this._searchCommand;
|
||||
match = string.match(RegExp(`^${this._searchCommand.regexp}`, 'i'));
|
||||
}
|
||||
|
||||
const paramName = command && command.param ? command.param.name : null;
|
||||
const enteredParamValue = command && command.param && match[1] ? match[1].trim() : null;
|
||||
|
||||
console.log(this.props.runCommand);
|
||||
|
||||
if (command.search) {
|
||||
this.setState({
|
||||
activeScope: SEARCH,
|
||||
placeholder: ''
|
||||
});
|
||||
|
||||
enteredParamValue && this.props.runCommand(SEARCH, { searchTerm: enteredParamValue });
|
||||
} else if (command.param && !enteredParamValue) {
|
||||
// Partial Match
|
||||
// Command was partially matched: It requires a param, but param wasn't entered
|
||||
// Set a scope so user can fill the param
|
||||
this.setState({
|
||||
value: '',
|
||||
activeScope: command.token,
|
||||
placeholder: command.param.display
|
||||
});
|
||||
} else {
|
||||
// Match
|
||||
// Command was matched and either it doesn't require a param or it's required param was entered
|
||||
// Dispatch action
|
||||
this.setState({
|
||||
value: '',
|
||||
placeholder: PLACEHOLDER,
|
||||
activeScope: null
|
||||
}, () => {
|
||||
this._input.blur();
|
||||
});
|
||||
const payload = command.payload || {};
|
||||
if (paramName) {
|
||||
payload[paramName] = enteredParamValue;
|
||||
}
|
||||
this.props.runCommand(command.type, payload);
|
||||
}
|
||||
}
|
||||
|
||||
maybeRemoveActiveScope() {
|
||||
if (this.state.value.length === 0 && this.state.activeScope) {
|
||||
this.setState({
|
||||
activeScope: null,
|
||||
placeholder: PLACEHOLDER
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getSuggestions() {
|
||||
return this._getSuggestions(this.state.value, this.state.activeScope, this._compiledCommands, this.props.defaultCommands);
|
||||
}
|
||||
|
||||
// Memoized version
|
||||
_getSuggestions(value, scope, commands, defaultCommands) {
|
||||
if (scope) return []; // No autocomplete for scoped input
|
||||
if (value.length === 0 && defaultCommands) {
|
||||
return commands
|
||||
.filter(command => defaultCommands.indexOf(command.id) !== -1)
|
||||
.map(result => (
|
||||
Object.assign({}, result, { string: result.token }
|
||||
)));
|
||||
}
|
||||
|
||||
const results = fuzzy.filter(value, commands, {
|
||||
pre: '<strong>',
|
||||
post: '</strong>',
|
||||
extract: el => el.token
|
||||
});
|
||||
|
||||
const returnResults = results.slice(0, 4).map(result => (
|
||||
Object.assign({}, result.original, { string: result.string }
|
||||
)));
|
||||
returnResults.push(this._searchCommand);
|
||||
|
||||
return returnResults;
|
||||
}
|
||||
|
||||
handleKeyDown(event) {
|
||||
let highlightedIndex, index;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
highlightedIndex = this.state.highlightedIndex;
|
||||
index = (
|
||||
highlightedIndex === this.getSuggestions().length - 1 ||
|
||||
this.state.isOpen === false
|
||||
) ? 0 : highlightedIndex + 1;
|
||||
this.setState({
|
||||
highlightedIndex: index,
|
||||
isOpen: true,
|
||||
});
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
highlightedIndex = this.state.highlightedIndex;
|
||||
index = (
|
||||
highlightedIndex === 0
|
||||
) ? this.getSuggestions().length - 1 : highlightedIndex - 1;
|
||||
this.setState({
|
||||
highlightedIndex: index,
|
||||
isOpen: true,
|
||||
});
|
||||
break;
|
||||
case 'Enter':
|
||||
if (this.state.isOpen) {
|
||||
const command = this.getSuggestions()[this.state.highlightedIndex];
|
||||
const newState = {
|
||||
isOpen: false,
|
||||
highlightedIndex: 0
|
||||
};
|
||||
if (command && !command.search) {
|
||||
newState.value = command.token;
|
||||
}
|
||||
this.setState(newState, () => {
|
||||
this._input.focus();
|
||||
this._input.setSelectionRange(
|
||||
this.state.value.length,
|
||||
this.state.value.length
|
||||
);
|
||||
this.matchCommand();
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
this.setState({
|
||||
value: '',
|
||||
highlightedIndex: 0,
|
||||
isOpen: false,
|
||||
activeScope: null,
|
||||
placeholder: PLACEHOLDER
|
||||
});
|
||||
break;
|
||||
case 'Backspace':
|
||||
this.setState({
|
||||
highlightedIndex: 0,
|
||||
isOpen: true
|
||||
}, this.maybeRemoveActiveScope);
|
||||
break;
|
||||
default:
|
||||
this.setState({
|
||||
highlightedIndex: 0,
|
||||
isOpen: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleChange(event) {
|
||||
this.setState({
|
||||
value: event.target.value,
|
||||
});
|
||||
}
|
||||
|
||||
handleInputBlur() {
|
||||
if (this._ignoreBlur) return;
|
||||
this.setState({
|
||||
isOpen: false,
|
||||
highlightedIndex: 0
|
||||
});
|
||||
}
|
||||
|
||||
handleInputFocus() {
|
||||
if (this._ignoreBlur) return;
|
||||
this.setState({ isOpen: true });
|
||||
}
|
||||
|
||||
handleInputClick() {
|
||||
if (this.state.isOpen === false)
|
||||
this.setState({ isOpen: true });
|
||||
}
|
||||
|
||||
highlightCommandFromMouse(index) {
|
||||
this.setState({ highlightedIndex: index });
|
||||
}
|
||||
|
||||
selectCommandFromMouse(command) {
|
||||
const newState = {
|
||||
isOpen: false,
|
||||
highlightedIndex: 0
|
||||
};
|
||||
if (command && !command.search) {
|
||||
newState.value = command.token;
|
||||
}
|
||||
this.setState(newState, () => {
|
||||
this.matchCommand();
|
||||
this._input.focus();
|
||||
this.setIgnoreBlur(false);
|
||||
});
|
||||
}
|
||||
|
||||
setIgnoreBlur(ignore) {
|
||||
this._ignoreBlur = ignore;
|
||||
}
|
||||
|
||||
renderMenu() {
|
||||
const commands = this.getSuggestions().map((command, index) => {
|
||||
let children;
|
||||
if (!command.search) {
|
||||
children = (
|
||||
<span><span dangerouslySetInnerHTML={{ __html: command.string }}/></span>
|
||||
);
|
||||
} else {
|
||||
children = (
|
||||
<span>
|
||||
{this.state.value.length === 0 ?
|
||||
<span><Icon type="search"/>Search... </span> :
|
||||
<span className={styles.faded}><Icon type="search"/>Search for: </span>
|
||||
}
|
||||
<strong>{this.state.value}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={this.state.highlightedIndex === index ? styles.highlightedCommand : styles.command}
|
||||
key={command.token.trim().replace(/[^a-z0-9]+/gi, '-')}
|
||||
onMouseDown={() => this.setIgnoreBlur(true)}
|
||||
onMouseEnter={() => this.highlightCommandFromMouse(index)}
|
||||
onClick={() => this.selectCommandFromMouse(command)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return commands.length === 0 ? null : (
|
||||
<div className={styles.menu}>
|
||||
<div className={styles.suggestions}>
|
||||
{commands}
|
||||
</div>
|
||||
<div className={styles.history}>
|
||||
Your past searches and commands
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderActiveScope() {
|
||||
if (this.state.activeScope === SEARCH) {
|
||||
return <div className={styles.inputScope}><Icon type="search"/></div>;
|
||||
} else {
|
||||
return <div className={styles.inputScope}>{this.state.activeScope}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const menu = this.state.isOpen && this.renderMenu();
|
||||
const scope = this.state.activeScope && this.renderActiveScope();
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<label className={styles.inputArea}>
|
||||
{scope}
|
||||
<input
|
||||
className={styles.inputField}
|
||||
ref={(c) => this._input = c}
|
||||
onFocus={this.handleInputFocus}
|
||||
onBlur={this.handleInputBlur}
|
||||
onChange={this.handleChange}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onClick={this.handleInputClick}
|
||||
placeholder={this.state.placeholder}
|
||||
value={this.state.value}
|
||||
/>
|
||||
</label>
|
||||
{menu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FindBar.propTypes = {
|
||||
commands: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
pattern: PropTypes.string.isRequired
|
||||
})).isRequired,
|
||||
defaultCommands: PropTypes.arrayOf(PropTypes.string),
|
||||
runCommand: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default FindBar;
|
42
src/components/stories/FindBar.js
Normal file
42
src/components/stories/FindBar.js
Normal file
@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { storiesOf, action } from '@kadira/storybook';
|
||||
|
||||
import FindBar from '../UI/FindBar/FindBar';
|
||||
|
||||
const CREATE_COLLECTION = 'CREATE_COLLECTION';
|
||||
const CREATE_POST = 'CREATE_POST';
|
||||
const CREATE_ARTICLE = 'CREATE_ARTICLE';
|
||||
const CREATE_FAQ = 'CREATE_FAQ';
|
||||
const ADD_NEWS = 'ADD_NEWS';
|
||||
const ADD_USER = 'ADD_USER';
|
||||
const OPEN_SETTINGS = 'OPEN_SETTINGS';
|
||||
const HELP = 'HELP';
|
||||
const MORE_COMMANDS = 'MORE_COMMANDS';
|
||||
|
||||
const commands = [
|
||||
{ id: CREATE_COLLECTION, pattern: 'Create new Collection(:collectionName)' },
|
||||
{ id: CREATE_POST, pattern: 'Create new Post(:postName)' },
|
||||
{ id: CREATE_ARTICLE, pattern: 'Create new Article(:articleName)' },
|
||||
{ id: CREATE_FAQ, pattern: 'Create new FAQ item(:faqName as FAQ item name)' },
|
||||
{ id: ADD_NEWS, pattern: 'Add news item(:headline)' },
|
||||
{ id: ADD_USER, pattern: 'Add new User(:userName as User name)' },
|
||||
{ id: OPEN_SETTINGS, pattern: 'Go to Settings' },
|
||||
{ id: HELP, pattern: 'Help' },
|
||||
{ id: MORE_COMMANDS, pattern: 'More Commands...' },
|
||||
];
|
||||
|
||||
const style = {
|
||||
width: 800,
|
||||
margin: 20
|
||||
};
|
||||
|
||||
storiesOf('FindBar', module)
|
||||
.add('Default View', () => (
|
||||
<div style={style}>
|
||||
<FindBar
|
||||
commands={commands}
|
||||
defaultCommands={[CREATE_POST, CREATE_COLLECTION, OPEN_SETTINGS, HELP, MORE_COMMANDS]}
|
||||
runCommand={action}
|
||||
/>
|
||||
</div>
|
||||
));
|
@ -1,3 +1,4 @@
|
||||
import './Card';
|
||||
import './Icon';
|
||||
import './Toast';
|
||||
import './FindBar';
|
||||
|
Reference in New Issue
Block a user