-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Suggestions component and required utils
- Loading branch information
Showing
7 changed files
with
505 additions
and
0 deletions.
There are no files selected for viewing
364 changes: 364 additions & 0 deletions
364
src/components/Home/AutocompleteEditor/Suggestions/index.jsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,364 @@ | ||
import React, { Component } from 'react'; | ||
import PropTypes from 'prop-types'; | ||
import { genKey } from 'draft-js'; | ||
import { List, fromJS } from 'immutable'; | ||
import decodeOffsetKey from 'draft-js-mention-plugin/utils/decodeOffsetKey'; | ||
import Entry from './Entry'; | ||
import getSearchText from '../utils/getSearchText'; | ||
import defaultEntryComponent from './Entry/defaultEntryComponent'; | ||
|
||
const suggestionsHoc = Comp => (props) => { | ||
if (List.isList(props.suggestions)) { | ||
console.warn('Immutable.List for the "suggestions" prop will be deprecated in the next major version, please use an array instead'); // eslint-disable-line no-console | ||
} | ||
|
||
return (<Comp | ||
{...props} | ||
suggestions={fromJS(props.suggestions)} | ||
/>); | ||
}; | ||
|
||
export class MentionSuggestions extends Component { | ||
static propTypes = { | ||
entityMutability: PropTypes.oneOf([ | ||
'SEGMENTED', | ||
'IMMUTABLE', | ||
'MUTABLE', | ||
]), | ||
entryComponent: PropTypes.func, | ||
onAddMention: PropTypes.func, | ||
suggestions: (props, propName, componentName) => { | ||
if (!List.isList(props[propName])) { | ||
return new Error( | ||
`Invalid prop \`${propName}\` supplied to \`${componentName}\`. should be an instance of immutable list.`, | ||
); | ||
} | ||
return undefined; | ||
}, | ||
}; | ||
|
||
state = { | ||
isActive: false, | ||
focusedOptionIndex: 0, | ||
}; | ||
|
||
componentWillMount() { | ||
this.key = genKey(); | ||
this.props.callbacks.onChange = this.onEditorStateChange; | ||
} | ||
|
||
componentWillReceiveProps(nextProps) { | ||
if (nextProps.suggestions.size === 0 && this.state.isActive) { | ||
this.closeDropdown(); | ||
} else if (nextProps.suggestions.size > 0 && !nextProps.suggestions.equals(this.props.suggestions) && !this.state.isActive) { | ||
this.openDropdown(); | ||
} | ||
} | ||
|
||
componentDidUpdate = (prevProps, prevState) => { | ||
if (this.popover) { | ||
// In case the list shrinks there should be still an option focused. | ||
// Note: this might run multiple times and deduct 1 until the condition is | ||
// not fullfilled anymore. | ||
const size = this.props.suggestions.size; | ||
if (size > 0 && this.state.focusedOptionIndex >= size) { | ||
this.setState({ | ||
focusedOptionIndex: size - 1, | ||
}); | ||
} | ||
|
||
// Note: this is a simple protection for the error when componentDidUpdate | ||
// try to get new getPortalClientRect, but the key already was deleted by | ||
// previous action. (right now, it only can happened when set the mention | ||
// trigger to be multi-characters which not supported anyway!) | ||
if (!this.props.store.getAllSearches().has(this.activeOffsetKey)) { | ||
return; | ||
} | ||
|
||
const decoratorRect = this.props.store.getPortalClientRect(this.activeOffsetKey); | ||
const newStyles = this.props.positionSuggestions({ | ||
decoratorRect, | ||
prevProps, | ||
prevState, | ||
props: this.props, | ||
state: this.state, | ||
popover: this.popover, | ||
}); | ||
Object.keys(newStyles).forEach((key) => { | ||
this.popover.style[key] = newStyles[key]; | ||
}); | ||
} | ||
}; | ||
|
||
componentWillUnmount = () => { | ||
this.props.callbacks.onChange = undefined; | ||
}; | ||
|
||
onEditorStateChange = (editorState) => { | ||
const searches = this.props.store.getAllSearches(); | ||
|
||
// if no search portal is active there is no need to show the popover | ||
if (searches.size === 0) { | ||
return editorState; | ||
} | ||
|
||
const removeList = () => { | ||
this.props.store.resetEscapedSearch(); | ||
this.closeDropdown(); | ||
return editorState; | ||
}; | ||
|
||
// get the current selection | ||
const selection = editorState.getSelection(); | ||
const anchorKey = selection.getAnchorKey(); | ||
const anchorOffset = selection.getAnchorOffset(); | ||
|
||
// the list should not be visible if a range is selected or the editor has no focus | ||
if (!selection.isCollapsed() || !selection.getHasFocus()) return removeList(); | ||
|
||
// identify the start & end positon of each search-text | ||
const offsetDetails = searches.map(offsetKey => decodeOffsetKey(offsetKey)); | ||
|
||
// a leave can be empty when it is removed due e.g. using backspace | ||
const leaves = offsetDetails | ||
.filter(({ blockKey }) => blockKey === anchorKey) | ||
.map(({ blockKey, decoratorKey, leafKey }) => ( | ||
editorState | ||
.getBlockTree(blockKey) | ||
.getIn([decoratorKey, 'leaves', leafKey]) | ||
)); | ||
|
||
// if all leaves are undefined the popover should be removed | ||
if (leaves.every(leave => leave === undefined)) { | ||
return removeList(); | ||
} | ||
|
||
// Checks that the cursor is after the @ character but still somewhere in | ||
// the word (search term). Setting it to allow the cursor to be left of | ||
// the @ causes troubles due selection confusion. | ||
const plainText = editorState.getCurrentContent().getPlainText(); | ||
const selectionIsInsideWord = leaves | ||
.filter(leave => leave !== undefined) | ||
.map(({ start, end }) => ( | ||
(start === 0 && anchorOffset === this.props.mentionTrigger.length && plainText.charAt(anchorOffset) !== this.props.mentionTrigger && new RegExp(this.props.mentionTrigger, 'g').test(plainText) && anchorOffset <= end) || // @ is the first character | ||
(anchorOffset > start + this.props.mentionTrigger.length && anchorOffset <= end) // @ is in the text or at the end | ||
)); | ||
|
||
if (selectionIsInsideWord.every(isInside => isInside === false)) return removeList(); | ||
|
||
const lastActiveOffsetKey = this.activeOffsetKey; | ||
this.activeOffsetKey = selectionIsInsideWord | ||
.filter(value => value === true) | ||
.keySeq() | ||
.first(); | ||
|
||
this.onSearchChange(editorState, selection, this.activeOffsetKey, lastActiveOffsetKey); | ||
|
||
// make sure the escaped search is reseted in the cursor since the user | ||
// already switched to another mention search | ||
if (!this.props.store.isEscaped(this.activeOffsetKey)) { | ||
this.props.store.resetEscapedSearch(); | ||
} | ||
|
||
// If none of the above triggered to close the window, it's safe to assume | ||
// the dropdown should be open. This is useful when a user focuses on another | ||
// input field and then comes back: the dropdown will show again. | ||
if (!this.state.isActive && !this.props.store.isEscaped(this.activeOffsetKey)) { | ||
this.openDropdown(); | ||
} | ||
|
||
// makes sure the focused index is reseted every time a new selection opens | ||
// or the selection was moved to another mention search | ||
if (this.lastSelectionIsInsideWord === undefined || | ||
!selectionIsInsideWord.equals(this.lastSelectionIsInsideWord)) { | ||
this.setState({ | ||
focusedOptionIndex: 0, | ||
}); | ||
} | ||
|
||
this.lastSelectionIsInsideWord = selectionIsInsideWord; | ||
|
||
return editorState; | ||
}; | ||
|
||
onSearchChange = (editorState, selection, activeOffsetKey, lastActiveOffsetKey) => { | ||
const { word } = getSearchText(editorState, selection, this.props.mentionTrigger); | ||
const searchValue = word.substring(this.props.mentionTrigger.length, word.length); | ||
|
||
if (this.lastSearchValue !== searchValue || activeOffsetKey !== lastActiveOffsetKey) { | ||
this.lastSearchValue = searchValue; | ||
this.props.onSearchChange({ value: searchValue }); | ||
} | ||
}; | ||
|
||
onDownArrow = (keyboardEvent) => { | ||
keyboardEvent.preventDefault(); | ||
const newIndex = this.state.focusedOptionIndex + 1; | ||
this.onMentionFocus(newIndex >= this.props.suggestions.size ? 0 : newIndex); | ||
}; | ||
|
||
onTab = (keyboardEvent) => { | ||
keyboardEvent.preventDefault(); | ||
this.commitSelection(); | ||
}; | ||
|
||
onUpArrow = (keyboardEvent) => { | ||
keyboardEvent.preventDefault(); | ||
if (this.props.suggestions.size > 0) { | ||
const newIndex = this.state.focusedOptionIndex - 1; | ||
this.onMentionFocus(newIndex < 0 ? this.props.suggestions.size - 1 : newIndex); | ||
} | ||
}; | ||
|
||
onEscape = (keyboardEvent) => { | ||
keyboardEvent.preventDefault(); | ||
|
||
const activeOffsetKey = this.lastSelectionIsInsideWord | ||
.filter(value => value === true) | ||
.keySeq() | ||
.first(); | ||
this.props.store.escapeSearch(activeOffsetKey); | ||
this.closeDropdown(); | ||
|
||
// to force a re-render of the outer component to change the aria props | ||
this.props.store.setEditorState(this.props.store.getEditorState()); | ||
}; | ||
|
||
onMentionSelect = (mention) => { | ||
// Note: This can happen in case a user typed @xxx (invalid mention) and | ||
// then hit Enter. Then the mention will be undefined. | ||
if (!mention) { | ||
return; | ||
} | ||
|
||
if (this.props.onAddMention) { | ||
this.props.onAddMention(mention); | ||
} | ||
|
||
this.closeDropdown(); | ||
const newEditorState = addMention( | ||
this.props.store.getEditorState(), | ||
mention, | ||
this.props.mentionPrefix, | ||
this.props.mentionTrigger, | ||
this.props.entityMutability, | ||
); | ||
this.props.store.setEditorState(newEditorState); | ||
}; | ||
|
||
onMentionFocus = (index) => { | ||
const descendant = `mention-option-${this.key}-${index}`; | ||
this.props.ariaProps.ariaActiveDescendantID = descendant; | ||
this.setState({ | ||
focusedOptionIndex: index, | ||
}); | ||
|
||
// to force a re-render of the outer component to change the aria props | ||
this.props.store.setEditorState(this.props.store.getEditorState()); | ||
}; | ||
|
||
commitSelection = () => { | ||
if (!this.props.store.getIsOpened()) { | ||
return 'not-handled'; | ||
} | ||
|
||
this.onMentionSelect(this.props.suggestions.get(this.state.focusedOptionIndex)); | ||
return 'handled'; | ||
}; | ||
|
||
openDropdown = () => { | ||
// This is a really nasty way of attaching & releasing the key related functions. | ||
// It assumes that the keyFunctions object will not loose its reference and | ||
// by this we can replace inner parameters spread over different modules. | ||
// This better be some registering & unregistering logic. PRs are welcome :) | ||
this.props.callbacks.onDownArrow = this.onDownArrow; | ||
this.props.callbacks.onUpArrow = this.onUpArrow; | ||
this.props.callbacks.onEscape = this.onEscape; | ||
this.props.callbacks.handleReturn = this.commitSelection; | ||
this.props.callbacks.onTab = this.onTab; | ||
|
||
const descendant = `mention-option-${this.key}-${this.state.focusedOptionIndex}`; | ||
this.props.ariaProps.ariaActiveDescendantID = descendant; | ||
this.props.ariaProps.ariaOwneeID = `mentions-list-${this.key}`; | ||
this.props.ariaProps.ariaHasPopup = 'true'; | ||
this.props.ariaProps.ariaExpanded = 'true'; | ||
this.setState({ | ||
isActive: true, | ||
}); | ||
|
||
if (this.props.onOpen) { | ||
this.props.onOpen(); | ||
} | ||
}; | ||
|
||
closeDropdown = () => { | ||
// make sure none of these callbacks are triggered | ||
this.props.callbacks.onDownArrow = undefined; | ||
this.props.callbacks.onUpArrow = undefined; | ||
this.props.callbacks.onTab = undefined; | ||
this.props.callbacks.onEscape = undefined; | ||
this.props.callbacks.handleReturn = undefined; | ||
this.props.ariaProps.ariaHasPopup = 'false'; | ||
this.props.ariaProps.ariaExpanded = 'false'; | ||
this.props.ariaProps.ariaActiveDescendantID = undefined; | ||
this.props.ariaProps.ariaOwneeID = undefined; | ||
this.setState({ | ||
isActive: false, | ||
}); | ||
|
||
if (this.props.onClose) { | ||
this.props.onClose(); | ||
} | ||
}; | ||
|
||
render() { | ||
if (!this.state.isActive) { | ||
return null; | ||
} | ||
|
||
const { | ||
entryComponent, | ||
popoverComponent = <div />, | ||
onClose, // eslint-disable-line no-unused-vars | ||
onOpen, // eslint-disable-line no-unused-vars | ||
onAddMention, // eslint-disable-line no-unused-vars, no-shadow | ||
onSearchChange, // eslint-disable-line no-unused-vars, no-shadow | ||
suggestions, // eslint-disable-line no-unused-vars | ||
ariaProps, // eslint-disable-line no-unused-vars | ||
callbacks, // eslint-disable-line no-unused-vars | ||
theme = {}, | ||
store, // eslint-disable-line no-unused-vars | ||
entityMutability, // eslint-disable-line no-unused-vars | ||
positionSuggestions, // eslint-disable-line no-unused-vars | ||
mentionTrigger, // eslint-disable-line no-unused-vars | ||
mentionPrefix, // eslint-disable-line no-unused-vars | ||
...elementProps } = this.props; | ||
|
||
return React.cloneElement( | ||
popoverComponent, | ||
{ | ||
...elementProps, | ||
className: theme.mentionSuggestions, | ||
role: 'listbox', | ||
id: `mentions-list-${this.key}`, | ||
ref: (element) => { this.popover = element; }, | ||
}, | ||
this.props.suggestions.map((mention, index) => ( | ||
<Entry | ||
key={mention.has('id') ? mention.get('id') : mention.get('name')} | ||
onMentionSelect={this.onMentionSelect} | ||
onMentionFocus={this.onMentionFocus} | ||
isFocused={this.state.focusedOptionIndex === index} | ||
mention={mention} | ||
index={index} | ||
id={`mention-option-${this.key}-${index}`} | ||
theme={theme} | ||
searchValue={this.lastSearchValue} | ||
entryComponent={entryComponent || defaultEntryComponent} | ||
/> | ||
)).toJS(), | ||
); | ||
} | ||
} | ||
|
||
export default suggestionsHoc(MentionSuggestions); |
10 changes: 10 additions & 0 deletions
10
src/components/Home/AutocompleteEditor/Suggestions/utils/decodeOffsetKey.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
const decodeOffsetKey = (offsetKey) => { | ||
const [blockKey, decoratorKey, leafKey] = offsetKey.split('-'); | ||
return { | ||
blockKey, | ||
decoratorKey: parseInt(decoratorKey, 10), | ||
leafKey: parseInt(leafKey, 10), | ||
}; | ||
}; | ||
|
||
export default decodeOffsetKey; |
19 changes: 19 additions & 0 deletions
19
src/components/Home/AutocompleteEditor/Suggestions/utils/defaultSuggestionsFilter.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
// Get the first 5 suggestions that match | ||
const size = list => (list.constructor.name === 'List' | ||
? list.size | ||
: list.length); | ||
|
||
const get = (obj, attr) => (obj.get | ||
? obj.get(attr) | ||
: obj[attr]); | ||
|
||
const defaultSuggestionsFilter = (searchValue, suggestions) => { | ||
const value = searchValue.toLowerCase(); | ||
const filteredSuggestions = suggestions.filter(suggestion => ( | ||
!value || get(suggestion, 'name').toLowerCase().indexOf(value) > -1 | ||
)); | ||
const length = size(filteredSuggestions) < 5 ? size(filteredSuggestions) : 5; | ||
return filteredSuggestions.slice(0, length); | ||
}; | ||
|
||
export default defaultSuggestionsFilter; |
Oops, something went wrong.