├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.js ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc ├── HISTORY.md ├── LICENSE.md ├── README.md ├── assets └── index.less ├── bunfig.toml ├── docs ├── demo.md ├── examples │ ├── allowClear.tsx │ ├── basic.tsx │ ├── debug.tsx │ ├── dynamic.less │ ├── dynamic.tsx │ ├── filter.tsx │ ├── multiple-prefix.tsx │ ├── onScroll.less │ ├── onScroll.tsx │ ├── split.tsx │ ├── textarea.less │ └── textarea.tsx └── index.md ├── index.js ├── jest.config.js ├── package.json ├── src ├── DropdownMenu.tsx ├── KeywordTrigger.tsx ├── Mentions.tsx ├── MentionsContext.ts ├── Option.tsx ├── context.tsx ├── hooks │ └── useEffectState.ts ├── index.ts └── util.ts ├── tests ├── AllowClear.spec.tsx ├── DropdownMenu.spec.tsx ├── FullProcess.spec.tsx ├── Mentions.spec.tsx ├── Open.spec.tsx ├── Option.spec.tsx ├── __mocks__ │ └── rc-trigger.tsx ├── __snapshots__ │ └── AllowClear.spec.tsx.snap ├── setup.ts ├── setupFilesAfterEnv.ts └── util.ts └── tsconfig.json /.dumirc.ts: -------------------------------------------------------------------------------- 1 | // more config: https://d.umijs.org/config 2 | import { defineConfig } from 'dumi'; 3 | 4 | const name = 'mentions'; 5 | 6 | export default defineConfig({ 7 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], 8 | themeConfig: { 9 | name: '@rc-component/mentions', 10 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 11 | }, 12 | outputPath: '.doc', 13 | exportStatic: {}, 14 | base: `/${name}/`, 15 | publicPath: `/${name}/`, 16 | styles: [ 17 | ` 18 | .markdown table { 19 | width: auto !important; 20 | } 21 | `, 22 | ], 23 | mfsu: {}, 24 | }); 25 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*.{js,css}] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@umijs/fabric/dist/eslint')], 3 | rules: { 4 | 'jsx-a11y/no-autofocus': 0, 5 | '@typescript-eslint/consistent-indexed-object-style': 0, 6 | '@typescript-eslint/no-parameter-properties': 0, 7 | '@typescript-eslint/no-throw-literal': 0, 8 | '@typescript-eslint/type-annotation-spacing': 0, 9 | '@typescript-eslint/ban-types': 0, 10 | }, 11 | overrides: [ 12 | { 13 | files: ['docs/**/*.tsx'], 14 | rules: { 15 | 'no-console': 0, 16 | }, 17 | }, 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /.fatherrc.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: "@types/react" 11 | versions: 12 | - 17.0.0 13 | - 17.0.1 14 | - 17.0.2 15 | - 17.0.3 16 | - dependency-name: "@types/react-dom" 17 | versions: 18 | - 17.0.0 19 | - 17.0.1 20 | - 17.0.2 21 | - dependency-name: react 22 | versions: 23 | - 17.0.1 24 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | *.iml 3 | *.log 4 | .idea/ 5 | .ipr 6 | .iws 7 | *~ 8 | ~* 9 | *.diff 10 | *.patch 11 | *.bak 12 | .DS_Store 13 | Thumbs.db 14 | .project 15 | .*proj 16 | .svn/ 17 | *.swp 18 | *.swo 19 | *.pyc 20 | *.pyo 21 | .build 22 | node_modules 23 | .cache 24 | dist 25 | assets/**/*.css 26 | build 27 | lib 28 | coverage 29 | .vscode 30 | yarn.lock 31 | storybook/ 32 | es/ 33 | package-lock.json 34 | src/*.js 35 | src/*.map 36 | tslint.json 37 | tsconfig.test.json 38 | .prettierignore 39 | .doc/ 40 | 41 | # dumi 42 | .dumi/tmp 43 | .dumi/tmp-test 44 | .dumi/tmp-production 45 | 46 | bun.lockb -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "all", 7 | "arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | ---- 3 | 4 | ## 0.4.0 / 2019-08-06 5 | 6 | - Support `getPopupContainer` prop. 7 | 8 | ## 0.3.0 / 2019-05-14 9 | 10 | - Support `placement` prop. 11 | 12 | ## 0.2.0 / 2019-05-08 13 | 14 | - Support `rows` prop. 15 | 16 | ## 0.1.0 / 2019-05-08 17 | 18 | - Initial release. 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-present alipay.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-mentions 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![npm download][download-image]][download-url] 5 | [![build status][github-actions-image]][github-actions-url] 6 | [![Codecov][codecov-image]][codecov-url] 7 | [![bundle size][bundlephobia-image]][bundlephobia-url] 8 | [![dumi][dumi-image]][dumi-url] 9 | 10 | [npm-image]: http://img.shields.io/npm/v/rc-mentions.svg?style=flat-square 11 | [npm-url]: http://npmjs.org/package/rc-mentions 12 | [travis-image]: https://img.shields.io/travis/react-component/mentions/master?style=flat-square 13 | [travis-url]: https://travis-ci.com/react-component/mentions 14 | [github-actions-image]: https://github.com/react-component/mentions/workflows/CI/badge.svg 15 | [github-actions-url]: https://github.com/react-component/mentions/actions 16 | [codecov-image]: https://img.shields.io/codecov/c/github/react-component/mentions/master.svg?style=flat-square 17 | [codecov-url]: https://app.codecov.io/gh/react-component/mentions 18 | [david-url]: https://david-dm.org/react-component/mentions 19 | [david-image]: https://david-dm.org/react-component/mentions/status.svg?style=flat-square 20 | [david-dev-url]: https://david-dm.org/react-component/mentions?type=dev 21 | [david-dev-image]: https://david-dm.org/react-component/mentions/dev-status.svg?style=flat-square 22 | [download-image]: https://img.shields.io/npm/dm/rc-mentions.svg?style=flat-square 23 | [download-url]: https://npmjs.org/package/rc-mentions 24 | [bundlephobia-url]: https://bundlephobia.com/package/rc-mentions 25 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-mentions 26 | [dumi-url]: https://github.com/umijs/dumi 27 | [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square 28 | 29 | ## Screenshots 30 | 31 | 32 | 33 | ## Feature 34 | 35 | - support ie9,ie9+,chrome,firefox,safari 36 | 37 | ### Keyboard 38 | 39 | - Open mentions (focus input || focus and click) 40 | - KeyDown/KeyUp/Enter to navigate menu 41 | 42 | ## install 43 | 44 | [![rc-mentions](https://nodei.co/npm/rc-mentions.png)](https://npmjs.org/package/rc-mentions) 45 | 46 | ## Usage 47 | 48 | ### basic use 49 | 50 | ```js 51 | /** 52 | * inline: true 53 | */ 54 | import Mentions from '@rc-component/mentions'; 55 | // Import the default styles 56 | import './index.less'; 57 | 58 | const { Option } = Mentions; 59 | 60 | var Demo = ( 61 | 62 | 63 | 64 | 65 | 66 | ); 67 | React.render(, container); 68 | ``` 69 | 70 | **Note:** We use [index.less](https://github.com/react-component/mentions/blob/master/assets/index.less) for styling, you can convert them into css and properly reference them to the code above. 71 | 72 | ## API 73 | 74 | ### Mentions props 75 | 76 | | name | description | type | default | 77 | | ----------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | ----------- | 78 | | autoFocus | Auto get focus when component mounted | `boolean` | `false` | 79 | | defaultValue | Default value | `string` | - | 80 | | filterOption | Customize filter option logic | `false \| (input: string, option: OptionProps) => boolean` | - | 81 | | notFoundContent | Set mentions content when not match | `ReactNode` | 'Not Found' | 82 | | placement | Set popup placement | `'top' \| 'bottom'` | 'bottom' | 83 | | direction | Set popup direction | `'ltr' \| 'rtl'` | 'ltr' | 84 | | prefix | Set trigger prefix keyword | `string \| string[]` | '@' | 85 | | rows | Set row count | `number` | 1 | 86 | | split | Set split string before and after selected mention | `string` | ' ' | 87 | | silent | Used in transition phase, does not respond to keyboard enter events when equal to `true` | `boolean` | `false` | 88 | | validateSearch | Customize trigger search logic | `(text: string, props: MentionsProps) => void` | - | 89 | | value | Set value of mentions | `string` | - | 90 | | onChange | Trigger when value changed | `(text: string) => void` | - | 91 | | onKeyDown | Trigger when user hits a key | `React.KeyboardEventHandler` | - | 92 | | onKeyUp | Trigger when user releases a key | `React.KeyboardEventHandler` | - | 93 | | onSelect | Trigger when user select the option | `(option: OptionProps, prefix: string) => void` | - | 94 | | onSearch | Trigger when prefix hit | `(text: string, prefix: string) => void` | - | 95 | | onFocus | Trigger when mentions get focus | `React.FocusEventHandler` | - | 96 | | onBlur | Trigger when mentions lose focus | `React.FocusEventHandler` | - | 97 | | getPopupContainer | DOM Container for suggestions | `() => HTMLElement` | - | 98 | | autoSize | Textarea height autosize feature, can be set to `true\|false` or an object `{ minRows: 2, maxRows: 6 }` | `boolean \| object` | - | 99 | | onPressEnter | The callback function that is triggered when Enter key is pressed | `function(e)` | - | 100 | | onResize | The callback function that is triggered when textarea resize | `function({ width, height })` | - | 101 | 102 | ### Methods 103 | 104 | | name | description | 105 | | ------- | -------------------- | 106 | | focus() | Component get focus | 107 | | blur() | Component lose focus | 108 | 109 | ## Development 110 | 111 | ``` 112 | npm install 113 | npm start 114 | ``` 115 | 116 | ## Example 117 | 118 | http://localhost:9001/ 119 | 120 | online example: http://react-component.github.io/mentions/ 121 | 122 | ## Test Case 123 | 124 | ``` 125 | npm test 126 | ``` 127 | 128 | ## Coverage 129 | 130 | ``` 131 | npm run coverage 132 | ``` 133 | 134 | ## License 135 | 136 | rc-mentions is released under the MIT license. 137 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @mentionsPrefixCls: rc-mentions; 2 | 3 | .@{mentionsPrefixCls} { 4 | display: inline-block; 5 | position: relative; 6 | white-space: pre-wrap; 7 | 8 | // ================= Input Area ================= 9 | > textarea, &-measure { 10 | font-size: inherit; 11 | font-size-adjust: inherit; 12 | font-style: inherit; 13 | font-variant: inherit; 14 | font-stretch: inherit; 15 | font-weight: inherit; 16 | font-family: inherit; 17 | 18 | padding: 0; 19 | margin: 0; 20 | line-height: inherit; 21 | vertical-align: top; 22 | overflow: inherit; 23 | word-break: inherit; 24 | white-space: inherit; 25 | word-wrap: break-word; 26 | overflow-x: initial; 27 | overflow-y: auto; 28 | text-align: inherit; 29 | letter-spacing: inherit; 30 | white-space: inherit; 31 | tab-size: inherit; 32 | direction: inherit; 33 | } 34 | 35 | > textarea { 36 | border: none; 37 | width: 100%; 38 | } 39 | 40 | &-measure { 41 | position: absolute; 42 | left: 0; 43 | right: 0; 44 | top: 0; 45 | bottom: 0; 46 | pointer-events: none; 47 | // color: rgba(255, 0, 0, 0.3); 48 | color: transparent; 49 | z-index: -1; 50 | } 51 | 52 | // ================== Dropdown ================== 53 | &-dropdown { 54 | position: absolute; 55 | 56 | &-menu { 57 | list-style: none; 58 | margin: 0; 59 | padding: 0; 60 | 61 | &-item { 62 | cursor: pointer; 63 | } 64 | } 65 | } 66 | } 67 | 68 | // Customize style 69 | .@{mentionsPrefixCls} { 70 | font-size: 20px; 71 | border: 1px solid #999; 72 | border-radius: 3px; 73 | overflow: hidden; 74 | 75 | &-dropdown { 76 | border: 1px solid #999; 77 | border-radius: 3px; 78 | background: #FFF; 79 | 80 | &-menu { 81 | &-item { 82 | padding: 4px 8px; 83 | 84 | &-active { 85 | background: #e6f7ff; 86 | } 87 | 88 | &-disabled { 89 | opacity: 0.5; 90 | } 91 | } 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | peer = false -------------------------------------------------------------------------------- /docs/demo.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Usage 3 | order: 0 4 | nav: 5 | title: Demo 6 | --- 7 | 8 | # Mentions Demos 9 | 10 | ## Basic 11 | 12 | 13 | 14 | ## Dynamic 15 | 16 | 17 | 18 | ## Filter 19 | 20 | Option has `key` and filter only hit by `key` 21 | 22 | 23 | 24 | ## Multiple Prefix 25 | 26 | 27 | 28 | ## Split 29 | 30 | 31 | 32 | ## Textarea 33 | 34 | 35 | 36 | ## Debug 37 | 38 | 39 | 40 | ## Allow Clear 41 | 42 | 43 | 44 | ## On Scroll 45 | 46 | 47 | -------------------------------------------------------------------------------- /docs/examples/allowClear.tsx: -------------------------------------------------------------------------------- 1 | import Mentions from '@rc-component/mentions'; 2 | import React, { useState } from 'react'; 3 | 4 | export default function App() { 5 | const [value, setValue] = useState('hello world'); 6 | 7 | return ( 8 |
9 |

Uncontrolled

10 | 11 |

controlled

12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /docs/examples/basic.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mentions from '@rc-component/mentions'; 3 | import '../../assets/index.less'; 4 | 5 | const onSelect = (option, prefix) => { 6 | console.log('Select:', prefix, '-', option.value); 7 | }; 8 | 9 | const onFocus = () => { 10 | console.log('onFocus'); 11 | }; 12 | 13 | const onBlur = () => { 14 | console.log('onBlur'); 15 | }; 16 | 17 | export default () => ( 18 | 40 | ); 41 | -------------------------------------------------------------------------------- /docs/examples/debug.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mentions, { UnstableContext } from '@rc-component/mentions'; 3 | import '../../assets/index.less'; 4 | 5 | export default () => ( 6 | 7 | { 11 | console.log(e); 12 | }} 13 | options={[ 14 | { 15 | value: 'light', 16 | label: 'Light', 17 | }, 18 | { 19 | value: 'bamboo', 20 | label: 'Bamboo', 21 | }, 22 | { 23 | value: 'cat', 24 | label: 'Cat', 25 | }, 26 | ]} 27 | /> 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /docs/examples/dynamic.less: -------------------------------------------------------------------------------- 1 | .dynamic-option { 2 | font-size: 20px; 3 | 4 | img { 5 | height: 20px; 6 | width: 20px; 7 | vertical-align: middle; 8 | margin-right: 4px; 9 | transition: all .3s; 10 | } 11 | 12 | span { 13 | vertical-align: middle; 14 | display: inline-block; 15 | transition: all .3s; 16 | margin-right: 8px; 17 | } 18 | 19 | &.rc-mentions-dropdown-menu-item-active { 20 | img { 21 | transform: scale(1.8); 22 | } 23 | 24 | span { 25 | margin-left: 8px; 26 | margin-right: 0; 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /docs/examples/dynamic.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mentions from '@rc-component/mentions'; 3 | import '../../assets/index.less'; 4 | import './dynamic.less'; 5 | 6 | const useDebounce = (fn, delay) => { 7 | const { current } = React.useRef({ fn, timer: null }); 8 | React.useEffect(() => { 9 | current.fn = fn; 10 | }, [fn]); 11 | return React.useCallback( 12 | (...args) => { 13 | if (current.timer) { 14 | clearTimeout(current.timer); 15 | } 16 | current.timer = setTimeout(() => { 17 | current.fn(...args); 18 | }, delay); 19 | }, 20 | [delay], 21 | ); 22 | }; 23 | 24 | export default () => { 25 | const [loading, setLoading] = React.useState(false); 26 | const [users, setUsers] = React.useState([]); 27 | const searchRef = React.useRef(''); 28 | 29 | const loadGithubUsers = useDebounce((key: string) => { 30 | if (!key) { 31 | setUsers([]); 32 | return; 33 | } 34 | 35 | fetch(`https://api.github.com/search/users?q=${key}`) 36 | .then(res => res.json()) 37 | .then(({ items = [] }) => { 38 | if (searchRef.current !== key) { 39 | console.log('Out Of Date >', key, items); 40 | return; 41 | } 42 | 43 | console.log('Fetch Users >', items); 44 | setUsers(items.slice(0, 10)); 45 | setLoading(false); 46 | }); 47 | }, 800); 48 | 49 | const onSearch = (text: string) => { 50 | searchRef.current = text; 51 | setLoading(!!text); 52 | setUsers([]); 53 | console.log('Search:', text); 54 | loadGithubUsers(text); 55 | }; 56 | 57 | let options; 58 | if (loading) { 59 | options = [ 60 | { 61 | value: searchRef.current, 62 | disabled: true, 63 | label: `Searching '${searchRef.current}'...`, 64 | }, 65 | ]; 66 | } else { 67 | options = users.map(({ login, avatar_url: avatar }) => ({ 68 | key: login, 69 | value: login, 70 | className: 'dynamic-option', 71 | label: ( 72 | <> 73 | {login} 74 | {login} 75 | 76 | ), 77 | })); 78 | } 79 | 80 | return ( 81 |
82 | 88 | search: {searchRef.current} 89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /docs/examples/filter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mentions from '@rc-component/mentions'; 3 | import '../../assets/index.less'; 4 | 5 | function filterOption(input, { key }) { 6 | return key.indexOf(input) !== -1; 7 | } 8 | 9 | export default () => ( 10 | 32 | ); 33 | -------------------------------------------------------------------------------- /docs/examples/multiple-prefix.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mentions from '@rc-component/mentions'; 3 | import '../../assets/index.less'; 4 | 5 | const OPTIONS = { 6 | '@': ['light', 'bamboo', 'cat'], 7 | '#': ['123', '456', '7890'], 8 | }; 9 | 10 | export default () => { 11 | const [prefix, setPrefix] = React.useState('@'); 12 | 13 | const onSearch = (_, prefix) => { 14 | setPrefix(prefix); 15 | }; 16 | 17 | const options = OPTIONS[prefix].map(value => ({ 18 | value, 19 | key: value, 20 | label: value, 21 | })); 22 | 23 | return ( 24 |
25 | @ for string, # for number 26 | 33 |
34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /docs/examples/onScroll.less: -------------------------------------------------------------------------------- 1 | .on-scroll .rc-mentions-dropdown-menu { 2 | max-height: 250px; 3 | overflow: auto; 4 | } 5 | -------------------------------------------------------------------------------- /docs/examples/onScroll.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mentions, { UnstableContext } from '@rc-component/mentions'; 3 | import '../../assets/index.less'; 4 | import './onScroll.less'; 5 | 6 | export default () => ( 7 | 8 | ({ 14 | value: `item-${index}`, 15 | label: `item-${index}`, 16 | }))} 17 | /> 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /docs/examples/split.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mentions from '@rc-component/mentions'; 3 | import '../../assets/index.less'; 4 | 5 | const { Option } = Mentions; 6 | 7 | function validateSearch(text) { 8 | console.log('~~>', text); 9 | return text.length <= 3; 10 | } 11 | 12 | export default () => ( 13 |
14 |

Customize Split Logic

15 |

Only validate string length less than 3

16 | 22 | 23 | 24 | 25 | 26 |
27 | ); 28 | -------------------------------------------------------------------------------- /docs/examples/textarea.less: -------------------------------------------------------------------------------- 1 | .motion { 2 | 3 | .effect() { 4 | animation-duration: 0.3s; 5 | animation-fill-mode: both; 6 | } 7 | 8 | &-zoom-enter,&-zoom-appear { 9 | opacity: 0; 10 | .effect(); 11 | animation-timing-function: cubic-bezier(0.18, 0.89, 0.32, 1.28); 12 | animation-play-state: paused; 13 | } 14 | 15 | &-zoom-leave { 16 | .effect(); 17 | animation-timing-function: cubic-bezier(0.6, -0.3, 0.74, 0.05); 18 | animation-play-state: paused; 19 | } 20 | 21 | &-zoom-enter&-zoom-enter-active, &-zoom-appear&-zoom-appear-active { 22 | animation-name: rcTriggerZoomIn; 23 | animation-play-state: running; 24 | } 25 | 26 | &-zoom-leave&-zoom-leave-active { 27 | animation-name: rcTriggerZoomOut; 28 | animation-play-state: running; 29 | } 30 | 31 | @keyframes rcTriggerZoomIn { 32 | 0% { 33 | opacity: 0; 34 | transform-origin: 50% 50%; 35 | transform: scale(0, 0); 36 | } 37 | 100% { 38 | opacity: 1; 39 | transform-origin: 50% 50%; 40 | transform: scale(1, 1); 41 | } 42 | } 43 | @keyframes rcTriggerZoomOut { 44 | 0% { 45 | opacity: 1; 46 | transform-origin: 50% 50%; 47 | transform: scale(1, 1); 48 | } 49 | 100% { 50 | opacity: 0; 51 | transform-origin: 50% 50%; 52 | transform: scale(0, 0); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /docs/examples/textarea.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Mentions from '@rc-component/mentions'; 3 | import '../../assets/index.less'; 4 | import './textarea.less'; 5 | 6 | export default () => ( 7 |
8 | 26 | 27 | 45 | 46 |
47 | 66 |
67 | 68 |
69 | 88 |
89 | 90 |
91 | 110 |
111 |
112 | ); 113 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: rc-mentions 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./src/'); 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFiles: ['./tests/setup.ts'], 3 | setupFilesAfterEnv: ['./tests/setupFilesAfterEnv.ts'], 4 | collectCoverageFrom: ['./src/**/*.{ts,tsx}'], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/mentions", 3 | "version": "1.2.0", 4 | "description": "React Mentions", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-mentions", 9 | "mentions" 10 | ], 11 | "homepage": "http://github.com/react-component/mentions", 12 | "bugs": { 13 | "url": "http://github.com/react-component/mentions/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git@github.com:react-component/mentions.git" 18 | }, 19 | "license": "MIT", 20 | "main": "./lib/index", 21 | "module": "./es/index", 22 | "files": [ 23 | "assets/*.css", 24 | "es", 25 | "dist", 26 | "lib" 27 | ], 28 | "scripts": { 29 | "compile": "father build", 30 | "coverage": "rc-test --coverage", 31 | "docs:build": "dumi build", 32 | "docs:deploy": "gh-pages -d .doc", 33 | "gh-pages": "npm run docs:build && npm run docs:deploy", 34 | "lint": "eslint src/ --ext .tsx,.ts", 35 | "prepare": "husky install", 36 | "prepublishOnly": "npm run compile && rc-np", 37 | "postpublish": "npm run gh-pages", 38 | "start": "dumi dev", 39 | "test": "rc-test" 40 | }, 41 | "lint-staged": { 42 | "**/*.{js,jsx,tsx,ts,md,json}": [ 43 | "prettier --write", 44 | "git add" 45 | ] 46 | }, 47 | "dependencies": { 48 | "@rc-component/input": "~1.0.1", 49 | "@rc-component/menu": "~1.1.0", 50 | "@rc-component/textarea": "~1.0.0", 51 | "@rc-component/trigger": "^3.0.0", 52 | "@rc-component/util": "^1.2.0", 53 | "classnames": "^2.2.6" 54 | }, 55 | "devDependencies": { 56 | "@rc-component/father-plugin": "^2.0.2", 57 | "@rc-component/np": "^1.0.3", 58 | "@testing-library/jest-dom": "^6.4.6", 59 | "@testing-library/react": "^16.0.0", 60 | "@types/classnames": "^2.2.6", 61 | "@types/react": "^18.0.8", 62 | "@types/react-dom": "^18.0.3", 63 | "@types/warning": "^3.0.0", 64 | "@umijs/fabric": "^3.0.0", 65 | "dumi": "^2.0.18", 66 | "eslint": "^8.0.0", 67 | "eslint-plugin-jest": "^28.11.0", 68 | "eslint-plugin-unicorn": "^56.0.1", 69 | "father": "^4.0.0", 70 | "gh-pages": "^5.0.0", 71 | "husky": "^9.1.6", 72 | "lint-staged": "^15.2.7", 73 | "prettier": "^3.3.2", 74 | "rc-test": "^7.0.14", 75 | "react": "^18.0.0", 76 | "react-dom": "^18.0.0", 77 | "typescript": "^5.0.4" 78 | }, 79 | "peerDependencies": { 80 | "react": ">=16.9.0", 81 | "react-dom": ">=16.9.0" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/DropdownMenu.tsx: -------------------------------------------------------------------------------- 1 | import Menu, { MenuItem, MenuRef } from '@rc-component/menu'; 2 | import React, { useEffect, useRef } from 'react'; 3 | import MentionsContext from './MentionsContext'; 4 | import type { DataDrivenOptionProps } from './Mentions'; 5 | export interface DropdownMenuProps { 6 | prefixCls?: string; 7 | options: DataDrivenOptionProps[]; 8 | } 9 | 10 | /** 11 | * We only use Menu to display the candidate. 12 | * The focus is controlled by textarea to make accessibility easy. 13 | */ 14 | function DropdownMenu(props: DropdownMenuProps) { 15 | const { 16 | notFoundContent, 17 | activeIndex, 18 | setActiveIndex, 19 | selectOption, 20 | onFocus, 21 | onBlur, 22 | onScroll, 23 | } = React.useContext(MentionsContext); 24 | 25 | const { prefixCls, options } = props; 26 | const activeOption = options[activeIndex] || {}; 27 | const menuRef = useRef(null); 28 | 29 | // Monitor the changes in ActiveIndex and scroll to the visible area if there are any changes 30 | useEffect(() => { 31 | if (activeIndex === -1 || !menuRef.current) { 32 | return; 33 | } 34 | 35 | const activeItem = menuRef.current?.findItem?.({ key: activeOption.key }); 36 | if (activeItem) { 37 | activeItem.scrollIntoView({ 38 | block: 'nearest', 39 | inline: 'nearest', 40 | }); 41 | } 42 | }, [activeIndex, activeOption.key]); 43 | 44 | return ( 45 | { 50 | const option = options.find(({ key: optionKey }) => optionKey === key); 51 | selectOption(option); 52 | }} 53 | onFocus={onFocus} 54 | onBlur={onBlur} 55 | onScroll={onScroll} 56 | > 57 | {options.map((option, index) => { 58 | const { key, disabled, className, style, label } = option; 59 | return ( 60 | { 66 | setActiveIndex(index); 67 | }} 68 | > 69 | {label} 70 | 71 | ); 72 | })} 73 | 74 | {!options.length && {notFoundContent}} 75 | 76 | ); 77 | } 78 | 79 | export default DropdownMenu; 80 | -------------------------------------------------------------------------------- /src/KeywordTrigger.tsx: -------------------------------------------------------------------------------- 1 | import Trigger from '@rc-component/trigger'; 2 | import type { FC } from 'react'; 3 | import * as React from 'react'; 4 | import { useMemo } from 'react'; 5 | import DropdownMenu from './DropdownMenu'; 6 | import type { DataDrivenOptionProps, Direction, Placement } from './Mentions'; 7 | 8 | const BUILT_IN_PLACEMENTS = { 9 | bottomRight: { 10 | points: ['tl', 'br'], 11 | offset: [0, 4], 12 | overflow: { 13 | adjustX: 1, 14 | adjustY: 1, 15 | }, 16 | }, 17 | bottomLeft: { 18 | points: ['tr', 'bl'], 19 | offset: [0, 4], 20 | overflow: { 21 | adjustX: 1, 22 | adjustY: 1, 23 | }, 24 | }, 25 | topRight: { 26 | points: ['bl', 'tr'], 27 | offset: [0, -4], 28 | overflow: { 29 | adjustX: 1, 30 | adjustY: 1, 31 | }, 32 | }, 33 | topLeft: { 34 | points: ['br', 'tl'], 35 | offset: [0, -4], 36 | overflow: { 37 | adjustX: 1, 38 | adjustY: 1, 39 | }, 40 | }, 41 | }; 42 | 43 | interface KeywordTriggerProps { 44 | loading?: boolean; 45 | options: DataDrivenOptionProps[]; 46 | prefixCls?: string; 47 | placement?: Placement; 48 | direction?: Direction; 49 | visible?: boolean; 50 | transitionName?: string; 51 | children?: React.ReactElement; 52 | getPopupContainer?: () => HTMLElement; 53 | popupClassName?: string; 54 | popupStyle?: React.CSSProperties; 55 | } 56 | 57 | const KeywordTrigger: FC = props => { 58 | const { 59 | prefixCls, 60 | options, 61 | children, 62 | visible, 63 | transitionName, 64 | getPopupContainer, 65 | popupClassName, 66 | popupStyle, 67 | direction, 68 | placement, 69 | } = props; 70 | 71 | const dropdownPrefix = `${prefixCls}-dropdown`; 72 | 73 | const dropdownElement = ( 74 | 75 | ); 76 | 77 | const dropdownPlacement = useMemo(() => { 78 | let popupPlacement; 79 | if (direction === 'rtl') { 80 | popupPlacement = placement === 'top' ? 'topLeft' : 'bottomLeft'; 81 | } else { 82 | popupPlacement = placement === 'top' ? 'topRight' : 'bottomRight'; 83 | } 84 | return popupPlacement; 85 | }, [direction, placement]); 86 | 87 | return ( 88 | 99 | {children} 100 | 101 | ); 102 | }; 103 | 104 | export default KeywordTrigger; 105 | -------------------------------------------------------------------------------- /src/Mentions.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { BaseInput } from '@rc-component/input'; 3 | import type { HolderRef } from '@rc-component/input/lib/BaseInput'; 4 | import type { CommonInputProps } from '@rc-component/input/lib/interface'; 5 | import type { TextAreaProps, TextAreaRef } from '@rc-component/textarea'; 6 | import TextArea from '@rc-component/textarea'; 7 | import toArray from '@rc-component/util/lib/Children/toArray'; 8 | import useMergedState from '@rc-component/util/lib/hooks/useMergedState'; 9 | import KeyCode from '@rc-component/util/lib/KeyCode'; 10 | import React, { 11 | forwardRef, 12 | useContext, 13 | useEffect, 14 | useImperativeHandle, 15 | useMemo, 16 | useRef, 17 | useState, 18 | } from 'react'; 19 | import useEffectState from './hooks/useEffectState'; 20 | import KeywordTrigger from './KeywordTrigger'; 21 | import MentionsContext from './MentionsContext'; 22 | import type { OptionProps } from './Option'; 23 | import Option from './Option'; 24 | import { 25 | filterOption as defaultFilterOption, 26 | validateSearch as defaultValidateSearch, 27 | getBeforeSelectionText, 28 | getLastMeasureIndex, 29 | replaceWithMeasure, 30 | setInputSelection, 31 | } from './util'; 32 | import { UnstableContext } from './context'; 33 | 34 | type BaseTextareaAttrs = Omit< 35 | TextAreaProps, 36 | 'prefix' | 'onChange' | 'onSelect' | 'showCount' | 'classNames' 37 | >; 38 | 39 | export type Placement = 'top' | 'bottom'; 40 | export type Direction = 'ltr' | 'rtl'; 41 | 42 | export interface DataDrivenOptionProps extends Omit { 43 | label?: React.ReactNode; 44 | } 45 | 46 | export interface MentionsProps extends BaseTextareaAttrs { 47 | autoFocus?: boolean; 48 | className?: string; 49 | defaultValue?: string; 50 | notFoundContent?: React.ReactNode; 51 | split?: string; 52 | style?: React.CSSProperties; 53 | transitionName?: string; 54 | placement?: Placement; 55 | direction?: Direction; 56 | prefix?: string | string[]; 57 | prefixCls?: string; 58 | value?: string; 59 | silent?: boolean; 60 | filterOption?: false | typeof defaultFilterOption; 61 | validateSearch?: typeof defaultValidateSearch; 62 | onChange?: (text: string) => void; 63 | onSelect?: (option: OptionProps, prefix: string) => void; 64 | onSearch?: (text: string, prefix: string) => void; 65 | onFocus?: React.FocusEventHandler; 66 | onBlur?: React.FocusEventHandler; 67 | getPopupContainer?: () => HTMLElement; 68 | popupClassName?: string; 69 | children?: React.ReactNode; 70 | options?: DataDrivenOptionProps[]; 71 | classNames?: CommonInputProps['classNames'] & { 72 | mentions?: string; 73 | textarea?: string; 74 | popup?: string; 75 | }; 76 | styles?: { 77 | textarea?: React.CSSProperties; 78 | popup?: React.CSSProperties; 79 | }; 80 | onPopupScroll?: (event: React.UIEvent) => void; 81 | } 82 | 83 | export interface MentionsRef { 84 | focus: VoidFunction; 85 | blur: VoidFunction; 86 | 87 | /** @deprecated It may not work as expected */ 88 | textarea: HTMLTextAreaElement | null; 89 | 90 | nativeElement: HTMLElement; 91 | } 92 | 93 | const InternalMentions = forwardRef( 94 | (props, ref) => { 95 | const { 96 | // Style 97 | prefixCls, 98 | className, 99 | style, 100 | classNames: mentionClassNames, 101 | styles, 102 | 103 | // Misc 104 | prefix = '@', 105 | split = ' ', 106 | notFoundContent = 'Not Found', 107 | value, 108 | defaultValue, 109 | children, 110 | options, 111 | allowClear, 112 | silent, 113 | 114 | // Events 115 | validateSearch = defaultValidateSearch, 116 | filterOption = defaultFilterOption, 117 | onChange, 118 | onKeyDown, 119 | onKeyUp, 120 | onPressEnter, 121 | onSearch, 122 | onSelect, 123 | 124 | onFocus, 125 | onBlur, 126 | 127 | // Dropdown 128 | transitionName, 129 | placement, 130 | direction, 131 | getPopupContainer, 132 | popupClassName, 133 | 134 | rows = 1, 135 | 136 | // Fix Warning: Received `false` for a non-boolean attribute `visible`. 137 | // https://github.com/ant-design/ant-design/blob/df933e94efc8f376003bbdc658d64b64a0e53495/components/mentions/demo/render-panel.tsx 138 | // @ts-expect-error 139 | visible, 140 | onPopupScroll, 141 | 142 | // Rest 143 | ...restProps 144 | } = props; 145 | 146 | const mergedPrefix = useMemo( 147 | () => (Array.isArray(prefix) ? prefix : [prefix]), 148 | [prefix], 149 | ); 150 | 151 | // =============================== Refs =============================== 152 | const containerRef = useRef(null); 153 | const textareaRef = useRef(null); 154 | const measureRef = useRef(null); 155 | 156 | const getTextArea = () => textareaRef.current?.resizableTextArea?.textArea; 157 | 158 | React.useImperativeHandle(ref, () => ({ 159 | focus: () => textareaRef.current?.focus(), 160 | blur: () => textareaRef.current?.blur(), 161 | textarea: textareaRef.current?.resizableTextArea?.textArea, 162 | nativeElement: containerRef.current, 163 | })); 164 | 165 | // ============================== State =============================== 166 | const [measuring, setMeasuring] = useState(false); 167 | const [measureText, setMeasureText] = useState(''); 168 | const [measurePrefix, setMeasurePrefix] = useState(''); 169 | const [measureLocation, setMeasureLocation] = useState(0); 170 | const [activeIndex, setActiveIndex] = useState(0); 171 | const [isFocus, setIsFocus] = useState(false); 172 | 173 | // ============================== Value =============================== 174 | const [mergedValue, setMergedValue] = useMergedState('', { 175 | defaultValue, 176 | value: value, 177 | }); 178 | 179 | // =============================== Open =============================== 180 | const { open } = useContext(UnstableContext); 181 | 182 | useEffect(() => { 183 | // Sync measure div top with textarea for rc-trigger usage 184 | if (measuring && measureRef.current) { 185 | measureRef.current.scrollTop = getTextArea().scrollTop; 186 | } 187 | }, [measuring]); 188 | 189 | const [ 190 | mergedMeasuring, 191 | mergedMeasureText, 192 | mergedMeasurePrefix, 193 | mergedMeasureLocation, 194 | ] = React.useMemo< 195 | [ 196 | typeof measuring, 197 | typeof measureText, 198 | typeof measurePrefix, 199 | typeof measureLocation, 200 | ] 201 | >(() => { 202 | if (open) { 203 | for (let i = 0; i < mergedPrefix.length; i += 1) { 204 | const curPrefix = mergedPrefix[i]; 205 | const index = mergedValue.lastIndexOf(curPrefix); 206 | if (index >= 0) { 207 | return [true, '', curPrefix, index]; 208 | } 209 | } 210 | } 211 | 212 | return [measuring, measureText, measurePrefix, measureLocation]; 213 | }, [ 214 | open, 215 | measuring, 216 | mergedPrefix, 217 | mergedValue, 218 | measureText, 219 | measurePrefix, 220 | measureLocation, 221 | ]); 222 | 223 | // ============================== Option ============================== 224 | const getOptions = React.useCallback( 225 | (targetMeasureText: string) => { 226 | let list; 227 | if (options && options.length > 0) { 228 | list = options.map(item => ({ 229 | ...item, 230 | key: item?.key ?? item.value, 231 | })); 232 | } else { 233 | list = toArray(children).map( 234 | ({ 235 | props: optionProps, 236 | key, 237 | }: { 238 | props: OptionProps; 239 | key: React.Key; 240 | }) => ({ 241 | ...optionProps, 242 | label: optionProps.children, 243 | key: (key || optionProps.value) as string, 244 | }), 245 | ); 246 | } 247 | 248 | return list.filter((option: OptionProps) => { 249 | /** Return all result if `filterOption` is false. */ 250 | if (filterOption === false) { 251 | return true; 252 | } 253 | return filterOption(targetMeasureText, option); 254 | }); 255 | }, 256 | [children, options, filterOption], 257 | ); 258 | 259 | const mergedOptions = React.useMemo( 260 | () => getOptions(mergedMeasureText), 261 | [getOptions, mergedMeasureText], 262 | ); 263 | 264 | // ============================= Measure ============================== 265 | // Mark that we will reset input selection to target position when user select option 266 | const onSelectionEffect = useEffectState(); 267 | 268 | const startMeasure = ( 269 | nextMeasureText: string, 270 | nextMeasurePrefix: string, 271 | nextMeasureLocation: number, 272 | ) => { 273 | setMeasuring(true); 274 | setMeasureText(nextMeasureText); 275 | setMeasurePrefix(nextMeasurePrefix); 276 | setMeasureLocation(nextMeasureLocation); 277 | setActiveIndex(0); 278 | }; 279 | 280 | const stopMeasure = (callback?: VoidFunction) => { 281 | setMeasuring(false); 282 | setMeasureLocation(0); 283 | setMeasureText(''); 284 | onSelectionEffect(callback); 285 | }; 286 | 287 | // ============================== Change ============================== 288 | const triggerChange = (nextValue: string) => { 289 | setMergedValue(nextValue); 290 | onChange?.(nextValue); 291 | }; 292 | 293 | const onInternalChange: React.ChangeEventHandler = ({ 294 | target: { value: nextValue }, 295 | }) => { 296 | triggerChange(nextValue); 297 | }; 298 | 299 | const selectOption = (option: OptionProps) => { 300 | const { value: mentionValue = '' } = option; 301 | const { text, selectionLocation } = replaceWithMeasure(mergedValue, { 302 | measureLocation: mergedMeasureLocation, 303 | targetText: mentionValue, 304 | prefix: mergedMeasurePrefix, 305 | selectionStart: getTextArea()?.selectionStart, 306 | split, 307 | }); 308 | triggerChange(text); 309 | stopMeasure(() => { 310 | // We need restore the selection position 311 | setInputSelection(getTextArea(), selectionLocation); 312 | }); 313 | 314 | onSelect?.(option, mergedMeasurePrefix); 315 | }; 316 | 317 | // ============================= KeyEvent ============================= 318 | // Check if hit the measure keyword 319 | const onInternalKeyDown: React.KeyboardEventHandler< 320 | HTMLTextAreaElement 321 | > = event => { 322 | const { which } = event; 323 | 324 | onKeyDown?.(event); 325 | 326 | // Skip if not measuring 327 | if (!mergedMeasuring) { 328 | return; 329 | } 330 | 331 | if (which === KeyCode.UP || which === KeyCode.DOWN) { 332 | // Control arrow function 333 | const optionLen = mergedOptions.length; 334 | const offset = which === KeyCode.UP ? -1 : 1; 335 | const newActiveIndex = (activeIndex + offset + optionLen) % optionLen; 336 | setActiveIndex(newActiveIndex); 337 | event.preventDefault(); 338 | } else if (which === KeyCode.ESC) { 339 | stopMeasure(); 340 | } else if (which === KeyCode.ENTER) { 341 | // Measure hit 342 | event.preventDefault(); 343 | // loading skip 344 | if (silent) { 345 | return; 346 | } 347 | 348 | if (!mergedOptions.length) { 349 | stopMeasure(); 350 | return; 351 | } 352 | const option = mergedOptions[activeIndex]; 353 | selectOption(option); 354 | } 355 | }; 356 | 357 | /** 358 | * When to start measure: 359 | * 1. When user press `prefix` 360 | * 2. When measureText !== prevMeasureText 361 | * - If measure hit 362 | * - If measuring 363 | * 364 | * When to stop measure: 365 | * 1. Selection is out of range 366 | * 2. Contains `space` 367 | * 3. ESC or select one 368 | */ 369 | const onInternalKeyUp: React.KeyboardEventHandler< 370 | HTMLTextAreaElement 371 | > = event => { 372 | const { key, which } = event; 373 | const target = event.target as HTMLTextAreaElement; 374 | const selectionStartText = getBeforeSelectionText(target); 375 | const { location: measureIndex, prefix: nextMeasurePrefix } = 376 | getLastMeasureIndex(selectionStartText, mergedPrefix); 377 | 378 | // If the client implements an onKeyUp handler, call it 379 | onKeyUp?.(event); 380 | 381 | // Skip if match the white key list 382 | if ( 383 | [KeyCode.ESC, KeyCode.UP, KeyCode.DOWN, KeyCode.ENTER].indexOf( 384 | which, 385 | ) !== -1 386 | ) { 387 | return; 388 | } 389 | 390 | if (measureIndex !== -1) { 391 | const nextMeasureText = selectionStartText.slice( 392 | measureIndex + nextMeasurePrefix.length, 393 | ); 394 | const validateMeasure: boolean = validateSearch(nextMeasureText, split); 395 | const matchOption = !!getOptions(nextMeasureText).length; 396 | 397 | if (validateMeasure) { 398 | // adding AltGraph also fort azert keyboard 399 | if ( 400 | key === nextMeasurePrefix || 401 | key === 'Shift' || 402 | which === KeyCode.ALT || 403 | key === 'AltGraph' || 404 | mergedMeasuring || 405 | (nextMeasureText !== mergedMeasureText && matchOption) 406 | ) { 407 | startMeasure(nextMeasureText, nextMeasurePrefix, measureIndex); 408 | } 409 | } else if (mergedMeasuring) { 410 | // Stop if measureText is invalidate 411 | stopMeasure(); 412 | } 413 | 414 | /** 415 | * We will trigger `onSearch` to developer since they may use for async update. 416 | * If met `space` means user finished searching. 417 | */ 418 | if (onSearch && validateMeasure) { 419 | onSearch(nextMeasureText, nextMeasurePrefix); 420 | } 421 | } else if (mergedMeasuring) { 422 | stopMeasure(); 423 | } 424 | }; 425 | 426 | const onInternalPressEnter: React.KeyboardEventHandler< 427 | HTMLTextAreaElement 428 | > = event => { 429 | if (!mergedMeasuring && onPressEnter) { 430 | onPressEnter(event); 431 | } 432 | }; 433 | 434 | // ============================ Focus Blur ============================ 435 | const focusRef = useRef(); 436 | 437 | const onInternalFocus = (event?: React.FocusEvent) => { 438 | window.clearTimeout(focusRef.current); 439 | if (!isFocus && event && onFocus) { 440 | onFocus(event); 441 | } 442 | setIsFocus(true); 443 | }; 444 | 445 | const onInternalBlur = (event?: React.FocusEvent) => { 446 | focusRef.current = window.setTimeout(() => { 447 | setIsFocus(false); 448 | stopMeasure(); 449 | onBlur?.(event); 450 | }, 0); 451 | }; 452 | 453 | const onDropdownFocus = () => { 454 | onInternalFocus(); 455 | }; 456 | 457 | const onDropdownBlur = () => { 458 | onInternalBlur(); 459 | }; 460 | 461 | // ============================== Scroll =============================== 462 | const onInternalPopupScroll: React.UIEventHandler< 463 | HTMLDivElement 464 | > = event => { 465 | onPopupScroll?.(event); 466 | }; 467 | 468 | // ============================== Render ============================== 469 | 470 | return ( 471 |
476 |