| - |
### Methods
-| name | description | parameters | return |
-|----------|----------------|----------|--------------|
-| | | | |
+| name | description |
+|----------|----------------|
+| focus() | Component get focus |
+| blur() | Component lose focus |
## Development
diff --git a/assets/index.less b/assets/index.less
index e020cb6..7e08a36 100644
--- a/assets/index.less
+++ b/assets/index.less
@@ -1 +1,94 @@
@mentionsPrefixCls: rc-mentions;
+
+.@{mentionsPrefixCls} {
+ display: inline-block;
+ position: relative;
+ white-space: pre-wrap;
+
+ // ================= Input Area =================
+ > textarea, &-measure {
+ font-size: inherit;
+ font-size-adjust: inherit;
+ font-style: inherit;
+ font-variant: inherit;
+ font-stretch: inherit;
+ font-weight: inherit;
+ font-family: inherit;
+
+ padding: 0;
+ margin: 0;
+ line-height: inherit;
+ vertical-align: top;
+ overflow: inherit;
+ word-break: inherit;
+ white-space: inherit;
+ word-wrap: break-word;
+ overflow-x: initial;
+ overflow-y: auto;
+ text-align: inherit;
+ letter-spacing: inherit;
+ white-space: inherit;
+ tab-size: inherit;
+ direction: inherit;
+ }
+
+ > textarea {
+ border: none;
+ width: 100%;
+ }
+
+ &-measure {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ pointer-events: none;
+ // color: rgba(255, 0, 0, 0.3);
+ color: transparent;
+ z-index: -1;
+ }
+
+ // ================== Dropdown ==================
+ &-dropdown {
+ position: absolute;
+
+ &-menu {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ &-item {
+ cursor: pointer;
+ }
+ }
+ }
+}
+
+// Customize style
+.@{mentionsPrefixCls} {
+ font-size: 20px;
+ border: 1px solid #999;
+ border-radius: 3px;
+ overflow: hidden;
+
+ &-dropdown {
+ border: 1px solid #999;
+ border-radius: 3px;
+ background: #FFF;
+
+ &-menu {
+ &-item {
+ padding: 4px 8px;
+
+ &-active {
+ background: #e6f7ff;
+ }
+
+ &-disabled {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/basic.js b/examples/basic.js
index dbc1986..ea1ccb7 100644
--- a/examples/basic.js
+++ b/examples/basic.js
@@ -1,12 +1,40 @@
/* eslint no-console: 0 */
import React from 'react';
-// import Mentions from '../src';
+import Mentions from '../src';
import '../assets/index.less';
+const { Option } = Mentions;
+
class Demo extends React.Component {
+ onSelect = (option, prefix) => {
+ console.log('Select:', prefix, '-', option.value);
+ };
+
+ onFocus = () => {
+ console.log('onFocus');
+ };
+
+ onBlur = () => {
+ console.log('onBlur');
+ };
+
render() {
- return null;
+ return (
+
+
+
+
+
+
+
+ );
}
}
diff --git a/examples/dynamic.js b/examples/dynamic.js
new file mode 100644
index 0000000..d55a839
--- /dev/null
+++ b/examples/dynamic.js
@@ -0,0 +1,85 @@
+/* eslint-disable no-console, no-undef */
+
+import React from 'react';
+import debounce from 'lodash.debounce';
+import Mentions from '../src';
+import '../assets/index.less';
+import './dynamic.less';
+
+const { Option } = Mentions;
+
+class Demo extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.loadGithubUsers = debounce(this.loadGithubUsers, 800);
+ }
+
+ state = {
+ search: '',
+ loading: false,
+ users: [],
+ };
+
+ onSearch = search => {
+ this.setState({ search, loading: !!search, users: [] });
+ console.log('Search:', search);
+ this.loadGithubUsers(search);
+ };
+
+ loadGithubUsers(key) {
+ if (!key) {
+ this.setState({
+ users: [],
+ });
+ return;
+ }
+
+ fetch(`https://api.github.com/search/users?q=${key}`)
+ .then(res => res.json())
+ .then(({ items = [] }) => {
+ const { search } = this.state;
+ if (search !== key) {
+ console.log('Out Of Date >', key, items);
+ return;
+ }
+
+ console.log('Fetch Users >', items);
+ this.setState({
+ users: items.slice(0, 10),
+ loading: false,
+ });
+ });
+ }
+
+ render() {
+ const { users, loading, search } = this.state;
+
+ let options;
+ if (loading) {
+ options = (
+
+ );
+ } else {
+ options = users.map(({ login, avatar_url: avatar }) => (
+
+ ));
+ }
+
+ return (
+
+
+ {options}
+
+ search: {search}
+
+ );
+ }
+}
+
+export default Demo;
diff --git a/examples/dynamic.less b/examples/dynamic.less
new file mode 100644
index 0000000..a71eaa0
--- /dev/null
+++ b/examples/dynamic.less
@@ -0,0 +1,29 @@
+.dynamic-option {
+ font-size: 20px;
+
+ img {
+ height: 20px;
+ width: 20px;
+ vertical-align: middle;
+ margin-right: 4px;
+ transition: all .3s;
+ }
+
+ span {
+ vertical-align: middle;
+ display: inline-block;
+ transition: all .3s;
+ margin-right: 8px;
+ }
+
+ &.rc-mentions-dropdown-menu-item-active {
+ img {
+ transform: scale(1.8);
+ }
+
+ span {
+ margin-left: 8px;
+ margin-right: 0;
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/filter.js b/examples/filter.js
new file mode 100644
index 0000000..61ba4cf
--- /dev/null
+++ b/examples/filter.js
@@ -0,0 +1,37 @@
+/* eslint no-console: 0 */
+
+import React from 'react';
+import Mentions from '../src';
+import '../assets/index.less';
+
+const { Option } = Mentions;
+
+function filterOption(input, { id }) {
+ return id.indexOf(input) !== -1;
+}
+
+class Demo extends React.Component {
+ state = {};
+
+ render() {
+ return (
+
+
Customize Filter
+
Option has `id` and filter only hit by `id`
+
+
+
+
+
+
+ );
+ }
+}
+
+export default Demo;
diff --git a/examples/multiple-prefix.js b/examples/multiple-prefix.js
new file mode 100644
index 0000000..18f3a6d
--- /dev/null
+++ b/examples/multiple-prefix.js
@@ -0,0 +1,46 @@
+/* eslint no-console: 0 */
+
+import React from 'react';
+import Mentions from '../src';
+import '../assets/index.less';
+
+const { Option } = Mentions;
+
+const OPTIONS = {
+ '@': ['light', 'bamboo', 'cat'],
+ '#': ['123', '456', '7890'],
+};
+
+class Demo extends React.Component {
+ state = {
+ prefix: '@',
+ };
+
+ onSearch = (_, prefix) => {
+ this.setState({ prefix });
+ };
+
+ render() {
+ const { prefix } = this.state;
+
+ return (
+
+ @ for string, # for number
+
+ {OPTIONS[prefix].map(value => (
+
+ ))}
+
+
+ );
+ }
+}
+
+export default Demo;
diff --git a/examples/split.js b/examples/split.js
new file mode 100644
index 0000000..f50e506
--- /dev/null
+++ b/examples/split.js
@@ -0,0 +1,37 @@
+/* eslint no-console: 0 */
+
+import React from 'react';
+import Mentions from '../src';
+import '../assets/index.less';
+
+const { Option } = Mentions;
+
+function validateSearch(text) {
+ console.log('~~>', text);
+ return text.length <= 3;
+}
+
+class Demo extends React.Component {
+ state = {};
+
+ render() {
+ return (
+
+
Customize Split Logic
+
Only validate string length less than 3
+
+
+
+
+
+
+ );
+ }
+}
+
+export default Demo;
diff --git a/package.json b/package.json
index 7104842..95fa4c9 100644
--- a/package.json
+++ b/package.json
@@ -56,15 +56,22 @@
"enzyme-adapter-react-16": "^1.7.1",
"enzyme-to-json": "^3.1.4",
"lint-staged": "^8.1.0",
+ "lodash.debounce": "^4.0.8",
"pre-commit": "1.x",
"querystring": "^0.2.0",
- "rc-tools": "^9.3.11",
+ "rc-tools": "^9.4.1",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"typescript": "^3.2.2"
},
"dependencies": {
- "babel-runtime": "^6.23.0"
+ "@ant-design/create-react-context": "^0.2.4",
+ "babel-runtime": "^6.23.0",
+ "classnames": "^2.2.6",
+ "rc-menu": "^7.4.22",
+ "rc-trigger": "^2.6.2",
+ "rc-util": "^4.6.0",
+ "react-lifecycles-compat": "^3.0.4"
},
"pre-commit": [
"lint-staged"
diff --git a/src/DropdownMenu.tsx b/src/DropdownMenu.tsx
new file mode 100644
index 0000000..a6920af
--- /dev/null
+++ b/src/DropdownMenu.tsx
@@ -0,0 +1,63 @@
+import Menu, { MenuItem } from 'rc-menu';
+import * as React from 'react';
+import { MentionsContextConsumer, MentionsContextProps } from './MentionsContext';
+import { OptionProps } from './Option';
+
+interface DropdownMenuProps {
+ prefixCls?: string;
+ options: OptionProps[];
+}
+
+/**
+ * We only use Menu to display the candidate.
+ * The focus is controlled by textarea to make accessibility easy.
+ */
+class DropdownMenu extends React.Component {
+ public renderDropdown = ({
+ notFoundContent,
+ activeIndex,
+ setActiveIndex,
+ selectOption,
+ onFocus,
+ }: MentionsContextProps) => {
+ const { prefixCls, options } = this.props;
+ const activeOption = options[activeIndex] || {};
+
+ return (
+
+ );
+ };
+
+ public render() {
+ return {this.renderDropdown};
+ }
+}
+
+export default DropdownMenu;
diff --git a/src/KeywordTrigger.tsx b/src/KeywordTrigger.tsx
new file mode 100644
index 0000000..e56ca48
--- /dev/null
+++ b/src/KeywordTrigger.tsx
@@ -0,0 +1,59 @@
+import Trigger from 'rc-trigger';
+import * as React from 'react';
+import DropdownMenu from './DropdownMenu';
+import { OptionProps } from './Option';
+
+const BUILT_IN_PLACEMENTS = {
+ bottomRight: {
+ points: ['tl', 'br'],
+ offset: [0, 4],
+ overflow: {
+ adjustX: 0,
+ adjustY: 1,
+ },
+ },
+ topRight: {
+ points: ['bl', 'tr'],
+ offset: [0, -4],
+ overflow: {
+ adjustX: 0,
+ adjustY: 1,
+ },
+ },
+};
+
+interface KeywordTriggerProps {
+ visible?: boolean;
+ loading?: boolean;
+ prefixCls?: string;
+ options: OptionProps[];
+}
+
+class KeywordTrigger extends React.Component {
+ public getDropdownPrefix = () => `${this.props.prefixCls}-dropdown`;
+
+ public getDropdownElement = () => {
+ const { options } = this.props;
+ return ;
+ };
+
+ public render() {
+ const { children, visible } = this.props;
+
+ const popupElement = this.getDropdownElement();
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+export default KeywordTrigger;
diff --git a/src/Mentions.tsx b/src/Mentions.tsx
index 20af4de..450867b 100644
--- a/src/Mentions.tsx
+++ b/src/Mentions.tsx
@@ -1,7 +1,354 @@
+import classNames from 'classnames';
+import toArray from 'rc-util/lib/Children/toArray';
+import KeyCode from 'rc-util/lib/KeyCode';
import * as React from 'react';
+import { polyfill } from 'react-lifecycles-compat';
+import KeywordTrigger from './KeywordTrigger';
+import { MentionsContextProvider } from './MentionsContext';
+import Option, { OptionProps } from './Option';
+import {
+ filterOption as defaultFilterOption,
+ getBeforeSelectionText,
+ getLastMeasureIndex,
+ replaceWithMeasure,
+ setInputSelection,
+ validateSearch as defaultValidateSearch,
+} from './util';
-export default class Mentions extends React.Component {
- render() {
- return null;
+export interface MentionsProps {
+ defaultValue?: string;
+ value?: string;
+ onChange?: (text: string) => void;
+ onSelect?: (option: OptionProps, prefix: string) => void;
+ onSearch?: (text: string, prefix: string) => void;
+ onFocus?: React.FocusEventHandler;
+ onBlur?: React.FocusEventHandler;
+ prefixCls?: string;
+ prefix?: string | string[];
+ className?: string;
+ style?: React.CSSProperties;
+ autoFocus?: boolean;
+ split?: string;
+ validateSearch?: typeof defaultValidateSearch;
+ filterOption?: false | typeof defaultFilterOption;
+ notFoundContent?: React.ReactNode;
+}
+interface MentionsState {
+ value: string;
+ measuring: boolean;
+ measureText: string | null;
+ measurePrefix: string;
+ measureLocation: number;
+ activeIndex: number;
+ isFocus: boolean;
+}
+class Mentions extends React.Component {
+ public static Option = Option;
+
+ public static defaultProps = {
+ prefixCls: 'rc-mentions',
+ prefix: '@',
+ split: ' ',
+ validateSearch: defaultValidateSearch,
+ filterOption: defaultFilterOption,
+ notFoundContent: 'Not Found',
+ };
+
+ public static getDerivedStateFromProps(props: MentionsProps, prevState: MentionsState) {
+ const newState: Partial = {};
+
+ if ('value' in props && props.value !== prevState.value) {
+ newState.value = props.value;
+ }
+
+ return newState;
+ }
+
+ public textarea?: HTMLTextAreaElement;
+ public measure?: HTMLDivElement;
+ public focusId: number | undefined = undefined;
+
+ constructor(props: MentionsProps) {
+ super(props);
+ this.state = {
+ value: props.defaultValue || props.value || '',
+ measuring: false,
+ measureLocation: 0,
+ measureText: null,
+ measurePrefix: '',
+ activeIndex: 0,
+ isFocus: false,
+ };
+ }
+
+ public componentDidUpdate() {
+ const { measuring } = this.state;
+
+ // Sync measure div top with textarea for rc-trigger usage
+ if (measuring) {
+ this.measure!.scrollTop = this.textarea!.scrollTop;
+ }
+ }
+
+ public triggerChange = (value: string) => {
+ const { onChange } = this.props;
+ if (!('value' in this.props)) {
+ this.setState({ value });
+ }
+
+ if (onChange) {
+ onChange(value);
+ }
+ };
+
+ public onChange: React.ChangeEventHandler = ({ target: { value } }) => {
+ this.triggerChange(value);
+ };
+
+ // Check if hit the measure keyword
+ public onKeyDown: React.KeyboardEventHandler = event => {
+ const { which } = event;
+ const { activeIndex, measuring } = this.state;
+
+ // Skip if not measuring
+ if (!measuring) {
+ return;
+ }
+
+ if (which === KeyCode.UP || which === KeyCode.DOWN) {
+ // Control arrow function
+ const optionLen = this.getOptions().length;
+ const offset = which === KeyCode.UP ? -1 : 1;
+ const newActiveIndex = (activeIndex + offset + optionLen) % optionLen;
+ this.setState({
+ activeIndex: newActiveIndex,
+ });
+ event.preventDefault();
+ } else if (which === KeyCode.ESC) {
+ this.stopMeasure();
+ return;
+ } else if (which === KeyCode.ENTER) {
+ // Measure hit
+ const option = this.getOptions()[activeIndex];
+ this.selectOption(option);
+ event.preventDefault();
+ }
+ };
+
+ /**
+ * When to start measure:
+ * 1. When user press `prefix`
+ * 2. When measureText !== prevMeasureText
+ * - If measure hit
+ * - If measuring
+ *
+ * When to stop measure:
+ * 1. Selection is out of range
+ * 2. Contains `space`
+ * 3. ESC or select one
+ */
+ public onKeyUp: React.KeyboardEventHandler = event => {
+ const { key, which } = event;
+ const { measureText: prevMeasureText, measuring } = this.state;
+ const { prefix = '', onSearch, validateSearch } = this.props;
+ const target = event.target as HTMLTextAreaElement;
+ const selectionStartText = getBeforeSelectionText(target);
+ const { location: measureIndex, prefix: measurePrefix } = getLastMeasureIndex(
+ selectionStartText,
+ prefix,
+ );
+
+ // Skip if match the white key list
+ if ([KeyCode.ESC, KeyCode.UP, KeyCode.DOWN, KeyCode.ENTER].indexOf(which) !== -1) {
+ return;
+ }
+
+ if (measureIndex !== -1) {
+ const measureText = selectionStartText.slice(measureIndex + measurePrefix.length);
+ const validateMeasure: boolean = validateSearch!(measureText, this.props);
+ const matchOption: boolean = !!this.getOptions(measureText).length;
+
+ if (validateMeasure) {
+ if (
+ key === measurePrefix ||
+ measuring ||
+ (measureText !== prevMeasureText && matchOption)
+ ) {
+ this.startMeasure(measureText, measurePrefix, measureIndex);
+ }
+ } else if (measuring) {
+ // Stop if measureText is invalidate
+ this.stopMeasure();
+ }
+
+ /**
+ * We will trigger `onSearch` to developer since they may use for async update.
+ * If met `space` means user finished searching.
+ */
+ if (onSearch && validateMeasure) {
+ onSearch(measureText, measurePrefix);
+ }
+ } else if (measuring) {
+ this.stopMeasure();
+ }
+ };
+
+ public onInputFocus: React.FocusEventHandler = event => {
+ this.onFocus(event);
+ };
+
+ public onInputBlur: React.FocusEventHandler = event => {
+ this.onBlur(event);
+ };
+
+ public onDropdownFocus = () => {
+ this.onFocus();
+ };
+
+ public onFocus = (event?: React.FocusEvent) => {
+ window.clearTimeout(this.focusId);
+ const { isFocus } = this.state;
+ const { onFocus } = this.props;
+ if (!isFocus && event && onFocus) {
+ onFocus(event);
+ }
+ this.setState({ isFocus: true });
+ };
+
+ public onBlur = (event: React.FocusEvent) => {
+ this.focusId = window.setTimeout(() => {
+ const { onBlur } = this.props;
+ this.setState({ isFocus: false });
+ this.stopMeasure();
+ if (onBlur) {
+ onBlur(event);
+ }
+ }, 0);
+ };
+
+ public selectOption = (option: OptionProps) => {
+ const { value, measureLocation, measurePrefix } = this.state;
+ const { split, onSelect } = this.props;
+
+ const { value: mentionValue = '' } = option;
+ const { text, selectionLocation } = replaceWithMeasure(value, {
+ measureLocation,
+ targetText: mentionValue,
+ prefix: measurePrefix,
+ selectionStart: this.textarea!.selectionStart,
+ split: split!,
+ });
+ this.triggerChange(text);
+ this.stopMeasure(() => {
+ // We need restore the selection position
+ setInputSelection(this.textarea!, selectionLocation);
+ });
+
+ if (onSelect) {
+ onSelect(option, measurePrefix);
+ }
+ };
+
+ public setActiveIndex = (activeIndex: number) => {
+ this.setState({
+ activeIndex,
+ });
+ };
+
+ public setTextAreaRef = (element: HTMLTextAreaElement) => {
+ this.textarea = element;
+ };
+
+ public setMeasureRef = (element: HTMLDivElement) => {
+ this.measure = element;
+ };
+
+ public getOptions = (measureText?: string): OptionProps[] => {
+ const targetMeasureText = measureText || this.state.measureText || '';
+ const { children, filterOption } = this.props;
+ const list = toArray(children)
+ .map(({ props }: { props: OptionProps }) => props)
+ .filter((option: OptionProps) => {
+ /** Return all result if `filterOption` is false. */
+ if (filterOption === false) {
+ return true;
+ }
+ return filterOption!(targetMeasureText, option);
+ });
+ return list;
+ };
+
+ public startMeasure(measureText: string, measurePrefix: string, measureLocation: number) {
+ this.setState({
+ measuring: true,
+ measureText,
+ measurePrefix,
+ measureLocation,
+ activeIndex: 0,
+ });
+ }
+
+ public stopMeasure(callback?: () => void) {
+ this.setState(
+ {
+ measuring: false,
+ measureLocation: 0,
+ measureText: null,
+ },
+ callback,
+ );
+ }
+
+ public focus() {
+ this.textarea!.focus();
+ }
+
+ public blur() {
+ this.textarea!.blur();
+ }
+
+ public render() {
+ const { value, measureLocation, measurePrefix, measuring, activeIndex } = this.state;
+ const { prefixCls, className, style, autoFocus, notFoundContent } = this.props;
+
+ const options = measuring ? this.getOptions() : [];
+
+ return (
+
+
+ {measuring && (
+
+ {value.slice(0, measureLocation)}
+
+
+ {measurePrefix}
+
+
+ {value.slice(measureLocation + measurePrefix.length)}
+
+ )}
+
+ );
}
}
+
+polyfill(Mentions);
+
+export default Mentions;
diff --git a/src/MentionsContext.ts b/src/MentionsContext.ts
new file mode 100644
index 0000000..5818228
--- /dev/null
+++ b/src/MentionsContext.ts
@@ -0,0 +1,16 @@
+import createReactContext, { Context } from '@ant-design/create-react-context';
+import * as React from 'react';
+import { OptionProps } from './Option';
+
+export interface MentionsContextProps {
+ notFoundContent: React.ReactNode;
+ activeIndex: number;
+ setActiveIndex: (index: number) => void;
+ selectOption: (option: OptionProps) => void;
+ onFocus: () => void;
+}
+
+const MentionsContext: Context = createReactContext(null);
+
+export const MentionsContextProvider = MentionsContext.Provider;
+export const MentionsContextConsumer = MentionsContext.Consumer;
diff --git a/src/Option.tsx b/src/Option.tsx
new file mode 100644
index 0000000..88e0fa1
--- /dev/null
+++ b/src/Option.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+
+export interface OptionProps {
+ value?: string;
+ disabled?: boolean;
+ children?: React.ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const Option: React.SFC = () => null;
+
+export default Option;
diff --git a/src/util.ts b/src/util.ts
new file mode 100644
index 0000000..a5b1f1f
--- /dev/null
+++ b/src/util.ts
@@ -0,0 +1,124 @@
+import { MentionsProps } from './Mentions';
+import { OptionProps } from './Option';
+
+/**
+ * Cut input selection into 2 part and return text before selection start
+ */
+export function getBeforeSelectionText(input: HTMLTextAreaElement) {
+ const { selectionStart } = input as any;
+ return input.value.slice(0, selectionStart);
+}
+
+interface MeasureIndex {
+ location: number;
+ prefix: string;
+}
+/**
+ * Find the last match prefix index
+ */
+export function getLastMeasureIndex(text: string, prefix: string | string[] = ''): MeasureIndex {
+ const prefixList: string[] = Array.isArray(prefix) ? prefix : [prefix];
+ return prefixList.reduce(
+ (lastMatch: MeasureIndex, prefixStr): MeasureIndex => {
+ const lastIndex = text.lastIndexOf(prefixStr);
+ if (lastIndex > lastMatch.location) {
+ return {
+ location: lastIndex,
+ prefix: prefixStr,
+ };
+ }
+ return lastMatch;
+ },
+ { location: -1, prefix: '' },
+ );
+}
+
+interface MeasureConfig {
+ measureLocation: number;
+ prefix: string;
+ targetText: string;
+ selectionStart: number;
+ split: string;
+}
+
+function lower(char: string | undefined): string {
+ return (char || '').toLowerCase();
+}
+
+function reduceText(text: string, targetText: string, split: string) {
+ const firstChar = text[0];
+ if (!firstChar || firstChar === split) {
+ return text;
+ }
+
+ // Reuse rest text as it can
+ let restText = text;
+ const targetTextLen = targetText.length;
+ for (let i = 0; i < targetTextLen; i += 1) {
+ if (lower(restText[i]) !== lower(targetText[i])) {
+ restText = restText.slice(i);
+ break;
+ } else if (i === targetTextLen - 1) {
+ restText = restText.slice(targetTextLen);
+ }
+ }
+
+ return restText;
+}
+
+/**
+ * Paint targetText into current text:
+ * text: little@litest
+ * targetText: light
+ * => little @light test
+ */
+export function replaceWithMeasure(text: string, measureConfig: MeasureConfig) {
+ const { measureLocation, prefix, targetText, selectionStart, split } = measureConfig;
+
+ // Before text will append one space if have other text
+ let beforeMeasureText = text.slice(0, measureLocation);
+ if (beforeMeasureText[beforeMeasureText.length - split.length] === split) {
+ beforeMeasureText = beforeMeasureText.slice(0, beforeMeasureText.length - split.length);
+ }
+ if (beforeMeasureText) {
+ beforeMeasureText = `${beforeMeasureText}${split}`;
+ }
+
+ // Cut duplicate string with current targetText
+ let restText = reduceText(
+ text.slice(selectionStart),
+ targetText.slice(selectionStart - measureLocation - prefix.length),
+ split,
+ );
+ if (restText.slice(0, split.length) === split) {
+ restText = restText.slice(split.length);
+ }
+
+ const connectedStartText = `${beforeMeasureText}${prefix}${targetText}${split}`;
+
+ return {
+ text: `${connectedStartText}${restText}`,
+ selectionLocation: connectedStartText.length,
+ };
+}
+
+export function setInputSelection(input: HTMLTextAreaElement, location: number) {
+ input.setSelectionRange(location, location);
+
+ /**
+ * Reset caret into view.
+ * Since this function always called by user control, it's safe to focus element.
+ */
+ input.blur();
+ input.focus();
+}
+
+export function validateSearch(text: string, props: MentionsProps) {
+ const { split } = props;
+ return !split || text.indexOf(split) === -1;
+}
+
+export function filterOption(input: string, { value = '' }: OptionProps): boolean {
+ const lowerCase = input.toLowerCase();
+ return value.toLowerCase().indexOf(lowerCase) !== -1;
+}
diff --git a/storybook/index.js b/storybook/index.js
index 74f8f53..85f94f6 100644
--- a/storybook/index.js
+++ b/storybook/index.js
@@ -6,7 +6,16 @@ import { storiesOf } from '@storybook/react';
import { withConsole } from '@storybook/addon-console';
import { withViewport } from '@storybook/addon-viewport';
import { withInfo } from '@storybook/addon-info';
-
+import BasicSource from 'rc-source-loader!../examples/basic';
+import DynamicSource from 'rc-source-loader!../examples/dynamic';
+import FilterSource from 'rc-source-loader!../examples/filter';
+import MultiplePrefixSource from 'rc-source-loader!../examples/multiple-prefix';
+import SplitSource from 'rc-source-loader!../examples/split';
+import Basic from '../examples/basic';
+import Dynamic from '../examples/dynamic';
+import Filter from '../examples/filter';
+import MultiplePrefix from '../examples/multiple-prefix';
+import Split from '../examples/split';
import READMECode from '../README.md';
storiesOf('rc-mentions', module)
@@ -31,4 +40,29 @@ storiesOf('rc-mentions', module)
code: READMECode,
},
},
- );
+ )
+ .add('basic', () => , {
+ source: {
+ code: BasicSource,
+ },
+ })
+ .add('dynamic', () => , {
+ source: {
+ code: DynamicSource,
+ },
+ })
+ .add('filter', () => , {
+ source: {
+ code: FilterSource,
+ },
+ })
+ .add('multiple-prefix', () => , {
+ source: {
+ code: MultiplePrefixSource,
+ },
+ })
+ .add('split', () => , {
+ source: {
+ code: SplitSource,
+ },
+ });
diff --git a/tests/FullProcess.spec.jsx b/tests/FullProcess.spec.jsx
new file mode 100644
index 0000000..292fc8f
--- /dev/null
+++ b/tests/FullProcess.spec.jsx
@@ -0,0 +1,113 @@
+import { mount } from 'enzyme';
+import KeyCode from 'rc-util/lib/KeyCode';
+import React from 'react';
+import Mentions from '../src';
+import { simulateInput } from './shared/input';
+
+const { Option } = Mentions;
+
+describe('Full Process', () => {
+ function createMentions(props) {
+ return mount(
+
+
+
+
+ ,
+ );
+ }
+
+ it('Keyboard selection', () => {
+ const onChange = jest.fn();
+ const onSelect = jest.fn();
+ const onSearch = jest.fn();
+ const wrapper = createMentions({ onChange, onSelect, onSearch });
+
+ simulateInput(wrapper, '@');
+ expect(wrapper.find('DropdownMenu').props().options).toMatchObject([
+ { value: 'bamboo' },
+ { value: 'light' },
+ { value: 'cat' },
+ ]);
+
+ simulateInput(wrapper, '@a');
+ expect(wrapper.find('DropdownMenu').props().options).toMatchObject([
+ { value: 'bamboo' },
+ { value: 'cat' },
+ ]);
+ expect(onSearch).toBeCalledWith('a', '@');
+
+ wrapper.find('textarea').simulate('keyDown', {
+ which: KeyCode.DOWN,
+ });
+ wrapper.find('textarea').simulate('keyDown', {
+ which: KeyCode.ENTER,
+ });
+
+ expect(onChange).toBeCalledWith('@cat ');
+ expect(onSelect).toBeCalledWith(expect.objectContaining({ value: 'cat' }), '@');
+ });
+
+ it('insert into half way', () => {
+ const onChange = jest.fn();
+ const wrapper = createMentions({ onChange });
+ simulateInput(wrapper, '1 @ 2');
+
+ // Mock direct to the position
+ wrapper.find('textarea').instance().selectionStart = 3;
+ wrapper.find('textarea').simulate('keyUp', {});
+ expect(wrapper.state().measuring).toBeTruthy();
+
+ wrapper.find('textarea').simulate('keyDown', {
+ which: KeyCode.ENTER,
+ });
+
+ expect(onChange).toBeCalledWith('1 @bamboo 2');
+ });
+
+ it('reuse typed text', () => {
+ const onChange = jest.fn();
+ const wrapper = createMentions({ onChange });
+ simulateInput(wrapper, '1 @bamboo 2');
+
+ // Mock direct to the position
+ wrapper.find('textarea').instance().selectionStart = 3;
+ wrapper.find('textarea').simulate('keyUp', {});
+ expect(wrapper.state().measuring).toBeTruthy();
+
+ wrapper.find('textarea').simulate('keyDown', {
+ which: KeyCode.ENTER,
+ });
+
+ expect(onChange).toBeCalledWith('1 @bamboo 2');
+ });
+
+ it('stop measure if match split', () => {
+ const wrapper = createMentions();
+ simulateInput(wrapper, '@a');
+ expect(wrapper.state().measuring).toBeTruthy();
+ simulateInput(wrapper, '@a ');
+ expect(wrapper.state().measuring).toBeFalsy();
+ });
+
+ it('stop measure if remove prefix', () => {
+ const wrapper = createMentions();
+ simulateInput(wrapper, 'test@');
+ expect(wrapper.state().measuring).toBeTruthy();
+ simulateInput(wrapper, 'test');
+ expect(wrapper.state().measuring).toBeFalsy();
+ });
+
+ it('ESC', () => {
+ const wrapper = createMentions();
+
+ simulateInput(wrapper, '@');
+ expect(wrapper.state().measuring).toBe(true);
+
+ simulateInput(wrapper, '@', {
+ which: KeyCode.ESC,
+ });
+
+ expect(wrapper.state().measuring).toBe(false);
+ });
+});
diff --git a/tests/Mentions.spec.jsx b/tests/Mentions.spec.jsx
new file mode 100644
index 0000000..e31067c
--- /dev/null
+++ b/tests/Mentions.spec.jsx
@@ -0,0 +1,134 @@
+import { mount } from 'enzyme';
+import React from 'react';
+import Mentions from '../src';
+import { simulateInput } from './shared/input';
+
+const { Option } = Mentions;
+
+describe('Mentions', () => {
+ function createMentions(props) {
+ return mount(
+
+
+
+
+ ,
+ );
+ }
+
+ describe('focus test', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('autoFocus', () => {
+ const wrapper = createMentions({ autoFocus: true });
+ expect(document.activeElement).toBe(wrapper.find('textarea').instance());
+ });
+
+ it('not lose focus if click on dropdown', () => {
+ const onBlur = jest.fn();
+ const wrapper = createMentions({ autoFocus: true, defaultValue: '@', onBlur });
+
+ // Inject to trigger measure
+ wrapper.instance().startMeasure('b', '@', 1);
+ jest.runAllTimers();
+ wrapper.update();
+
+ wrapper.find('MenuItem').simulate('focus');
+ wrapper.find('textarea').simulate('blur');
+ wrapper.find('MenuItem').simulate('click');
+ wrapper.find('textarea').simulate('focus'); // This is not good but code focus not work in simulate
+ jest.runAllTimers();
+
+ expect(onBlur).not.toBeCalled();
+ });
+
+ it('focus', () => {
+ const onFocus = jest.fn();
+ const wrapper = createMentions({ onFocus });
+ wrapper.find('textarea').simulate('focus');
+ expect(onFocus).toBeCalled();
+ });
+
+ it('blur', () => {
+ const onBlur = jest.fn();
+ const wrapper = createMentions({ onBlur });
+ wrapper.find('textarea').simulate('blur');
+ jest.runAllTimers();
+ expect(onBlur).toBeCalled();
+ });
+
+ it('focus() & blur()', () => {
+ const wrapper = createMentions();
+ wrapper.instance().focus();
+ expect(document.activeElement).toBe(wrapper.find('textarea').instance());
+
+ wrapper.instance().blur();
+ expect(document.activeElement).not.toBe(wrapper.find('textarea').instance());
+ });
+ });
+
+ describe('value', () => {
+ it('defaultValue', () => {
+ const wrapper = createMentions({ defaultValue: 'light' });
+ expect(wrapper.find('textarea').props().value).toBe('light');
+ });
+
+ it('controlled value', () => {
+ const wrapper = createMentions({ value: 'bamboo' });
+ expect(wrapper.find('textarea').props().value).toBe('bamboo');
+
+ wrapper.setProps({ value: 'cat' });
+ expect(wrapper.find('textarea').props().value).toBe('cat');
+ });
+
+ it('onChange', () => {
+ const onChange = jest.fn();
+ const wrapper = createMentions({ onChange });
+ wrapper.find('textarea').simulate('change', {
+ target: { value: 'bamboo' },
+ });
+ expect(onChange).toBeCalledWith('bamboo');
+ });
+ });
+
+ describe('filterOption', () => {
+ it('false', () => {
+ const wrapper = createMentions({ filterOption: false });
+ simulateInput(wrapper, '@notExist');
+ expect(wrapper.find('DropdownMenu').props().options.length).toBe(3);
+ });
+
+ it('function', () => {
+ const wrapper = createMentions({ filterOption: (_, { value }) => value.includes('a') });
+ simulateInput(wrapper, '@notExist');
+ expect(wrapper.find('DropdownMenu').props().options.length).toBe(2);
+ });
+ });
+
+ it('notFoundContent', () => {
+ const notFoundContent = 'Bamboo Light';
+ const wrapper = createMentions({ notFoundContent });
+ simulateInput(wrapper, '@a');
+ simulateInput(wrapper, '@notExist');
+ expect(wrapper.find('DropdownMenu').props().options.length).toBe(0);
+ expect(wrapper.find('MenuItem').props().children).toBe(notFoundContent);
+ });
+
+ describe('accessibility', () => {
+ it('hover', () => {
+ const wrapper = createMentions();
+ simulateInput(wrapper, '@');
+ wrapper
+ .find('MenuItem')
+ .last()
+ .simulate('mouseEnter');
+ expect(wrapper.find('Menu').props().activeKey).toBe('cat');
+ });
+ });
+});
diff --git a/tests/Option.spec.jsx b/tests/Option.spec.jsx
new file mode 100644
index 0000000..ff78f74
--- /dev/null
+++ b/tests/Option.spec.jsx
@@ -0,0 +1,13 @@
+import { mount } from 'enzyme';
+import React from 'react';
+import Mentions from '../src';
+
+const { Option } = Mentions;
+
+describe('Option', () => {
+ // Option is a placeholder component. It should render nothing.
+ it('should be empty', () => {
+ const wrapper = mount();
+ expect(wrapper.instance()).toBe(null);
+ });
+});
diff --git a/tests/setup.ts b/tests/setup.ts
deleted file mode 100644
index 128e45c..0000000
--- a/tests/setup.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-require('raf').polyfill();
-
-const Enzyme = require('enzyme');
-const Adapter = require('enzyme-adapter-react-16');
-
-Enzyme.configure({ adapter: new Adapter() });
diff --git a/tests/shared/input.js b/tests/shared/input.js
new file mode 100644
index 0000000..8676c11
--- /dev/null
+++ b/tests/shared/input.js
@@ -0,0 +1,25 @@
+/* eslint-disable import/prefer-default-export */
+
+export function simulateInput(wrapper, text = '', keyEvent) {
+ const lastChar = text[text.length - 1];
+ const myKeyEvent = keyEvent || {
+ which: lastChar.charCodeAt(0),
+ key: lastChar,
+ };
+
+ wrapper.find('textarea').simulate('keyDown', myKeyEvent);
+
+ const textareaInstance = wrapper.find('textarea').instance();
+ textareaInstance.value = text;
+ textareaInstance.selectionStart = text.length;
+ textareaInstance.selectionStart = text.length;
+
+ if (!keyEvent) {
+ wrapper.find('textarea').simulate('change', {
+ target: { value: text },
+ });
+ }
+
+ wrapper.find('textarea').simulate('keyUp', myKeyEvent);
+ wrapper.update();
+}
diff --git a/typings/index.d.ts b/typings/index.d.ts
index 36ee66c..743b3b7 100644
--- a/typings/index.d.ts
+++ b/typings/index.d.ts
@@ -1 +1,4 @@
+declare module 'react-lifecycles-compat';
declare module 'rc-trigger';
+declare module 'rc-util/*';
+declare module 'rc-menu';