Merge pull request #419 from aviraldg/feature-autocomplete-improvements

Update autocomplete design and scroll it correctly
pull/21833/head
Matthew Hodgson 2016-08-24 14:47:22 +01:00 committed by GitHub
commit 2f0599aae1
8 changed files with 150 additions and 58 deletions

View File

@ -1,4 +1,5 @@
import Q from 'q'; import Q from 'q';
import React from 'react';
export default class AutocompleteProvider { export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) { constructor(commandRegex?: RegExp, fuseOpts?: any) {
@ -51,4 +52,9 @@ export default class AutocompleteProvider {
getName(): string { getName(): string {
return 'Default Provider'; return 'Default Provider';
} }
renderCompletions(completions: [React.Component]): ?React.Component {
console.error('stub; should be implemented in subclasses');
return null;
}
} }

View File

@ -74,7 +74,7 @@ export default class CommandProvider extends AutocompleteProvider {
} }
getName() { getName() {
return 'Commands'; return '*️⃣ Commands';
} }
static getInstance(): CommandProvider { static getInstance(): CommandProvider {
@ -83,4 +83,10 @@ export default class CommandProvider extends AutocompleteProvider {
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block">
{completions}
</div>;
}
} }

View File

@ -1,19 +1,62 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
export function TextualCompletion({ /* These were earlier stateless functional components but had to be converted
title, since we need to use refs/findDOMNode to access the underlying DOM node to focus the correct completion,
subtitle, something that is not entirely possible with stateless functional components. One could
description, presumably wrap them in a <div> before rendering but I think this is the better way to do it.
}: { */
title: ?string,
subtitle: ?string, export class TextualCompletion extends React.Component {
description: ?string render() {
}) { const {
return ( title,
<div style={{width: '100%'}}> subtitle,
<span>{title}</span> description,
<em>{subtitle}</em> className,
<span style={{color: 'gray', float: 'right'}}>{description}</span> ...restProps,
</div> } = this.props;
); return (
<div className={classNames('mx_Autocomplete_Completion_block', className)} {...restProps}>
<span className="mx_Autocomplete_Completion_title">{title}</span>
<span className="mx_Autocomplete_Completion_subtitle">{subtitle}</span>
<span className="mx_Autocomplete_Completion_description">{description}</span>
</div>
);
}
} }
TextualCompletion.propTypes = {
title: React.PropTypes.string,
subtitle: React.PropTypes.string,
description: React.PropTypes.string,
className: React.PropTypes.string,
};
export class PillCompletion extends React.Component {
render() {
const {
title,
subtitle,
description,
initialComponent,
className,
...restProps,
} = this.props;
return (
<div className={classNames('mx_Autocomplete_Completion_pill', className)} {...restProps}>
{initialComponent}
<span className="mx_Autocomplete_Completion_title">{title}</span>
<span className="mx_Autocomplete_Completion_subtitle">{subtitle}</span>
<span className="mx_Autocomplete_Completion_description">{description}</span>
</div>
);
}
}
PillCompletion.propTypes = {
title: React.PropTypes.string,
subtitle: React.PropTypes.string,
description: React.PropTypes.string,
initialComponent: React.PropTypes.element,
className: React.PropTypes.string,
};

View File

@ -78,7 +78,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
} }
getName() { getName() {
return 'Results from DuckDuckGo'; return '🔍 Results from DuckDuckGo';
} }
static getInstance(): DuckDuckGoProvider { static getInstance(): DuckDuckGoProvider {
@ -87,4 +87,10 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
} }
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block">
{completions}
</div>;
}
} }

View File

@ -3,6 +3,8 @@ import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q'; import Q from 'q';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione'; import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import sdk from '../index';
import {PillCompletion} from './Components';
const EMOJI_REGEX = /:\w*:?/g; const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_SHORTNAMES = Object.keys(emojioneList);
@ -16,28 +18,28 @@ export default class EmojiProvider extends AutocompleteProvider {
} }
getCompletions(query: string, selection: {start: number, end: number}) { getCompletions(query: string, selection: {start: number, end: number}) {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection); let {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
completions = this.fuse.search(command[0]).map(result => { completions = this.fuse.search(command[0]).map(result => {
let shortname = EMOJI_SHORTNAMES[result]; const shortname = EMOJI_SHORTNAMES[result];
let imageHTML = shortnameToImage(shortname); const unicode = shortnameToUnicode(shortname);
return { return {
completion: shortnameToUnicode(shortname), completion: unicode,
component: ( component: (
<div className="mx_Autocomplete_Completion"> <PillCompletion title={shortname} initialComponent={<EmojiText style={{maxWidth: '1em'}}>{unicode}</EmojiText>} />
<span style={{maxWidth: '1em'}} dangerouslySetInnerHTML={{__html: imageHTML}}></span>&nbsp;&nbsp;{shortname}
</div>
), ),
range, range,
}; };
}).slice(0, 4); }).slice(0, 8);
} }
return Q.when(completions); return Q.when(completions);
} }
getName() { getName() {
return 'Emoji'; return '😃 Emoji';
} }
static getInstance() { static getInstance() {
@ -45,4 +47,10 @@ export default class EmojiProvider extends AutocompleteProvider {
instance = new EmojiProvider(); instance = new EmojiProvider();
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
</div>;
}
} }

View File

@ -3,8 +3,9 @@ import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q'; import Q from 'q';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import {TextualCompletion} from './Components'; import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../MatrixTools'; import {getDisplayAliasForRoom} from '../MatrixTools';
import sdk from '../index';
const ROOM_REGEX = /(?=#)([^\s]*)/g; const ROOM_REGEX = /(?=#)([^\s]*)/g;
@ -21,6 +22,8 @@ export default class RoomProvider extends AutocompleteProvider {
} }
getCompletions(query: string, selection: {start: number, end: number}) { getCompletions(query: string, selection: {start: number, end: number}) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get(); let client = MatrixClientPeg.get();
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
@ -39,7 +42,7 @@ export default class RoomProvider extends AutocompleteProvider {
return { return {
completion: displayAlias, completion: displayAlias,
component: ( component: (
<TextualCompletion title={room.name} description={displayAlias} /> <PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
), ),
range, range,
}; };
@ -49,7 +52,7 @@ export default class RoomProvider extends AutocompleteProvider {
} }
getName() { getName() {
return 'Rooms'; return '💬 Rooms';
} }
static getInstance() { static getInstance() {
@ -59,4 +62,10 @@ export default class RoomProvider extends AutocompleteProvider {
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
</div>;
}
} }

View File

@ -2,7 +2,8 @@ import React from 'react';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q'; import Q from 'q';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import {TextualCompletion} from './Components'; import {PillCompletion} from './Components';
import sdk from '../index';
const USER_REGEX = /@[^\s]*/g; const USER_REGEX = /@[^\s]*/g;
@ -20,6 +21,8 @@ export default class UserProvider extends AutocompleteProvider {
} }
getCompletions(query: string, selection: {start: number, end: number}) { getCompletions(query: string, selection: {start: number, end: number}) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
let completions = []; let completions = [];
let {command, range} = this.getCurrentCommand(query, selection); let {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
@ -29,7 +32,8 @@ export default class UserProvider extends AutocompleteProvider {
return { return {
completion: user.userId, completion: user.userId,
component: ( component: (
<TextualCompletion <PillCompletion
initialComponent={<MemberAvatar member={user} width={24} height={24}/>}
title={displayName} title={displayName}
description={user.userId} /> description={user.userId} />
), ),
@ -41,7 +45,7 @@ export default class UserProvider extends AutocompleteProvider {
} }
getName() { getName() {
return 'Users'; return '👥 Users';
} }
setUserList(users) { setUserList(users) {
@ -54,4 +58,10 @@ export default class UserProvider extends AutocompleteProvider {
} }
return instance; return instance;
} }
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill">
{completions}
</div>;
}
} }

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import flatMap from 'lodash/flatMap'; import flatMap from 'lodash/flatMap';
import sdk from '../../../index';
import {getCompletions} from '../../../autocomplete/Autocompleter'; import {getCompletions} from '../../../autocomplete/Autocompleter';
@ -100,11 +101,27 @@ export default class Autocomplete extends React.Component {
this.setState({selectionOffset}); this.setState({selectionOffset});
} }
componentDidUpdate() {
// this is the selected completion, so scroll it into view if needed
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
if (selectedCompletion && this.container) {
const domNode = ReactDOM.findDOMNode(selectedCompletion);
const offsetTop = domNode && domNode.offsetTop;
if (offsetTop > this.container.scrollTop + this.container.offsetHeight ||
offsetTop < this.container.scrollTop) {
this.container.scrollTop = offsetTop - this.container.offsetTop;
}
}
}
render() { render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 0; let position = 0;
let renderedCompletions = this.state.completions.map((completionResult, i) => { let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => { let completions = completionResult.completions.map((completion, i) => {
let className = classNames('mx_Autocomplete_Completion', {
const className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset, 'selected': position === this.state.selectionOffset,
}); });
let componentPosition = position; let componentPosition = position;
@ -116,40 +133,27 @@ export default class Autocomplete extends React.Component {
this.onConfirm(); this.onConfirm();
}; };
return ( return React.cloneElement(completion.component, {
<div key={i} key: i,
className={className} ref: `completion${i}`,
onMouseOver={onMouseOver} className,
onClick={onClick}> onMouseOver,
{completion.component} onClick,
</div> });
);
}); });
return completions.length > 0 ? ( return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection"> <div key={i} className="mx_Autocomplete_ProviderSection">
<span className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</span> <EmojiText element="div" className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</EmojiText>
<ReactCSSTransitionGroup {completionResult.provider.renderCompletions(completions)}
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{completions}
</ReactCSSTransitionGroup>
</div> </div>
) : null; ) : null;
}); });
return ( return (
<div className="mx_Autocomplete"> <div className="mx_Autocomplete" ref={(e) => this.container = e}>
<ReactCSSTransitionGroup {renderedCompletions}
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{renderedCompletions}
</ReactCSSTransitionGroup>
</div> </div>
); );
} }