From 4a1f6ad2a972bb0f30414db3dc1899d88a01d29b Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 20 Oct 2015 16:50:55 -0400 Subject: Added an autocomplete dropdown to the search bar --- web/react/components/search_autocomplete.jsx | 139 +++++++++++++++++++++++++++ web/react/components/search_bar.jsx | 27 ++++++ 2 files changed, 166 insertions(+) create mode 100644 web/react/components/search_autocomplete.jsx (limited to 'web/react/components') diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx new file mode 100644 index 000000000..284b475c1 --- /dev/null +++ b/web/react/components/search_autocomplete.jsx @@ -0,0 +1,139 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +const ChannelStore = require('../stores/channel_store.jsx'); +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 +}; + +export default class SearchAutocomplete extends React.Component { + constructor(props) { + super(props); + + this.handleClick = this.handleClick.bind(this); + this.handleDocumentClick = this.handleDocumentClick.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); + + this.state = { + show: false, + mode: '', + filter: '' + }; + } + + componentDidMount() { + $(document).on('click', this.handleDocumentClick); + } + + componentWillUnmount() { + $(document).off('click', this.handleDocumentClick); + } + + handleClick(value) { + this.props.completeWord(this.state.filter, value); + + this.setState({ + show: false, + mode: '', + filter: '' + }); + } + + handleDocumentClick(e) { + const container = $(ReactDOM.findDOMNode(this.refs.container)); + + if (!(container.is(e.target) || container.has(e.target).length > 0)) { + this.setState({ + show: false + }); + } + } + + handleInputChange(textbox, text) { + const caret = Utils.getCaretPosition(textbox); + const preText = text.substring(0, caret); + + let mode = ''; + let filter = ''; + for (const pattern in patterns) { + const result = patterns[pattern].exec(preText); + + if (result) { + mode = pattern; + filter = result[1]; + break; + } + } + + this.setState({ + mode, + filter, + show: mode || filter + }); + } + + render() { + if (!this.state.show) { + return null; + } + + let suggestions = []; + + if (this.state.mode === 'channels') { + let channels = ChannelStore.getAll(); + + if (this.state.filter) { + channels = channels.filter((channel) => channel.name.startsWith(this.state.filter)); + } + + suggestions = channels.map((channel) => { + return ( +
+ {channel.name} +
+ ); + }); + } else if (this.state.mode === 'users') { + let users = UserStore.getActiveOnlyProfileList(); + + if (this.state.filter) { + users = users.filter((user) => user.username.startsWith(this.state.filter)); + } + + suggestions = users.map((user) => { + return ( +
+ {user.username} +
+ ); + }); + } + + if (suggestions.length === 0) { + return null; + } + + return ( +
+ {suggestions} +
+ ); + } +} + +SearchAutocomplete.propTypes = { + completeWord: React.PropTypes.func.isRequired +}; diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 7540b41a4..509ca94e9 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -9,6 +9,7 @@ var utils = require('../utils/utils.jsx'); var Constants = require('../utils/constants.jsx'); var ActionTypes = Constants.ActionTypes; var Popover = ReactBootstrap.Popover; +var SearchAutocomplete = require('./search_autocomplete.jsx'); export default class SearchBar extends React.Component { constructor() { @@ -21,6 +22,7 @@ export default class SearchBar extends React.Component { this.handleUserBlur = this.handleUserBlur.bind(this); this.performSearch = this.performSearch.bind(this); this.handleSubmit = this.handleSubmit.bind(this); + this.completeWord = this.completeWord.bind(this); const state = this.getSearchTermStateFromStores(); state.focused = false; @@ -79,6 +81,8 @@ export default class SearchBar extends React.Component { PostStore.storeSearchTerm(term); PostStore.emitSearchTermChange(false); this.setState({searchTerm: term}); + + this.refs.autocomplete.handleInputChange(e.target, term); } handleMouseInput(e) { e.preventDefault(); @@ -120,6 +124,24 @@ export default class SearchBar extends React.Component { e.preventDefault(); this.performSearch(this.state.searchTerm.trim()); } + + completeWord(partialWord, word) { + const textbox = ReactDOM.findDOMNode(this.refs.search); + let text = textbox.value; + + const caret = utils.getCaretPosition(textbox); + const preText = text.substring(0, caret - partialWord.length); + const postText = text.substring(caret); + text = preText + word + postText; + + textbox.value = text; + utils.setCaretPosition(textbox, preText.length + word.length); + + PostStore.storeSearchTerm(text); + PostStore.emitSearchTermChange(false); + this.setState({searchTerm: text}); + } + render() { var isSearching = null; if (this.state.isSearching) { @@ -149,6 +171,7 @@ export default class SearchBar extends React.Component { role='form' className='search__form relative-div' onSubmit={this.handleSubmit} + style={{"overflow": "visible"}} > {isSearching} + Date: Tue, 20 Oct 2015 17:31:20 -0400 Subject: Added styling to search autocomplete --- web/react/components/search_autocomplete.jsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) (limited to 'web/react/components') diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx index 284b475c1..0229b07fd 100644 --- a/web/react/components/search_autocomplete.jsx +++ b/web/react/components/search_autocomplete.jsx @@ -90,11 +90,14 @@ export default class SearchAutocomplete extends React.Component { channels = channels.filter((channel) => channel.name.startsWith(this.state.filter)); } + channels.sort((a, b) => a.name.localeCompare(b.name)); + suggestions = channels.map((channel) => { return (
{channel.name}
@@ -107,12 +110,19 @@ export default class SearchAutocomplete extends React.Component { users = users.filter((user) => user.username.startsWith(this.state.filter)); } + users.sort((a, b) => a.username.localeCompare(b.username)); + suggestions = users.map((user) => { return (
+ {suggestions}
-- cgit v1.2.3-1-g7c22 From a5a2826700b1fc6b19ba38698cfa703f58476bc6 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Wed, 21 Oct 2015 11:03:50 -0400 Subject: Added keyboard selection to search autocomplete --- web/react/components/search_autocomplete.jsx | 176 +++++++++++++++++++++------ web/react/components/search_bar.jsx | 19 ++- 2 files changed, 151 insertions(+), 44 deletions(-) (limited to 'web/react/components') 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 (
{channel.name}
); }); } 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 (
{ 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'}
{isSearching} -- cgit v1.2.3-1-g7c22