summaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorhmhealey <harrisonmhealey@gmail.com>2015-10-21 11:03:50 -0400
committerhmhealey <harrisonmhealey@gmail.com>2015-10-23 13:05:38 -0400
commita5a2826700b1fc6b19ba38698cfa703f58476bc6 (patch)
treea57d55458bd2b8f96673513a8f91c88d72feb85f /web
parent4172d0286e61b4fd5459fc64e7653535751a012d (diff)
downloadchat-a5a2826700b1fc6b19ba38698cfa703f58476bc6.tar.gz
chat-a5a2826700b1fc6b19ba38698cfa703f58476bc6.tar.bz2
chat-a5a2826700b1fc6b19ba38698cfa703f58476bc6.zip
Added keyboard selection to search autocomplete
Diffstat (limited to 'web')
-rw-r--r--web/react/components/search_autocomplete.jsx176
-rw-r--r--web/react/components/search_bar.jsx19
-rw-r--r--web/react/stores/user_store.jsx4
-rw-r--r--web/react/utils/constants.jsx3
-rw-r--r--web/sass-files/sass/partials/_search.scss10
5 files changed, 166 insertions, 46 deletions
diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx
index 0229b07fd..03c7b894c 100644
--- a/web/react/components/search_autocomplete.jsx
+++ b/web/react/components/search_autocomplete.jsx
@@ -2,13 +2,14 @@
// See License.txt for license information.
const ChannelStore = require('../stores/channel_store.jsx');
+const KeyCodes = require('../utils/constants.jsx').KeyCodes;
const UserStore = require('../stores/user_store.jsx');
const Utils = require('../utils/utils.jsx');
-const patterns = {
- channels: /\b(?:in|channel):\s*(\S*)$/i,
- users: /\bfrom:\s*(\S*)$/i
-};
+const patterns = new Map([
+ ['channels', /\b(?:in|channel):\s*(\S*)$/i],
+ ['users', /\bfrom:\s*(\S*)$/i]
+]);
export default class SearchAutocomplete extends React.Component {
constructor(props) {
@@ -17,11 +18,17 @@ export default class SearchAutocomplete extends React.Component {
this.handleClick = this.handleClick.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+
+ this.completeWord = this.completeWord.bind(this);
+ this.updateSuggestions = this.updateSuggestions.bind(this);
this.state = {
show: false,
mode: '',
- filter: ''
+ filter: '',
+ selection: 0,
+ suggestions: new Map()
};
}
@@ -34,13 +41,7 @@ export default class SearchAutocomplete extends React.Component {
}
handleClick(value) {
- this.props.completeWord(this.state.filter, value);
-
- this.setState({
- show: false,
- mode: '',
- filter: ''
- });
+ this.completeWord(value);
}
handleDocumentClick(e) {
@@ -59,16 +60,20 @@ export default class SearchAutocomplete extends React.Component {
let mode = '';
let filter = '';
- for (const pattern in patterns) {
- const result = patterns[pattern].exec(preText);
+ for (const [modeForPattern, pattern] of patterns) {
+ const result = pattern.exec(preText);
if (result) {
- mode = pattern;
+ mode = modeForPattern;
filter = result[1];
break;
}
}
+ if (mode !== this.state.mode || filter !== this.state.filter) {
+ this.updateSuggestions(mode, filter);
+ }
+
this.setState({
mode,
filter,
@@ -76,48 +81,147 @@ export default class SearchAutocomplete extends React.Component {
});
}
- render() {
- if (!this.state.show) {
- return null;
+ handleKeyDown(e) {
+ if (!this.state.show || this.state.suggestions.length === 0) {
+ return;
}
- let suggestions = [];
+ if (e.which === KeyCodes.UP || e.which === KeyCodes.DOWN) {
+ e.preventDefault();
+ let selection = this.state.selection;
+
+ if (e.which === KeyCodes.UP) {
+ selection -= 1;
+ } else {
+ selection += 1;
+ }
+
+ if (selection >= 0 && selection < this.state.suggestions.length) {
+ this.setState({
+ selection
+ });
+ }
+ } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) {
+ e.preventDefault();
+
+ this.completeSelectedWord();
+ }
+ }
+
+ completeSelectedWord() {
if (this.state.mode === 'channels') {
+ this.completeWord(this.state.suggestions[this.state.selection].name);
+ } else if (this.state.mode === 'users') {
+ this.completeWord(this.state.suggestions[this.state.selection].username);
+ }
+ }
+
+ completeWord(value) {
+ // add a space so that anything else typed doesn't interfere with the search flag
+ this.props.completeWord(this.state.filter, value + ' ');
+
+ this.setState({
+ show: false,
+ mode: '',
+ filter: '',
+ selection: 0
+ });
+ }
+
+ updateSuggestions(mode, filter) {
+ let suggestions = [];
+
+ if (mode === 'channels') {
let channels = ChannelStore.getAll();
- if (this.state.filter) {
- channels = channels.filter((channel) => channel.name.startsWith(this.state.filter));
+ if (filter) {
+ channels = channels.filter((channel) => channel.name.startsWith(filter));
}
channels.sort((a, b) => a.name.localeCompare(b.name));
- suggestions = channels.map((channel) => {
+ suggestions = channels;
+ } else if (mode === 'users') {
+ let users = UserStore.getActiveOnlyProfileList();
+
+ if (filter) {
+ users = users.filter((user) => user.username.startsWith(filter));
+ }
+
+ users.sort((a, b) => a.username.localeCompare(b.username));
+
+ suggestions = users;
+ }
+
+ let selection = this.state.selection;
+
+ // keep the same user/channel selected if it's still visible as a suggestion
+ if (selection > 0 && this.state.suggestions.length > 0) {
+ // we can't just use indexOf to find if the selection is still in the list since they are different javascript objects
+ const currentSelectionId = this.state.suggestions[selection].id;
+ let found = false;
+
+ for (let i = 0; i < suggestions.length; i++) {
+ if (suggestions[i].id === currentSelectionId) {
+ selection = i;
+ found = true;
+
+ break;
+ }
+ }
+
+ if (!found) {
+ selection = 0;
+ }
+ } else {
+ selection = 0;
+ }
+
+ this.setState({
+ suggestions,
+ selection
+ });
+ }
+
+ render() {
+ if (!this.state.show || this.state.suggestions.length === 0) {
+ return null;
+ }
+
+ let suggestions = [];
+
+ if (this.state.mode === 'channels') {
+ suggestions = this.state.suggestions.map((channel, index) => {
+ let className = 'search-autocomplete__channel';
+ if (this.state.selection === index) {
+ className += ' selected';
+ }
+
return (
<div
- key={channel.id}
+ key={channel.name}
+ ref={channel.name}
onClick={this.handleClick.bind(this, channel.name)}
- className='search-autocomplete__channel'
+ className={className}
>
{channel.name}
</div>
);
});
} else if (this.state.mode === 'users') {
- let users = UserStore.getActiveOnlyProfileList();
-
- if (this.state.filter) {
- users = users.filter((user) => user.username.startsWith(this.state.filter));
- }
-
- users.sort((a, b) => a.username.localeCompare(b.username));
+ suggestions = this.state.suggestions.map((user, index) => {
+ let className = 'search-autocomplete__user';
+ if (this.state.selection === index) {
+ className += ' selected';
+ }
- suggestions = users.map((user) => {
return (
<div
- key={user.id}
+ key={user.username}
+ ref={user.username}
onClick={this.handleClick.bind(this, user.username)}
- className='search-autocomplete__user'
+ className={className}
>
<img
className='profile-img'
@@ -129,10 +233,6 @@ export default class SearchAutocomplete extends React.Component {
});
}
- if (suggestions.length === 0) {
- return null;
- }
-
return (
<div
ref='container'
diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx
index 509ca94e9..3932807d0 100644
--- a/web/react/components/search_bar.jsx
+++ b/web/react/components/search_bar.jsx
@@ -17,6 +17,7 @@ export default class SearchBar extends React.Component {
this.mounted = false;
this.onListenerChange = this.onListenerChange.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleUserInput = this.handleUserInput.bind(this);
this.handleUserFocus = this.handleUserFocus.bind(this);
this.handleUserBlur = this.handleUserBlur.bind(this);
@@ -76,6 +77,11 @@ export default class SearchBar extends React.Component {
results: null
});
}
+ handleKeyDown(e) {
+ if (this.refs.autocomplete) {
+ this.refs.autocomplete.handleKeyDown(e);
+ }
+ }
handleUserInput(e) {
var term = e.target.value;
PostStore.storeSearchTerm(term);
@@ -101,7 +107,7 @@ export default class SearchBar extends React.Component {
this.setState({isSearching: true});
client.search(
terms,
- function success(data) {
+ (data) => {
this.setState({isSearching: false});
if (utils.isMobile()) {
ReactDOM.findDOMNode(this.refs.search).value = '';
@@ -112,11 +118,11 @@ export default class SearchBar extends React.Component {
results: data,
is_mention_search: isMentionSearch
});
- }.bind(this),
- function error(err) {
+ },
+ (err) => {
this.setState({isSearching: false});
AsyncClient.dispatchError(err, 'search');
- }.bind(this)
+ }
);
}
}
@@ -165,13 +171,13 @@ export default class SearchBar extends React.Component {
className='search__clear'
onClick={this.clearFocus}
>
- Cancel
+ {'Cancel'}
</span>
<form
role='form'
className='search__form relative-div'
onSubmit={this.handleSubmit}
- style={{"overflow": "visible"}}
+ style={{overflow: 'visible'}}
>
<span className='glyphicon glyphicon-search sidebar__search-icon' />
<input
@@ -183,6 +189,7 @@ export default class SearchBar extends React.Component {
onFocus={this.handleUserFocus}
onBlur={this.handleUserBlur}
onChange={this.handleUserInput}
+ onKeyDown={this.handleKeyDown}
onMouseUp={this.handleMouseInput}
/>
{isSearching}
diff --git a/web/react/stores/user_store.jsx b/web/react/stores/user_store.jsx
index e3e1944ce..ce80c5ec9 100644
--- a/web/react/stores/user_store.jsx
+++ b/web/react/stores/user_store.jsx
@@ -221,7 +221,9 @@ class UserStoreClass extends EventEmitter {
const profiles = [];
for (const id in profileMap) {
- profiles.push(profileMap[id]);
+ if (profileMap.hasOwnProperty(id)) {
+ profiles.push(profileMap[id]);
+ }
}
return profiles;
diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx
index 7d2626fc1..72773bf05 100644
--- a/web/react/utils/constants.jsx
+++ b/web/react/utils/constants.jsx
@@ -311,6 +311,7 @@ module.exports = {
RIGHT: 39,
BACKSPACE: 8,
ENTER: 13,
- ESCAPE: 27
+ ESCAPE: 27,
+ SPACE: 32
}
};
diff --git a/web/sass-files/sass/partials/_search.scss b/web/sass-files/sass/partials/_search.scss
index d7287295b..ce3563885 100644
--- a/web/sass-files/sass/partials/_search.scss
+++ b/web/sass-files/sass/partials/_search.scss
@@ -124,11 +124,17 @@
}
.search-autocomplete__channel {
+ cursor: pointer;
height: 36px;
padding: 0px 6px;
+
+ &.selected {
+ background-color:rgba(51, 51, 51, 0.15);
+ }
}
.search-autocomplete__user {
+ cursor: pointer;
height: 36px;
padding: 0px;
@@ -138,4 +144,8 @@
width: 32px;
@include border-radius(16px);
}
+
+ &.selected {
+ background-color:rgba(51, 51, 51, 0.15);
+ }
}