├── .dumirc.ts
├── .editorconfig
├── .eslintrc.js
├── .fatherrc.js
├── .github
└── workflows
│ ├── codeql.yml
│ └── react-component-ci.yml
├── .gitignore
├── .npmignore
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── assets
└── index.less
├── docs
├── demo
│ ├── allowClear.tsx
│ ├── autoSize.tsx
│ ├── showCount.tsx
│ ├── simple.md
│ └── simple.tsx
└── index.md
├── jest.config.js
├── package.json
├── src
├── ResizableTextArea.tsx
├── TextArea.tsx
├── calculateNodeHeight.tsx
├── index.tsx
└── interface.ts
├── tests
├── ResizableTextArea.test.tsx
├── __snapshots__
│ ├── allowClear.test.tsx.snap
│ └── index.spec.tsx.snap
├── allowClear.test.tsx
├── count.test.tsx
├── focus.test.tsx
├── index.spec.tsx
├── setup.ts
├── setupFilesAfterEnv.ts
├── showCount.test.tsx
└── utils.ts
└── tsconfig.json
/.dumirc.ts:
--------------------------------------------------------------------------------
1 | // more config: https://d.umijs.org/config
2 | import { defineConfig } from 'dumi';
3 |
4 | const name = 'textarea';
5 |
6 | export default defineConfig({
7 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'],
8 | themeConfig: {
9 | name: 'rc-textarea',
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,md}]
6 | end_of_line = lf
7 | insert_final_newline = true
8 | indent_style = space
9 | indent_size = 2
10 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | const base = require('@umijs/fabric/dist/eslint');
2 |
3 | module.exports = {
4 | ...base,
5 | rules: {
6 | ...base.rules,
7 | 'no-template-curly-in-string': 0,
8 | 'prefer-promise-reject-errors': 0,
9 | 'react/no-array-index-key': 0,
10 | 'react/sort-comp': 0,
11 | '@typescript-eslint/no-explicit-any': 0,
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/.fatherrc.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'father';
2 |
3 | export default defineConfig({
4 | plugins: ['@rc-component/father-plugin'],
5 | });
6 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | name: "CodeQL"
2 |
3 | on:
4 | push:
5 | branches: [ "master" ]
6 | pull_request:
7 | branches: [ "master" ]
8 | schedule:
9 | - cron: "36 10 * * 5"
10 |
11 | jobs:
12 | analyze:
13 | name: Analyze
14 | runs-on: ubuntu-latest
15 | permissions:
16 | actions: read
17 | contents: read
18 | security-events: write
19 |
20 | strategy:
21 | fail-fast: false
22 | matrix:
23 | language: [ javascript ]
24 |
25 | steps:
26 | - name: Checkout
27 | uses: actions/checkout@v3
28 |
29 | - name: Initialize CodeQL
30 | uses: github/codeql-action/init@v2
31 | with:
32 | languages: ${{ matrix.language }}
33 | queries: +security-and-quality
34 |
35 | - name: Autobuild
36 | uses: github/codeql-action/autobuild@v2
37 |
38 | - name: Perform CodeQL Analysis
39 | uses: github/codeql-action/analyze@v2
40 | with:
41 | category: "/language:${{ matrix.language }}"
42 |
--------------------------------------------------------------------------------
/.github/workflows/react-component-ci.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
7 |
--------------------------------------------------------------------------------
/.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 | assets/**/*.css
25 | build
26 | lib
27 | es
28 | yarn.lock
29 | package-lock.json
30 | coverage/
31 | .doc
32 | # umi
33 | .dumi/tmp
34 | .dumi/tmp-test
35 | .dumi/tmp-production
36 | .env.local
37 |
38 | bun.lockb
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | build/
2 | *.cfg
3 | nohup.out
4 | *.iml
5 | .idea/
6 | .ipr
7 | .iws
8 | *~
9 | ~*
10 | *.diff
11 | *.log
12 | *.patch
13 | *.bak
14 | .DS_Store
15 | Thumbs.db
16 | .project
17 | .*proj
18 | .svn/
19 | *.swp
20 | out/
21 | .build
22 | node_modules
23 | .cache
24 | examples
25 | tests
26 | src
27 | /index.js
28 | .*
29 | assets/**/*.less
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .storybook
2 | node_modules
3 | lib
4 | es
5 | .cache
6 | package.json
7 | package-lock.json
8 | public
9 | .site
10 | _site
11 | .umi
12 | .doc
13 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "lf",
3 | "semi": true,
4 | "singleQuote": true,
5 | "tabWidth": 2,
6 | "trailingComma": "all"
7 | }
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.1.0
2 |
3 | - First release.
4 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019-present afc163
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-textarea
2 |
3 | [![NPM version][npm-image]][npm-url] [](https://github.com/umijs/dumi) [![npm download][download-image]][download-url] [![build status][github-actions-image]][github-actions-url] [![Codecov][codecov-image]][codecov-url] [![bundle size][bundlephobia-image]][bundlephobia-url]
4 |
5 | [npm-image]: http://img.shields.io/npm/v/rc-textarea.svg?style=flat-square
6 | [npm-url]: http://npmjs.org/package/rc-textarea
7 | [github-actions-image]: https://github.com/react-component/textarea/workflows/CI/badge.svg
8 | [github-actions-url]: https://github.com/react-component/textarea/actions
9 | [codecov-image]: https://img.shields.io/codecov/c/github/react-component/textarea/master.svg?style=flat-square
10 | [codecov-url]: https://codecov.io/gh/react-component/textarea/branch/master
11 | [download-image]: https://img.shields.io/npm/dm/rc-textarea.svg?style=flat-square
12 | [download-url]: https://npmjs.org/package/rc-textarea
13 | [bundlephobia-url]: https://bundlephobia.com/result?p=rc-textarea
14 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-textarea
15 |
16 | Pretty Textarea react component used in [ant.design](https://ant.design).
17 |
18 | ## Live Demo
19 |
20 | https://react-component.github.io/textarea/
21 |
22 | ## Install
23 |
24 | [](https://npmjs.org/package/rc-textarea)
25 |
26 | ## Usage
27 |
28 | ```js
29 | import Textarea from 'rc-textarea';
30 | import { render } from 'react-dom';
31 |
32 | render(, mountNode);
33 | ```
34 |
35 | ## API
36 |
37 | | Property | Type | Default | Description |
38 | | ------------ | --------------------------- | ----------- | ---------------------------------------------------------------------------------------------- |
39 | | prefixCls | string | rc-textarea | |
40 | | className | string | '' | additional class name of textarea |
41 | | style | React.CSSProperties | - | style properties of textarea |
42 | | autoSize | boolean \| object | - | Height autosize feature, can be set to `true\|false` or an object `{ minRows: 2, maxRows: 6 }` |
43 | | onPressEnter | function(e) | - | The callback function that is triggered when Enter key is pressed |
44 | | onResize | function({ width, height }) | - | The callback function that is triggered when resize |
45 |
46 | ## Development
47 |
48 | ```
49 | npm install
50 | npm start
51 | ```
52 |
53 | ## License
54 |
55 | rc-textarea is released under the MIT license.
56 |
--------------------------------------------------------------------------------
/assets/index.less:
--------------------------------------------------------------------------------
1 | @textarea-prefix-cls: rc-textarea;
2 |
3 | .rc-textarea-affix-wrapper {
4 | display: inline-block;
5 | box-sizing: border-box;
6 |
7 | textarea {
8 | box-sizing: border-box;
9 | width: 100%;
10 | height: 100%;
11 | padding: 0;
12 | border: 1px solid #1677ff;
13 | }
14 | }
15 |
16 | .@{textarea-prefix-cls}-out-of-range {
17 | &,
18 | & textarea {
19 | color: red;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/docs/demo/allowClear.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import Textarea from '@rc-component/textarea';
3 | import React, { useState } from 'react';
4 |
5 | export default function App() {
6 | const [value, setValue] = useState('hello\nworld');
7 |
8 | const onChange = (e) => {
9 | const {
10 | target: { value: currentValue },
11 | } = e;
12 | setValue(currentValue);
13 | };
14 |
15 | return (
16 |
17 |
Uncontrolled
18 |
19 |
controlled
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/docs/demo/autoSize.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import React, { useState } from 'react';
3 | import Textarea from '@rc-component/textarea';
4 |
5 | export default function App() {
6 | const [value, setValue] = useState('hello\nworld');
7 |
8 | const onChange = (e) => {
9 | const {
10 | target: { value: currentValue },
11 | } = e;
12 | setValue(currentValue);
13 | };
14 |
15 | const onResize = ({ width, height }) => {
16 | console.log(`size is changed, width:${width} height:${height}`);
17 | };
18 |
19 | return (
20 |
21 |
when set to true
22 |
28 |
when set to object of minRows and maxRows
29 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/docs/demo/showCount.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import Textarea from '@rc-component/textarea';
3 | import React, { useState } from 'react';
4 | import '../../assets/index.less';
5 |
6 | export default function App() {
7 | const [value, setValue] = useState('hello\nworld');
8 |
9 | const onChange = (e) => {
10 | const {
11 | target: { value: currentValue },
12 | } = e;
13 | setValue(currentValue);
14 | };
15 |
16 | return (
17 |
18 |
Uncontrolled
19 |
20 |
controlled
21 |
22 |
with height
23 |
29 |
30 |
Count.exceedFormatter
31 |
38 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/docs/demo/simple.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Usage
3 | order: 0
4 | nav:
5 | title: Demo
6 | ---
7 |
8 | ## Simple
9 |
10 |
11 |
12 | ## Auto Size
13 |
14 |
15 |
16 | ## Allow Clear
17 |
18 |
19 |
20 | ## Show Count
21 |
22 |
23 |
--------------------------------------------------------------------------------
/docs/demo/simple.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import React, { useState } from 'react';
3 | import type { TextAreaProps } from '@rc-component/textarea';
4 | import Textarea from '@rc-component/textarea';
5 |
6 | export default function App() {
7 | const [value, setValue] = useState('');
8 |
9 | const onChange = (e) => {
10 | const {
11 | target: { value: currentValue },
12 | } = e;
13 | console.log(e.target.value);
14 | setValue(currentValue);
15 | };
16 |
17 | const onResize: TextAreaProps['onResize'] = ({ width, height }) => {
18 | console.log(`size is changed, width:${width} height:${height}`);
19 | };
20 |
21 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
22 | const onPressEnter = (e) => {
23 | console.log(`enter key is pressed`);
24 | };
25 |
26 | return (
27 |
28 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: rc-textarea
3 | ---
4 |
5 |
6 |
--------------------------------------------------------------------------------
/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/textarea",
3 | "version": "1.0.0",
4 | "description": "Pretty Textarea react component used in used in ant.design",
5 | "keywords": [
6 | "react",
7 | "react-component",
8 | "react-textarea",
9 | "textarea",
10 | "antd",
11 | "ant-design"
12 | ],
13 | "main": "./lib/index",
14 | "module": "./es/index",
15 | "files": [
16 | "assets/*.css",
17 | "assets/*.less",
18 | "es",
19 | "lib",
20 | "dist"
21 | ],
22 | "homepage": "https://react-component.github.io/textarea",
23 | "repository": {
24 | "type": "git",
25 | "url": "git@github.com:react-component/textarea.git"
26 | },
27 | "bugs": {
28 | "url": "http://github.com/react-component/textarea/issues"
29 | },
30 | "license": "MIT",
31 | "scripts": {
32 | "start": "dumi dev",
33 | "docs:build": "dumi build",
34 | "docs:deploy": "gh-pages -d .doc",
35 | "compile": "father build && lessc assets/index.less assets/index.css",
36 | "gh-pages": "npm run docs:build && npm run docs:deploy",
37 | "prepublishOnly": "npm run compile && rc-np",
38 | "postpublish": "npm run gh-pages",
39 | "lint": "eslint src/ --ext .ts,.tsx,.jsx,.js,.md",
40 | "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
41 | "pretty-quick": "pretty-quick",
42 | "test": "rc-test",
43 | "coverage": "rc-test --coverage"
44 | },
45 | "dependencies": {
46 | "classnames": "^2.2.1",
47 | "@rc-component/input": "~1.0.0",
48 | "@rc-component/resize-observer": "^1.0.0",
49 | "@rc-component/util": "^1.2.0"
50 | },
51 | "devDependencies": {
52 | "@rc-component/father-plugin": "^2.0.2",
53 | "@rc-component/np": "^1.0.3",
54 | "@testing-library/jest-dom": "^5.16.5",
55 | "@testing-library/react": "^15.0.0",
56 | "@types/classnames": "^2.2.9",
57 | "@types/react": "^18.0.0",
58 | "@types/react-dom": "^18.0.0",
59 | "@umijs/fabric": "^2.0.8",
60 | "cheerio": "1.0.0-rc.12",
61 | "coveralls": "^3.0.6",
62 | "cross-env": "^7.0.2",
63 | "dumi": "^2.0.0",
64 | "eslint": "^7.0.0",
65 | "father": "^4.0.0",
66 | "gh-pages": "^3.1.0",
67 | "husky": "^4.2.5",
68 | "less": "^3.10.3",
69 | "prettier": "^2.0.5",
70 | "pretty-quick": "^2.0.1",
71 | "rc-test": "^7.1.1",
72 | "react": "^18.0.0",
73 | "react-dom": "^18.0.0"
74 | },
75 | "peerDependencies": {
76 | "react": ">=16.9.0",
77 | "react-dom": ">=16.9.0"
78 | },
79 | "husky": {
80 | "hooks": {
81 | "pre-commit": "pretty-quick --staged"
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/ResizableTextArea.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import ResizeObserver from '@rc-component/resize-observer';
3 | import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect';
4 | import useMergedState from '@rc-component/util/lib/hooks/useMergedState';
5 | import raf from '@rc-component/util/lib/raf';
6 | import * as React from 'react';
7 | import type { TextAreaProps } from '.';
8 | import calculateAutoSizeStyle from './calculateNodeHeight';
9 | import type { ResizableTextAreaRef } from './interface';
10 |
11 | const RESIZE_START = 0;
12 | const RESIZE_MEASURING = 1;
13 | const RESIZE_STABLE = 2;
14 |
15 | const ResizableTextArea = React.forwardRef(
16 | (props, ref) => {
17 | const {
18 | prefixCls,
19 | defaultValue,
20 | value,
21 | autoSize,
22 | onResize,
23 | className,
24 | style,
25 | disabled,
26 | onChange,
27 | // Test only
28 | onInternalAutoSize,
29 | ...restProps
30 | } = props as TextAreaProps & {
31 | onInternalAutoSize?: VoidFunction;
32 | };
33 |
34 | // =============================== Value ================================
35 | const [mergedValue, setMergedValue] = useMergedState(defaultValue, {
36 | value,
37 | postState: (val) => val ?? '',
38 | });
39 |
40 | const onInternalChange: React.ChangeEventHandler = (
41 | event,
42 | ) => {
43 | setMergedValue(event.target.value);
44 | onChange?.(event);
45 | };
46 |
47 | // ================================ Ref =================================
48 | const textareaRef = React.useRef();
49 |
50 | React.useImperativeHandle(ref, () => ({
51 | textArea: textareaRef.current,
52 | }));
53 |
54 | // ============================== AutoSize ==============================
55 | const [minRows, maxRows] = React.useMemo(() => {
56 | if (autoSize && typeof autoSize === 'object') {
57 | return [autoSize.minRows, autoSize.maxRows];
58 | }
59 |
60 | return [];
61 | }, [autoSize]);
62 |
63 | const needAutoSize = !!autoSize;
64 |
65 | // =============================== Scroll ===============================
66 | // https://github.com/ant-design/ant-design/issues/21870
67 | const fixFirefoxAutoScroll = () => {
68 | try {
69 | // FF has bug with jump of scroll to top. We force back here.
70 | if (document.activeElement === textareaRef.current) {
71 | const { selectionStart, selectionEnd, scrollTop } =
72 | textareaRef.current;
73 |
74 | // Fix Safari bug which not rollback when break line
75 | // This makes Chinese IME can't input. Do not fix this
76 | // const { value: tmpValue } = textareaRef.current;
77 | // textareaRef.current.value = '';
78 | // textareaRef.current.value = tmpValue;
79 |
80 | textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
81 | textareaRef.current.scrollTop = scrollTop;
82 | }
83 | } catch (e) {
84 | // Fix error in Chrome:
85 | // Failed to read the 'selectionStart' property from 'HTMLInputElement'
86 | // http://stackoverflow.com/q/21177489/3040605
87 | }
88 | };
89 |
90 | // =============================== Resize ===============================
91 | const [resizeState, setResizeState] = React.useState(RESIZE_STABLE);
92 | const [autoSizeStyle, setAutoSizeStyle] =
93 | React.useState();
94 |
95 | const startResize = () => {
96 | setResizeState(RESIZE_START);
97 | if (process.env.NODE_ENV === 'test') {
98 | onInternalAutoSize?.();
99 | }
100 | };
101 |
102 | // Change to trigger resize measure
103 | useLayoutEffect(() => {
104 | if (needAutoSize) {
105 | startResize();
106 | }
107 | }, [value, minRows, maxRows, needAutoSize]);
108 |
109 | useLayoutEffect(() => {
110 | if (resizeState === RESIZE_START) {
111 | setResizeState(RESIZE_MEASURING);
112 | } else if (resizeState === RESIZE_MEASURING) {
113 | const textareaStyles = calculateAutoSizeStyle(
114 | textareaRef.current,
115 | false,
116 | minRows,
117 | maxRows,
118 | );
119 |
120 | // Safari has bug that text will keep break line on text cut when it's prev is break line.
121 | // ZombieJ: This not often happen. So we just skip it.
122 | // const { selectionStart, selectionEnd, scrollTop } = textareaRef.current;
123 | // const { value: tmpValue } = textareaRef.current;
124 | // textareaRef.current.value = '';
125 | // textareaRef.current.value = tmpValue;
126 |
127 | // if (document.activeElement === textareaRef.current) {
128 | // textareaRef.current.scrollTop = scrollTop;
129 | // textareaRef.current.setSelectionRange(selectionStart, selectionEnd);
130 | // }
131 |
132 | setResizeState(RESIZE_STABLE);
133 | setAutoSizeStyle(textareaStyles);
134 | } else {
135 | fixFirefoxAutoScroll();
136 | }
137 | }, [resizeState]);
138 |
139 | // We lock resize trigger by raf to avoid Safari warning
140 | const resizeRafRef = React.useRef();
141 | const cleanRaf = () => {
142 | raf.cancel(resizeRafRef.current);
143 | };
144 |
145 | const onInternalResize = (size: { width: number; height: number }) => {
146 | if (resizeState === RESIZE_STABLE) {
147 | onResize?.(size);
148 |
149 | if (autoSize) {
150 | cleanRaf();
151 | resizeRafRef.current = raf(() => {
152 | startResize();
153 | });
154 | }
155 | }
156 | };
157 |
158 | React.useEffect(() => cleanRaf, []);
159 |
160 | // =============================== Render ===============================
161 | const mergedAutoSizeStyle = needAutoSize ? autoSizeStyle : null;
162 |
163 | const mergedStyle: React.CSSProperties = {
164 | ...style,
165 | ...mergedAutoSizeStyle,
166 | };
167 |
168 | if (resizeState === RESIZE_START || resizeState === RESIZE_MEASURING) {
169 | mergedStyle.overflowY = 'hidden';
170 | mergedStyle.overflowX = 'hidden';
171 | }
172 |
173 | return (
174 |
178 |
189 |
190 | );
191 | },
192 | );
193 |
194 | export default ResizableTextArea;
195 |
--------------------------------------------------------------------------------
/src/TextArea.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'classnames';
2 | import { BaseInput } from '@rc-component/input';
3 | import { type HolderRef } from '@rc-component/input/lib/BaseInput';
4 | import useCount from '@rc-component/input/lib/hooks/useCount';
5 | import { resolveOnChange } from '@rc-component/input/lib/utils/commonUtils';
6 | import useMergedState from '@rc-component/util/lib/hooks/useMergedState';
7 | import type { ReactNode } from 'react';
8 | import React, { useEffect, useImperativeHandle, useRef } from 'react';
9 | import ResizableTextArea from './ResizableTextArea';
10 | import type {
11 | ResizableTextAreaRef,
12 | TextAreaProps,
13 | TextAreaRef,
14 | } from './interface';
15 |
16 | const TextArea = React.forwardRef(
17 | (
18 | {
19 | defaultValue,
20 | value: customValue,
21 | onFocus,
22 | onBlur,
23 | onChange,
24 | allowClear,
25 | maxLength,
26 | onCompositionStart,
27 | onCompositionEnd,
28 | suffix,
29 | prefixCls = 'rc-textarea',
30 | showCount,
31 | count,
32 | className,
33 | style,
34 | disabled,
35 | hidden,
36 | classNames,
37 | styles,
38 | onResize,
39 | onClear,
40 | onPressEnter,
41 | readOnly,
42 | autoSize,
43 | onKeyDown,
44 | ...rest
45 | },
46 | ref,
47 | ) => {
48 | const [value, setValue] = useMergedState(defaultValue, {
49 | value: customValue,
50 | defaultValue,
51 | });
52 | const formatValue =
53 | value === undefined || value === null ? '' : String(value);
54 |
55 | const [focused, setFocused] = React.useState(false);
56 |
57 | const compositionRef = React.useRef(false);
58 |
59 | const [textareaResized, setTextareaResized] = React.useState(null);
60 |
61 | // =============================== Ref ================================
62 | const holderRef = useRef(null);
63 | const resizableTextAreaRef = useRef(null);
64 | const getTextArea = () => resizableTextAreaRef.current?.textArea;
65 |
66 | const focus = () => {
67 | getTextArea().focus();
68 | };
69 |
70 | useImperativeHandle(ref, () => ({
71 | resizableTextArea: resizableTextAreaRef.current,
72 | focus,
73 | blur: () => {
74 | getTextArea().blur();
75 | },
76 | nativeElement: holderRef.current?.nativeElement || getTextArea(),
77 | }));
78 |
79 | useEffect(() => {
80 | setFocused((prev) => !disabled && prev);
81 | }, [disabled]);
82 |
83 | // =========================== Select Range ===========================
84 | const [selection, setSelection] = React.useState<
85 | [start: number, end: number] | null
86 | >(null);
87 |
88 | React.useEffect(() => {
89 | if (selection) {
90 | getTextArea().setSelectionRange(...selection);
91 | }
92 | }, [selection]);
93 |
94 | // ============================== Count ===============================
95 | const countConfig = useCount(count, showCount);
96 | const mergedMax = countConfig.max ?? maxLength;
97 |
98 | // Max length value
99 | const hasMaxLength = Number(mergedMax) > 0;
100 |
101 | const valueLength = countConfig.strategy(formatValue);
102 |
103 | const isOutOfRange = !!mergedMax && valueLength > mergedMax;
104 |
105 | // ============================== Change ==============================
106 | const triggerChange = (
107 | e:
108 | | React.ChangeEvent
109 | | React.CompositionEvent,
110 | currentValue: string,
111 | ) => {
112 | let cutValue = currentValue;
113 |
114 | if (
115 | !compositionRef.current &&
116 | countConfig.exceedFormatter &&
117 | countConfig.max &&
118 | countConfig.strategy(currentValue) > countConfig.max
119 | ) {
120 | cutValue = countConfig.exceedFormatter(currentValue, {
121 | max: countConfig.max,
122 | });
123 |
124 | if (currentValue !== cutValue) {
125 | setSelection([
126 | getTextArea().selectionStart || 0,
127 | getTextArea().selectionEnd || 0,
128 | ]);
129 | }
130 | }
131 | setValue(cutValue);
132 |
133 | resolveOnChange(e.currentTarget, e, onChange, cutValue);
134 | };
135 |
136 | // =========================== Value Update ===========================
137 | const onInternalCompositionStart: React.CompositionEventHandler<
138 | HTMLTextAreaElement
139 | > = (e) => {
140 | compositionRef.current = true;
141 | onCompositionStart?.(e);
142 | };
143 |
144 | const onInternalCompositionEnd: React.CompositionEventHandler<
145 | HTMLTextAreaElement
146 | > = (e) => {
147 | compositionRef.current = false;
148 | triggerChange(e, e.currentTarget.value);
149 | onCompositionEnd?.(e);
150 | };
151 |
152 | const onInternalChange = (e: React.ChangeEvent) => {
153 | triggerChange(e, e.target.value);
154 | };
155 |
156 | const handleKeyDown = (e: React.KeyboardEvent) => {
157 | if (e.key === 'Enter' && onPressEnter) {
158 | onPressEnter(e);
159 | }
160 | onKeyDown?.(e);
161 | };
162 |
163 | const handleFocus: React.FocusEventHandler = (e) => {
164 | setFocused(true);
165 | onFocus?.(e);
166 | };
167 |
168 | const handleBlur: React.FocusEventHandler = (e) => {
169 | setFocused(false);
170 | onBlur?.(e);
171 | };
172 |
173 | // ============================== Reset ===============================
174 | const handleReset = (e: React.MouseEvent) => {
175 | setValue('');
176 | focus();
177 | resolveOnChange(getTextArea(), e, onChange);
178 | };
179 |
180 | let suffixNode = suffix;
181 | let dataCount: ReactNode;
182 | if (countConfig.show) {
183 | if (countConfig.showFormatter) {
184 | dataCount = countConfig.showFormatter({
185 | value: formatValue,
186 | count: valueLength,
187 | maxLength: mergedMax,
188 | });
189 | } else {
190 | dataCount = `${valueLength}${hasMaxLength ? ` / ${mergedMax}` : ''}`;
191 | }
192 |
193 | suffixNode = (
194 | <>
195 | {suffixNode}
196 |
200 | {dataCount}
201 |
202 | >
203 | );
204 | }
205 |
206 | const handleResize: TextAreaProps['onResize'] = (size) => {
207 | onResize?.(size);
208 | if (getTextArea()?.style.height) {
209 | setTextareaResized(true);
210 | }
211 | };
212 |
213 | const isPureTextArea = !autoSize && !showCount && !allowClear;
214 |
215 | return (
216 |
246 |
264 |
265 | );
266 | },
267 | );
268 |
269 | export default TextArea;
270 |
--------------------------------------------------------------------------------
/src/calculateNodeHeight.tsx:
--------------------------------------------------------------------------------
1 | // Thanks to https://github.com/andreypopp/react-textarea-autosize/
2 | import type React from 'react';
3 |
4 | /**
5 | * calculateNodeHeight(uiTextNode, useCache = false)
6 | */
7 |
8 | const HIDDEN_TEXTAREA_STYLE = `
9 | min-height:0 !important;
10 | max-height:none !important;
11 | height:0 !important;
12 | visibility:hidden !important;
13 | overflow:hidden !important;
14 | position:absolute !important;
15 | z-index:-1000 !important;
16 | top:0 !important;
17 | right:0 !important;
18 | pointer-events: none !important;
19 | `;
20 |
21 | const SIZING_STYLE = [
22 | 'letter-spacing',
23 | 'line-height',
24 | 'padding-top',
25 | 'padding-bottom',
26 | 'font-family',
27 | 'font-weight',
28 | 'font-size',
29 | 'font-variant',
30 | 'text-rendering',
31 | 'text-transform',
32 | 'width',
33 | 'text-indent',
34 | 'padding-left',
35 | 'padding-right',
36 | 'border-width',
37 | 'box-sizing',
38 | 'word-break',
39 | 'white-space',
40 | ];
41 |
42 | export interface NodeType {
43 | sizingStyle: string;
44 | paddingSize: number;
45 | borderSize: number;
46 | boxSizing: string;
47 | }
48 |
49 | const computedStyleCache: Record = {};
50 | let hiddenTextarea: HTMLTextAreaElement;
51 |
52 | export function calculateNodeStyling(node: HTMLElement, useCache = false) {
53 | const nodeRef = (node.getAttribute('id') ||
54 | node.getAttribute('data-reactid') ||
55 | node.getAttribute('name')) as string;
56 |
57 | if (useCache && computedStyleCache[nodeRef]) {
58 | return computedStyleCache[nodeRef];
59 | }
60 |
61 | const style = window.getComputedStyle(node);
62 |
63 | const boxSizing =
64 | style.getPropertyValue('box-sizing') ||
65 | style.getPropertyValue('-moz-box-sizing') ||
66 | style.getPropertyValue('-webkit-box-sizing');
67 |
68 | const paddingSize =
69 | parseFloat(style.getPropertyValue('padding-bottom')) +
70 | parseFloat(style.getPropertyValue('padding-top'));
71 |
72 | const borderSize =
73 | parseFloat(style.getPropertyValue('border-bottom-width')) +
74 | parseFloat(style.getPropertyValue('border-top-width'));
75 |
76 | const sizingStyle = SIZING_STYLE.map(
77 | (name) => `${name}:${style.getPropertyValue(name)}`,
78 | ).join(';');
79 |
80 | const nodeInfo: NodeType = {
81 | sizingStyle,
82 | paddingSize,
83 | borderSize,
84 | boxSizing,
85 | };
86 |
87 | if (useCache && nodeRef) {
88 | computedStyleCache[nodeRef] = nodeInfo;
89 | }
90 |
91 | return nodeInfo;
92 | }
93 |
94 | export default function calculateAutoSizeStyle(
95 | uiTextNode: HTMLTextAreaElement,
96 | useCache = false,
97 | minRows: number | null = null,
98 | maxRows: number | null = null,
99 | ): React.CSSProperties {
100 | if (!hiddenTextarea) {
101 | hiddenTextarea = document.createElement('textarea');
102 | hiddenTextarea.setAttribute('tab-index', '-1');
103 | hiddenTextarea.setAttribute('aria-hidden', 'true');
104 | // fix: A form field element should have an id or name attribute
105 | // A form field element has neither an id nor a name attribute. This might prevent the browser from correctly autofilling the form.
106 | // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea
107 | hiddenTextarea.setAttribute('name', 'hiddenTextarea');
108 | document.body.appendChild(hiddenTextarea);
109 | }
110 |
111 | // Fix wrap="off" issue
112 | // https://github.com/ant-design/ant-design/issues/6577
113 | if (uiTextNode.getAttribute('wrap')) {
114 | hiddenTextarea.setAttribute(
115 | 'wrap',
116 | uiTextNode.getAttribute('wrap') as string,
117 | );
118 | } else {
119 | hiddenTextarea.removeAttribute('wrap');
120 | }
121 |
122 | // Copy all CSS properties that have an impact on the height of the content in
123 | // the textbox
124 | const { paddingSize, borderSize, boxSizing, sizingStyle } =
125 | calculateNodeStyling(uiTextNode, useCache);
126 |
127 | // Need to have the overflow attribute to hide the scrollbar otherwise
128 | // text-lines will not calculated properly as the shadow will technically be
129 | // narrower for content
130 | hiddenTextarea.setAttribute(
131 | 'style',
132 | `${sizingStyle};${HIDDEN_TEXTAREA_STYLE}`,
133 | );
134 | hiddenTextarea.value = uiTextNode.value || uiTextNode.placeholder || '';
135 |
136 | let minHeight: number | undefined = undefined;
137 | let maxHeight: number | undefined = undefined;
138 | let overflowY: any;
139 |
140 | let height = hiddenTextarea.scrollHeight;
141 |
142 | if (boxSizing === 'border-box') {
143 | // border-box: add border, since height = content + padding + border
144 | height += borderSize;
145 | } else if (boxSizing === 'content-box') {
146 | // remove padding, since height = content
147 | height -= paddingSize;
148 | }
149 |
150 | if (minRows !== null || maxRows !== null) {
151 | // measure height of a textarea with a single row
152 | hiddenTextarea.value = ' ';
153 | const singleRowHeight = hiddenTextarea.scrollHeight - paddingSize;
154 | if (minRows !== null) {
155 | minHeight = singleRowHeight * minRows;
156 | if (boxSizing === 'border-box') {
157 | minHeight = minHeight + paddingSize + borderSize;
158 | }
159 | height = Math.max(minHeight, height);
160 | }
161 | if (maxRows !== null) {
162 | maxHeight = singleRowHeight * maxRows;
163 | if (boxSizing === 'border-box') {
164 | maxHeight = maxHeight + paddingSize + borderSize;
165 | }
166 | overflowY = height > maxHeight ? '' : 'hidden';
167 | height = Math.min(maxHeight, height);
168 | }
169 | }
170 |
171 | const style: React.CSSProperties = {
172 | height,
173 | overflowY,
174 | resize: 'none',
175 | };
176 |
177 | if (minHeight) {
178 | style.minHeight = minHeight;
179 | }
180 | if (maxHeight) {
181 | style.maxHeight = maxHeight;
182 | }
183 |
184 | return style;
185 | }
186 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import TextArea from './TextArea';
2 |
3 | export { default as ResizableTextArea } from './ResizableTextArea';
4 | export type {
5 | AutoSizeType,
6 | ResizableTextAreaRef,
7 | TextAreaProps,
8 | TextAreaRef,
9 | } from './interface';
10 |
11 | export default TextArea;
12 |
--------------------------------------------------------------------------------
/src/interface.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | BaseInputProps,
3 | CommonInputProps,
4 | InputProps,
5 | } from '@rc-component/input/lib/interface';
6 | import type React from 'react';
7 | import type { CSSProperties } from 'react';
8 |
9 | export interface AutoSizeType {
10 | minRows?: number;
11 | maxRows?: number;
12 | }
13 |
14 | // To compatible with origin usage. We have to wrap this
15 | export interface ResizableTextAreaRef {
16 | textArea: HTMLTextAreaElement;
17 | }
18 |
19 | export type HTMLTextareaProps =
20 | React.TextareaHTMLAttributes;
21 |
22 | export type TextAreaProps = Omit & {
23 | value?: HTMLTextareaProps['value'] | bigint;
24 | prefixCls?: string;
25 | className?: string;
26 | style?: React.CSSProperties;
27 | autoSize?: boolean | AutoSizeType;
28 | onPressEnter?: React.KeyboardEventHandler;
29 | onResize?: (size: { width: number; height: number }) => void;
30 | classNames?: CommonInputProps['classNames'] & {
31 | textarea?: string;
32 | count?: string;
33 | };
34 | styles?: {
35 | textarea?: CSSProperties;
36 | count?: CSSProperties;
37 | };
38 | } & Pick &
39 | Pick;
40 |
41 | export type TextAreaRef = {
42 | resizableTextArea: ResizableTextAreaRef;
43 | focus: () => void;
44 | blur: () => void;
45 | nativeElement: HTMLElement;
46 | };
47 |
--------------------------------------------------------------------------------
/tests/ResizableTextArea.test.tsx:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/react';
2 | import React from 'react';
3 | import type { ResizableTextAreaRef, TextAreaProps } from '../src';
4 | import { ResizableTextArea } from '../src';
5 |
6 | const resizableTextAreaProps = () => (global as any).textAreaProps;
7 |
8 | jest.mock('../src/ResizableTextArea', () => {
9 | const ReactReal: typeof React = jest.requireActual('react');
10 | const Resizable = jest.requireActual('../src/ResizableTextArea');
11 | const ResizableComponent = Resizable.default;
12 | return ReactReal.forwardRef(
13 | (props, ref) => {
14 | (global as any).textAreaProps = props;
15 | return ;
16 | },
17 | );
18 | });
19 |
20 | it('should have no onPressEnter prop', () => {
21 | render();
22 | expect(resizableTextAreaProps().onPressEnter).toBeUndefined();
23 | });
24 |
--------------------------------------------------------------------------------
/tests/__snapshots__/allowClear.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`should support allowClear should not show icon if defaultValue is undefined, null or empty string 1`] = `
4 |
7 |
10 |
13 |
19 |
20 |
21 | `;
22 |
23 | exports[`should support allowClear should not show icon if defaultValue is undefined, null or empty string 2`] = `
24 |
27 |
30 |
33 |
39 |
40 |
41 | `;
42 |
43 | exports[`should support allowClear should not show icon if defaultValue is undefined, null or empty string 3`] = `
44 |
47 |
50 |
53 |
59 |
60 |
61 | `;
62 |
63 | exports[`should support allowClear should not show icon if value is undefined, null or empty string 1`] = `
64 |
67 |
70 |
73 |
79 |
80 |
81 | `;
82 |
83 | exports[`should support allowClear should not show icon if value is undefined, null or empty string 2`] = `
84 |
87 |
90 |
93 |
99 |
100 |
101 | `;
102 |
103 | exports[`should support allowClear should not show icon if value is undefined, null or empty string 3`] = `
104 |
107 |
110 |
113 |
119 |
120 |
121 | `;
122 |
--------------------------------------------------------------------------------
/tests/__snapshots__/index.spec.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`TextArea classNames and styles should work 1`] = `
4 |
5 |
9 |
14 |
18 |
21 |
25 | 0
26 |
27 |
28 |
29 |
30 | `;
31 |
32 | exports[`TextArea should support disabled 1`] = `
33 |
37 | `;
38 |
39 | exports[`TextArea should support maxLength 1`] = `
40 |
44 | `;
45 |
--------------------------------------------------------------------------------
/tests/allowClear.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render } from '@testing-library/react';
2 | import type { ChangeEventHandler, TextareaHTMLAttributes } from 'react';
3 | import React from 'react';
4 | import TextArea from '../src';
5 |
6 | describe('should support allowClear', () => {
7 | it('should change type when click', () => {
8 | const { container } = render();
9 | fireEvent.change(container.querySelector('textarea')!, {
10 | target: { value: '111' },
11 | });
12 | expect(container.querySelector('textarea')?.value).toEqual('111');
13 | expect(
14 | container.querySelector('.rc-textarea-clear-icon-hidhen'),
15 | ).toBeFalsy();
16 | fireEvent.click(container.querySelector('.rc-textarea-clear-icon')!);
17 | expect(
18 | container.querySelector('.rc-textarea-clear-icon-hidden'),
19 | ).toBeTruthy();
20 | expect(container.querySelector('textarea')?.value).toEqual('');
21 | });
22 |
23 | it('should not show icon if value is undefined, null or empty string', () => {
24 | const wrappers = [null, undefined, ''].map((val) =>
25 | render(
26 |