diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index d23e109..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: 2 -jobs: - lint: - docker: - - image: circleci/node:latest - steps: - - checkout - - restore_cache: - keys: - - v1-dependencies-{{ checksum "package.json" }} - - run: npm install - - run: npm run init-tslint - - save_cache: - paths: - - node_modules - key: v1-dependencies-{{ checksum "package.json" }} - - run: npm run lint - test: - docker: - - image: circleci/node:latest - working_directory: ~/repo - steps: - - checkout - - restore_cache: - keys: - - v1-dependencies-{{ checksum "package.json" }} - - run: npm install - - run: npm run init-tslint - - save_cache: - paths: - - node_modules - key: v1-dependencies-{{ checksum "package.json" }} - - run: npm test -- --coverage -workflows: - version: 2 - build_and_test: - jobs: - - lint - - test diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..68575f3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,30 @@ +language: node_js + +sudo: false + +notifications: + email: + - smith3816@gmail.com + +node_js: +- 10 + +before_install: +- | + if ! git diff --name-only $TRAVIS_COMMIT_RANGE | grep -qvE '(\.md$)|(^(docs|examples))/' + then + echo "Only docs were updated, stopping build process." + exit + fi +script: +- | + if [ "$TEST_TYPE" = test ]; then + npm test -- --coverage && \ + bash <(curl -s https://codecov.io/bash) + else + npm run $TEST_TYPE + fi +env: + matrix: + - TEST_TYPE=lint + - TEST_TYPE=test \ No newline at end of file diff --git a/README.md b/README.md index 5a87de2..9f04691 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ React Mentions ## Screenshots - + ## Feature @@ -47,9 +47,14 @@ React Mentions ```js import Mentions from 'rc-mentions'; -// TODO: update +const { Option } = Mentions; var Demo = ( + + + + + ); React.render(, container); ``` @@ -60,13 +65,26 @@ React.render(, container); | name | description | type | default | |----------|----------------|----------|--------------| -| | | | | +| defaultValue | Default value | string | - | +| value | Set value of mentions | string | - | +| prefix | Set trigger prefix keyword | string \| string[] | '@' | +| autoFocus | Auto get focus when component mounted | boolean | `false` | +| split | Set split string before and after selected mention | string | ' ' | +| validateSearch | Customize trigger search logic | (text: string, props: MentionsProps) => void | - | +| filterOption | Customize filter option logic | false \| (input: string, option: OptionProps) => boolean | - | +| notFoundContent | Set mentions content when not match | ReactNode | 'Not Found' | +| onChange | Trigger when value changed |(text: string) => void | - | +| onSelect | Trigger when user select the option | (option: OptionProps, prefix: string) => void | - | +| onSearch | Trigger when prefix hit | (text: string, prefix: string) => void | - | +| onFocus | Trigger when mentions get focus | React.FocusEventHandler | - | +| onBlur | Trigger when mentions lose focus | React.FocusEventHandler | - | ### 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 ( + { + const option = options.find(({ value }) => value === key); + selectOption(option!); + }} + onFocus={onFocus} + > + {options.map((option, index) => { + const { value, disabled, children, className, style } = option; + return ( + { + setActiveIndex(index); + }} + > + {children} + + ); + })} + + {!options.length && {notFoundContent}} + + ); + }; + + 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 ( +
+