2018-07-26 07:04:12 -05:00
|
|
|
import _ from 'lodash';
|
2018-04-26 04:58:42 -05:00
|
|
|
import React from 'react';
|
|
|
|
import ReactDOM from 'react-dom';
|
2018-07-26 07:04:12 -05:00
|
|
|
import { Block, Change, Document, Text, Value } from 'slate';
|
2018-04-26 04:58:42 -05:00
|
|
|
import { Editor } from 'slate-react';
|
|
|
|
import Plain from 'slate-plain-serializer';
|
|
|
|
|
|
|
|
import ClearPlugin from './slate-plugins/clear';
|
|
|
|
import NewlinePlugin from './slate-plugins/newline';
|
|
|
|
|
|
|
|
import Typeahead from './Typeahead';
|
|
|
|
|
2018-06-11 10:36:45 -05:00
|
|
|
export const TYPEAHEAD_DEBOUNCE = 300;
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
function flattenSuggestions(s: any[]): any[] {
|
2018-04-26 04:58:42 -05:00
|
|
|
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
|
|
|
|
}
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
export const makeFragment = (text: string): Document => {
|
|
|
|
const lines = text.split('\n').map(line =>
|
|
|
|
Block.create({
|
|
|
|
type: 'paragraph',
|
|
|
|
nodes: [Text.create(line)],
|
|
|
|
})
|
|
|
|
);
|
|
|
|
|
|
|
|
const fragment = Document.create({
|
|
|
|
nodes: lines,
|
2018-04-26 04:58:42 -05:00
|
|
|
});
|
2018-07-26 07:04:12 -05:00
|
|
|
return fragment;
|
|
|
|
};
|
|
|
|
|
|
|
|
export const getInitialValue = (value: string): Value => Value.create({ document: makeFragment(value) });
|
|
|
|
|
|
|
|
export interface Suggestion {
|
|
|
|
/**
|
|
|
|
* The label of this completion item. By default
|
|
|
|
* this is also the text that is inserted when selecting
|
|
|
|
* this completion.
|
|
|
|
*/
|
|
|
|
label: string;
|
|
|
|
/**
|
|
|
|
* The kind of this completion item. Based on the kind
|
|
|
|
* an icon is chosen by the editor.
|
|
|
|
*/
|
|
|
|
kind?: string;
|
|
|
|
/**
|
|
|
|
* A human-readable string with additional information
|
|
|
|
* about this item, like type or symbol information.
|
|
|
|
*/
|
|
|
|
detail?: string;
|
|
|
|
/**
|
|
|
|
* A human-readable string, can be Markdown, that represents a doc-comment.
|
|
|
|
*/
|
|
|
|
documentation?: string;
|
|
|
|
/**
|
|
|
|
* A string that should be used when comparing this item
|
|
|
|
* with other items. When `falsy` the `label` is used.
|
|
|
|
*/
|
|
|
|
sortText?: string;
|
|
|
|
/**
|
|
|
|
* A string that should be used when filtering a set of
|
|
|
|
* completion items. When `falsy` the `label` is used.
|
|
|
|
*/
|
|
|
|
filterText?: string;
|
|
|
|
/**
|
|
|
|
* A string or snippet that should be inserted in a document when selecting
|
|
|
|
* this completion. When `falsy` the `label` is used.
|
|
|
|
*/
|
|
|
|
insertText?: string;
|
|
|
|
/**
|
|
|
|
* Delete number of characters before the caret position,
|
|
|
|
* by default the letters from the beginning of the word.
|
|
|
|
*/
|
|
|
|
deleteBackwards?: number;
|
|
|
|
/**
|
|
|
|
* Number of steps to move after the insertion, can be negative.
|
|
|
|
*/
|
|
|
|
move?: number;
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
export interface SuggestionGroup {
|
|
|
|
/**
|
|
|
|
* Label that will be displayed for all entries of this group.
|
|
|
|
*/
|
|
|
|
label: string;
|
|
|
|
/**
|
|
|
|
* List of suggestions of this group.
|
|
|
|
*/
|
|
|
|
items: Suggestion[];
|
|
|
|
/**
|
|
|
|
* If true, match only by prefix (and not mid-word).
|
|
|
|
*/
|
|
|
|
prefixMatch?: boolean;
|
|
|
|
/**
|
|
|
|
* If true, do not filter items in this group based on the search.
|
|
|
|
*/
|
|
|
|
skipFilter?: boolean;
|
2018-08-02 09:43:33 -05:00
|
|
|
/**
|
|
|
|
* If true, do not sort items.
|
|
|
|
*/
|
|
|
|
skipSort?: boolean;
|
2018-07-26 07:04:12 -05:00
|
|
|
}
|
2018-06-11 10:36:45 -05:00
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
interface TypeaheadFieldProps {
|
|
|
|
additionalPlugins?: any[];
|
|
|
|
cleanText?: (text: string) => string;
|
|
|
|
initialValue: string | null;
|
|
|
|
onBlur?: () => void;
|
|
|
|
onFocus?: () => void;
|
|
|
|
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
|
|
|
|
onValueChanged?: (value: Value) => void;
|
|
|
|
onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
|
|
|
|
placeholder?: string;
|
|
|
|
portalPrefix?: string;
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
export interface TypeaheadFieldState {
|
|
|
|
suggestions: SuggestionGroup[];
|
|
|
|
typeaheadContext: string | null;
|
|
|
|
typeaheadIndex: number;
|
|
|
|
typeaheadPrefix: string;
|
|
|
|
typeaheadText: string;
|
|
|
|
value: Value;
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
export interface TypeaheadInput {
|
|
|
|
editorNode: Element;
|
|
|
|
prefix: string;
|
|
|
|
selection?: Selection;
|
|
|
|
text: string;
|
2018-08-06 07:36:02 -05:00
|
|
|
value: Value;
|
2018-07-26 07:04:12 -05:00
|
|
|
wrapperNode: Element;
|
|
|
|
}
|
|
|
|
|
|
|
|
export interface TypeaheadOutput {
|
|
|
|
context?: string;
|
|
|
|
refresher?: Promise<{}>;
|
|
|
|
suggestions: SuggestionGroup[];
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
|
|
|
|
menuEl: HTMLElement | null;
|
|
|
|
plugins: any[];
|
2018-04-26 04:58:42 -05:00
|
|
|
resetTimer: any;
|
|
|
|
|
|
|
|
constructor(props, context) {
|
|
|
|
super(props, context);
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
// Base plugins
|
2018-08-07 05:34:12 -05:00
|
|
|
this.plugins = [ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
|
2018-04-26 04:58:42 -05:00
|
|
|
|
|
|
|
this.state = {
|
|
|
|
suggestions: [],
|
2018-07-26 07:04:12 -05:00
|
|
|
typeaheadContext: null,
|
2018-04-26 04:58:42 -05:00
|
|
|
typeaheadIndex: 0,
|
|
|
|
typeaheadPrefix: '',
|
2018-07-26 07:04:12 -05:00
|
|
|
typeaheadText: '',
|
|
|
|
value: getInitialValue(props.initialValue || ''),
|
2018-04-26 04:58:42 -05:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
this.updateMenu();
|
|
|
|
}
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
clearTimeout(this.resetTimer);
|
|
|
|
}
|
|
|
|
|
|
|
|
componentDidUpdate() {
|
|
|
|
this.updateMenu();
|
|
|
|
}
|
|
|
|
|
|
|
|
componentWillReceiveProps(nextProps) {
|
2018-07-26 07:04:12 -05:00
|
|
|
// initialValue is null in case the user typed
|
|
|
|
if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
|
|
|
|
this.setState({ value: getInitialValue(nextProps.initialValue) });
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
onChange = ({ value }) => {
|
|
|
|
const changed = value.document !== this.state.value.document;
|
|
|
|
this.setState({ value }, () => {
|
|
|
|
if (changed) {
|
2018-07-26 07:04:12 -05:00
|
|
|
this.handleChangeValue();
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
});
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
if (changed) {
|
|
|
|
window.requestAnimationFrame(this.handleTypeahead);
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
handleChangeValue = () => {
|
2018-04-26 04:58:42 -05:00
|
|
|
// Send text change to parent
|
2018-07-26 07:04:12 -05:00
|
|
|
const { onValueChanged } = this.props;
|
|
|
|
if (onValueChanged) {
|
|
|
|
onValueChanged(Plain.serialize(this.state.value));
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
handleTypeahead = _.debounce(async () => {
|
2018-04-26 04:58:42 -05:00
|
|
|
const selection = window.getSelection();
|
2018-07-26 07:04:12 -05:00
|
|
|
const { cleanText, onTypeahead } = this.props;
|
2018-08-06 07:36:02 -05:00
|
|
|
const { value } = this.state;
|
2018-07-26 07:04:12 -05:00
|
|
|
|
|
|
|
if (onTypeahead && selection.anchorNode) {
|
2018-04-26 04:58:42 -05:00
|
|
|
const wrapperNode = selection.anchorNode.parentElement;
|
2018-06-11 10:36:45 -05:00
|
|
|
const editorNode = wrapperNode.closest('.slate-query-field');
|
2018-04-26 04:58:42 -05:00
|
|
|
if (!editorNode || this.state.value.isBlurred) {
|
|
|
|
// Not inside this editor
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const range = selection.getRangeAt(0);
|
|
|
|
const offset = range.startOffset;
|
2018-07-26 07:04:12 -05:00
|
|
|
const text = selection.anchorNode.textContent;
|
|
|
|
let prefix = text.substr(0, offset);
|
|
|
|
if (cleanText) {
|
|
|
|
prefix = cleanText(prefix);
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
const { suggestions, context, refresher } = onTypeahead({
|
|
|
|
editorNode,
|
|
|
|
prefix,
|
|
|
|
selection,
|
|
|
|
text,
|
2018-08-06 07:36:02 -05:00
|
|
|
value,
|
2018-07-26 07:04:12 -05:00
|
|
|
wrapperNode,
|
2018-04-26 04:58:42 -05:00
|
|
|
});
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
const filteredSuggestions = suggestions
|
|
|
|
.map(group => {
|
|
|
|
if (group.items) {
|
|
|
|
if (prefix) {
|
|
|
|
// Filter groups based on prefix
|
|
|
|
if (!group.skipFilter) {
|
|
|
|
group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
|
|
|
|
if (group.prefixMatch) {
|
|
|
|
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
|
|
|
|
} else {
|
|
|
|
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// Filter out the already typed value (prefix) unless it inserts custom text
|
|
|
|
group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-08-02 09:43:33 -05:00
|
|
|
if (!group.skipSort) {
|
|
|
|
group.items = _.sortBy(group.items, item => item.sortText || item.label);
|
|
|
|
}
|
2018-07-26 07:04:12 -05:00
|
|
|
}
|
|
|
|
return group;
|
|
|
|
})
|
|
|
|
.filter(group => group.items && group.items.length > 0); // Filter out empty groups
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
this.setState(
|
|
|
|
{
|
|
|
|
suggestions: filteredSuggestions,
|
|
|
|
typeaheadPrefix: prefix,
|
|
|
|
typeaheadContext: context,
|
|
|
|
typeaheadText: text,
|
|
|
|
},
|
|
|
|
() => {
|
|
|
|
if (refresher) {
|
|
|
|
refresher.then(this.handleTypeahead).catch(e => console.error(e));
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
2018-07-26 07:04:12 -05:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}, TYPEAHEAD_DEBOUNCE);
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
applyTypeahead(change: Change, suggestion: Suggestion): Change {
|
|
|
|
const { cleanText, onWillApplySuggestion } = this.props;
|
|
|
|
const { typeaheadPrefix, typeaheadText } = this.state;
|
|
|
|
let suggestionText = suggestion.insertText || suggestion.label;
|
|
|
|
const move = suggestion.move || 0;
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
if (onWillApplySuggestion) {
|
|
|
|
suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
this.resetTypeahead();
|
|
|
|
|
|
|
|
// Remove the current, incomplete text and replace it with the selected suggestion
|
2018-07-26 07:04:12 -05:00
|
|
|
const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
|
|
|
|
const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
|
2018-04-26 04:58:42 -05:00
|
|
|
const suffixLength = text.length - typeaheadPrefix.length;
|
|
|
|
const offset = typeaheadText.indexOf(typeaheadPrefix);
|
2018-07-26 07:04:12 -05:00
|
|
|
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
|
2018-04-26 04:58:42 -05:00
|
|
|
const forward = midWord ? suffixLength + offset : 0;
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
// If new-lines, apply suggestion as block
|
|
|
|
if (suggestionText.match(/\n/)) {
|
|
|
|
const fragment = makeFragment(suggestionText);
|
|
|
|
return change
|
2018-04-26 04:58:42 -05:00
|
|
|
.deleteBackward(backward)
|
|
|
|
.deleteForward(forward)
|
2018-07-26 07:04:12 -05:00
|
|
|
.insertFragment(fragment)
|
|
|
|
.focus();
|
|
|
|
}
|
|
|
|
|
|
|
|
return change
|
|
|
|
.deleteBackward(backward)
|
|
|
|
.deleteForward(forward)
|
|
|
|
.insertText(suggestionText)
|
|
|
|
.move(move)
|
|
|
|
.focus();
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
onKeyDown = (event, change) => {
|
2018-06-12 05:31:21 -05:00
|
|
|
const { typeaheadIndex, suggestions } = this.state;
|
|
|
|
|
|
|
|
switch (event.key) {
|
|
|
|
case 'Escape': {
|
|
|
|
if (this.menuEl) {
|
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
this.resetTypeahead();
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
case ' ': {
|
|
|
|
if (event.ctrlKey) {
|
|
|
|
event.preventDefault();
|
|
|
|
this.handleTypeahead();
|
|
|
|
return true;
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
2018-06-12 05:31:21 -05:00
|
|
|
break;
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-06-12 05:31:21 -05:00
|
|
|
case 'Tab': {
|
|
|
|
if (this.menuEl) {
|
2018-04-26 04:58:42 -05:00
|
|
|
// Dont blur input
|
|
|
|
event.preventDefault();
|
|
|
|
if (!suggestions || suggestions.length === 0) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the currently selected suggestion
|
|
|
|
const flattenedSuggestions = flattenSuggestions(suggestions);
|
|
|
|
const selected = Math.abs(typeaheadIndex);
|
|
|
|
const selectedIndex = selected % flattenedSuggestions.length || 0;
|
|
|
|
const suggestion = flattenedSuggestions[selectedIndex];
|
|
|
|
|
|
|
|
this.applyTypeahead(change, suggestion);
|
|
|
|
return true;
|
|
|
|
}
|
2018-06-12 05:31:21 -05:00
|
|
|
break;
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-06-12 05:31:21 -05:00
|
|
|
case 'ArrowDown': {
|
|
|
|
if (this.menuEl) {
|
2018-04-26 04:58:42 -05:00
|
|
|
// Select next suggestion
|
|
|
|
event.preventDefault();
|
|
|
|
this.setState({ typeaheadIndex: typeaheadIndex + 1 });
|
|
|
|
}
|
2018-06-12 05:31:21 -05:00
|
|
|
break;
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-06-12 05:31:21 -05:00
|
|
|
case 'ArrowUp': {
|
|
|
|
if (this.menuEl) {
|
2018-04-26 04:58:42 -05:00
|
|
|
// Select previous suggestion
|
|
|
|
event.preventDefault();
|
|
|
|
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
|
|
|
|
}
|
2018-06-12 05:31:21 -05:00
|
|
|
break;
|
|
|
|
}
|
2018-04-26 04:58:42 -05:00
|
|
|
|
2018-06-12 05:31:21 -05:00
|
|
|
default: {
|
|
|
|
// console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
|
|
|
|
break;
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return undefined;
|
|
|
|
};
|
|
|
|
|
|
|
|
resetTypeahead = () => {
|
|
|
|
this.setState({
|
|
|
|
suggestions: [],
|
|
|
|
typeaheadIndex: 0,
|
|
|
|
typeaheadPrefix: '',
|
|
|
|
typeaheadContext: null,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
handleBlur = () => {
|
|
|
|
const { onBlur } = this.props;
|
|
|
|
// If we dont wait here, menu clicks wont work because the menu
|
|
|
|
// will be gone.
|
|
|
|
this.resetTimer = setTimeout(this.resetTypeahead, 100);
|
|
|
|
if (onBlur) {
|
|
|
|
onBlur();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
handleFocus = () => {
|
|
|
|
const { onFocus } = this.props;
|
|
|
|
if (onFocus) {
|
|
|
|
onFocus();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
onClickMenu = (item: Suggestion) => {
|
2018-04-26 04:58:42 -05:00
|
|
|
// Manually triggering change
|
|
|
|
const change = this.applyTypeahead(this.state.value.change(), item);
|
|
|
|
this.onChange(change);
|
|
|
|
};
|
|
|
|
|
|
|
|
updateMenu = () => {
|
|
|
|
const { suggestions } = this.state;
|
|
|
|
const menu = this.menuEl;
|
|
|
|
const selection = window.getSelection();
|
|
|
|
const node = selection.anchorNode;
|
|
|
|
|
|
|
|
// No menu, nothing to do
|
|
|
|
if (!menu) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// No suggestions or blur, remove menu
|
|
|
|
const hasSuggesstions = suggestions && suggestions.length > 0;
|
|
|
|
if (!hasSuggesstions) {
|
|
|
|
menu.removeAttribute('style');
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Align menu overlay to editor node
|
|
|
|
if (node) {
|
2018-06-12 12:02:02 -05:00
|
|
|
// Read from DOM
|
2018-04-26 04:58:42 -05:00
|
|
|
const rect = node.parentElement.getBoundingClientRect();
|
2018-06-12 12:02:02 -05:00
|
|
|
const scrollX = window.scrollX;
|
|
|
|
const scrollY = window.scrollY;
|
|
|
|
|
|
|
|
// Write DOM
|
|
|
|
requestAnimationFrame(() => {
|
2018-07-26 07:04:12 -05:00
|
|
|
menu.style.opacity = '1';
|
2018-06-12 12:02:02 -05:00
|
|
|
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
|
|
|
|
menu.style.left = `${rect.left + scrollX - 2}px`;
|
|
|
|
});
|
2018-04-26 04:58:42 -05:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
menuRef = el => {
|
|
|
|
this.menuEl = el;
|
|
|
|
};
|
|
|
|
|
|
|
|
renderMenu = () => {
|
2018-06-11 10:36:45 -05:00
|
|
|
const { portalPrefix } = this.props;
|
2018-04-26 04:58:42 -05:00
|
|
|
const { suggestions } = this.state;
|
|
|
|
const hasSuggesstions = suggestions && suggestions.length > 0;
|
|
|
|
if (!hasSuggesstions) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Guard selectedIndex to be within the length of the suggestions
|
|
|
|
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
|
|
|
|
const flattenedSuggestions = flattenSuggestions(suggestions);
|
|
|
|
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
|
2018-07-26 07:04:12 -05:00
|
|
|
const selectedItem: Suggestion | null =
|
|
|
|
flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
|
2018-04-26 04:58:42 -05:00
|
|
|
|
|
|
|
// Create typeahead in DOM root so we can later position it absolutely
|
|
|
|
return (
|
2018-06-11 10:36:45 -05:00
|
|
|
<Portal prefix={portalPrefix}>
|
2018-04-26 04:58:42 -05:00
|
|
|
<Typeahead
|
|
|
|
menuRef={this.menuRef}
|
2018-07-26 07:04:12 -05:00
|
|
|
selectedItem={selectedItem}
|
|
|
|
onClickItem={this.onClickMenu}
|
2018-04-26 04:58:42 -05:00
|
|
|
groupedItems={suggestions}
|
|
|
|
/>
|
|
|
|
</Portal>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
render() {
|
|
|
|
return (
|
2018-06-11 10:36:45 -05:00
|
|
|
<div className="slate-query-field">
|
2018-04-26 04:58:42 -05:00
|
|
|
{this.renderMenu()}
|
|
|
|
<Editor
|
|
|
|
autoCorrect={false}
|
|
|
|
onBlur={this.handleBlur}
|
|
|
|
onKeyDown={this.onKeyDown}
|
|
|
|
onChange={this.onChange}
|
|
|
|
onFocus={this.handleFocus}
|
|
|
|
placeholder={this.props.placeholder}
|
|
|
|
plugins={this.plugins}
|
|
|
|
spellCheck={false}
|
|
|
|
value={this.state.value}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-07-26 07:04:12 -05:00
|
|
|
class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
|
|
|
|
node: HTMLElement;
|
|
|
|
|
|
|
|
constructor(props) {
|
|
|
|
super(props);
|
|
|
|
const { index = 0, prefix = 'query' } = props;
|
|
|
|
this.node = document.createElement('div');
|
|
|
|
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
|
|
|
|
document.body.appendChild(this.node);
|
|
|
|
}
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
document.body.removeChild(this.node);
|
|
|
|
}
|
|
|
|
|
|
|
|
render() {
|
|
|
|
return ReactDOM.createPortal(this.props.children, this.node);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-04-26 04:58:42 -05:00
|
|
|
export default QueryField;
|