refactor: TextArea resize with React life cycle (#53235)

* chore: move style

* refactor: use react life cycle

* chore: adjust style

* test: fix test logic

* test: update snapshot

* test: coverage
This commit is contained in:
二货爱吃白萝卜 2025-03-21 14:56:10 +08:00 committed by GitHub
parent 79425c5dc1
commit a1eb00bb9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 122 additions and 162 deletions

View File

@ -17,7 +17,6 @@ import type { SizeType } from '../config-provider/SizeContext';
import { FormItemInputContext } from '../form/context';
import useVariant from '../form/hooks/useVariants';
import { useCompactItemContext } from '../space/Compact';
import useHandleResizeWrapper from './hooks/useHandleResizeWrapper';
import type { InputFocusOptions } from './Input';
import { triggerFocus } from './Input';
import { useSharedStyle } from './style';
@ -57,6 +56,8 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
styles,
variant: customVariant,
showCount,
onMouseDown,
onResize,
...rest
} = props;
@ -76,11 +77,11 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
styles: contextStyles,
} = useComponentConfig('textArea');
// ===================== Disabled =====================
// =================== Disabled ===================
const disabled = React.useContext(DisabledContext);
const mergedDisabled = customDisabled ?? disabled;
// ===================== Status =====================
// ==================== Status ====================
const {
status: contextStatus,
hasFeedback,
@ -88,7 +89,7 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
} = React.useContext(FormItemInputContext);
const mergedStatus = getMergedStatus(contextStatus, customStatus);
// ===================== Ref =====================
// ===================== Ref ======================
const innerRef = React.useRef<RcTextAreaRef>(null);
React.useImperativeHandle(ref, () => ({
@ -101,12 +102,12 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
const prefixCls = getPrefixCls('input', customizePrefixCls);
// ===================== Style =====================
// ==================== Style =====================
const rootCls = useCSSVarCls(prefixCls);
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls, rootClassName);
const [wrapCSSVar] = useStyle(prefixCls, rootCls);
// ===================== Compact Item =====================
// ================= Compact Item =================
const { compactSize, compactItemClassnames } = useCompactItemContext(prefixCls, direction);
// ===================== Size =====================
@ -116,8 +117,39 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
const mergedAllowClear = getAllowClear(allowClear ?? contextAllowClear);
const handleResizeWrapper = useHandleResizeWrapper();
// ==================== Resize ====================
// https://github.com/ant-design/ant-design/issues/51594
const [isMouseDown, setIsMouseDown] = React.useState(false);
// When has wrapper, resize will make as dirty for `resize: both` style
const [resizeDirty, setResizeDirty] = React.useState(false);
const onInternalMouseDown: typeof onMouseDown = (e) => {
setIsMouseDown(true);
onMouseDown?.(e);
const onMouseUp = () => {
setIsMouseDown(false);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mouseup', onMouseUp);
};
const onInternalResize: RcTextAreaProps['onResize'] = (size) => {
onResize?.(size);
// Change to dirty since this maybe from the `resize: both` style
if (isMouseDown && typeof getComputedStyle === 'function') {
const ele = innerRef.current?.nativeElement?.querySelector('textarea');
if (ele && getComputedStyle(ele).resize === 'both') {
setResizeDirty(true);
}
}
};
// ==================== Render ====================
return wrapSharedCSSVar(
wrapCSSVar(
<RcTextArea
@ -134,6 +166,8 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
rootClassName,
compactItemClassnames,
contextClassName,
// Only for wrapper
resizeDirty && `${prefixCls}-textarea-affix-wrapper-resize-dirty`,
)}
classNames={{
...classes,
@ -146,6 +180,7 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
hashId,
classes?.textarea,
contextClassNames.textarea,
isMouseDown && `${prefixCls}-mouse-active`,
),
variant: classNames(
{
@ -170,10 +205,8 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
}
showCount={showCount}
ref={innerRef}
onResize={(size) => {
rest.onResize?.(size);
showCount && handleResizeWrapper(innerRef.current);
}}
onResize={onInternalResize}
onMouseDown={onInternalMouseDown}
/>,
),
);

View File

@ -11956,6 +11956,26 @@ Array [
</button>
</span>
</span>,
<br />,
<span
class="ant-input-affix-wrapper ant-input-textarea-affix-wrapper ant-input-textarea-show-count ant-input-show-count ant-input-outlined"
data-count="0"
style="resize: both;"
>
<textarea
class="ant-input"
style="resize: both;"
/>
<span
class="ant-input-suffix"
>
<span
class="ant-input-data-count"
>
0
</span>
</span>
</span>,
]
`;

View File

@ -5264,6 +5264,26 @@ Array [
</button>
</span>
</span>,
<br />,
<span
class="ant-input-affix-wrapper ant-input-textarea-affix-wrapper ant-input-textarea-show-count ant-input-show-count ant-input-outlined"
data-count="0"
style="resize:both"
>
<textarea
class="ant-input"
style="resize:both"
/>
<span
class="ant-input-suffix"
>
<span
class="ant-input-data-count"
>
0
</span>
</span>
</span>,
]
`;

View File

@ -1,6 +1,5 @@
import type { ChangeEventHandler, TextareaHTMLAttributes } from 'react';
import React, { useState } from 'react';
import type { TextAreaRef as RcTextAreaRef } from 'rc-textarea';
import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
import Input from '..';
@ -10,12 +9,10 @@ import {
fireEvent,
pureRender,
render,
renderHook,
triggerResize,
waitFakeTimer,
waitFakeTimer19,
} from '../../../tests/utils';
import useHandleResizeWrapper from '../hooks/useHandleResizeWrapper';
import type { TextAreaRef } from '../TextArea';
const { TextArea } = Input;
@ -533,113 +530,21 @@ describe('TextArea allowClear', () => {
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('`bordered` is deprecated'));
errSpy.mockRestore();
});
});
describe('TextArea useHandleResizeWrapper', () => {
let requestAnimationFrameSpy: jest.SpyInstance;
it('resize: both', async () => {
const { container } = render(<TextArea showCount style={{ resize: 'both' }} />);
beforeAll(() => {
// Use fake timers to control requestAnimationFrame.
jest.useFakeTimers();
// Override requestAnimationFrame to simulate a 16ms delay.
requestAnimationFrameSpy = jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation((cb: FrameRequestCallback) => {
return window.setTimeout(() => cb(performance.now()), 16);
});
});
fireEvent.mouseDown(container.querySelector('textarea')!);
afterAll(() => {
jest.useRealTimers();
requestAnimationFrameSpy.mockRestore();
});
triggerResize(container.querySelector('textarea')!);
await waitFakeTimer();
it('does nothing when rcTextArea is null', () => {
const { result } = renderHook(useHandleResizeWrapper);
// Calling with null should not throw or change anything.
expect(() => result.current?.(null)).not.toThrow();
});
expect(container.querySelector('.ant-input-textarea-affix-wrapper')).toHaveClass(
'ant-input-textarea-affix-wrapper-resize-dirty',
);
expect(container.querySelector('.ant-input-mouse-active')).toBeTruthy();
it('does nothing when style width does not include "px"', () => {
const { result } = renderHook(useHandleResizeWrapper);
const fakeRcTextArea = {
resizableTextArea: {
textArea: {
style: {
width: '100', // missing 'px'
},
},
},
nativeElement: {
offsetWidth: 110,
style: {},
},
} as unknown as RcTextAreaRef;
result.current?.(fakeRcTextArea);
// Fast-forward time to see if any scheduled callback would execute.
jest.advanceTimersByTime(16);
// nativeElement.style.width remains unchanged.
expect(fakeRcTextArea.nativeElement.style.width).toBeUndefined();
});
it('adjusts width correctly when offsetWidth is slightly greater than the textArea width (increased scenario)', () => {
const { result } = renderHook(useHandleResizeWrapper);
const fakeRcTextArea = {
resizableTextArea: {
textArea: {
style: {
width: '100px', // valid width with px
},
},
},
// offsetWidth is 101 so the difference is 1 (< ELEMENT_GAP of 2)
nativeElement: {
offsetWidth: 101,
style: {},
},
} as unknown as RcTextAreaRef;
// Immediately after calling handleResizeWrapper, the update is scheduled.
expect(fakeRcTextArea.nativeElement.style.width).toBeUndefined();
result.current?.(fakeRcTextArea);
// Fast-forward time to trigger the requestAnimationFrame callback.
jest.advanceTimersByTime(16);
// Expected new width: 100 + 2 = 102px.
expect(fakeRcTextArea.nativeElement.style.width).toBe('102px');
});
it('adjusts width correctly when offsetWidth is significantly greater than the textArea width (decreased scenario)', () => {
const { result } = renderHook(useHandleResizeWrapper);
const fakeRcTextArea = {
resizableTextArea: {
textArea: {
style: {
width: '100px',
},
},
},
// offsetWidth is 105 so the difference is 5 (> ELEMENT_GAP of 2)
nativeElement: {
offsetWidth: 105,
style: {},
},
} as unknown as RcTextAreaRef;
// Immediately after calling handleResizeWrapper, the update is scheduled.
expect(fakeRcTextArea.nativeElement.style.width).toBeUndefined();
result.current?.(fakeRcTextArea);
// Fast-forward time to trigger the requestAnimationFrame callback.
jest.advanceTimersByTime(16);
// Expected new width remains: 100 + 2 = 102px.
expect(fakeRcTextArea.nativeElement.style.width).toBe('102px');
fireEvent.mouseUp(container.querySelector('textarea')!);
expect(container.querySelector('.ant-input-mouse-active')).toBeFalsy();
});
});

View File

@ -16,6 +16,13 @@ const App: React.FC = () => {
</Button>
<TextArea rows={4} autoSize={autoResize} defaultValue={defaultValue} />
<TextArea allowClear style={{ width: 93 }} />
<br />
<TextArea
style={{
resize: 'both',
}}
showCount
/>
</>
);
};

View File

@ -1,34 +0,0 @@
import React from 'react';
import type { TextAreaRef } from 'rc-textarea';
import raf from 'rc-util/lib/raf';
type ResizeWrapperHandler = (rcTextArea: TextAreaRef | null) => void;
const ELEMENT_GAP = 2;
const adjustElementWidth = (width: number, wrapper: HTMLElement): void => {
if (wrapper.offsetWidth - width < ELEMENT_GAP) {
// The textarea's width is increased
wrapper.style.width = `${width + ELEMENT_GAP}px`;
} else if (wrapper.offsetWidth - width > ELEMENT_GAP) {
// The textarea's width is decreased
wrapper.style.width = `${width + ELEMENT_GAP}px`;
}
};
const useHandleResizeWrapper = () => {
const handleResizeWrapper = React.useCallback<ResizeWrapperHandler>((rcTextArea) => {
if (!rcTextArea) {
return;
}
if (rcTextArea.resizableTextArea.textArea.style.width.includes('px')) {
const width = Number.parseInt(
rcTextArea.resizableTextArea.textArea.style.width.replace(/px/, ''),
);
raf(() => adjustElementWidth(width, rcTextArea.nativeElement));
}
}, []);
return handleResizeWrapper;
};
export default useHandleResizeWrapper;

View File

@ -70,17 +70,6 @@ export const genBasicInputStyle = (token: InputToken): CSSObject => ({
transition: `all ${token.motionDurationMid}`,
...genPlaceholderStyle(token.colorTextPlaceholder),
// Reset height for `textarea`s
'textarea&': {
maxWidth: '100%', // prevent textarea resize from coming out of its container
height: 'auto',
minHeight: token.controlHeight,
lineHeight: token.lineHeight,
verticalAlign: 'bottom',
transition: `all ${token.motionDurationSlow}, height 0s`,
resize: 'vertical',
},
// Size
'&-lg': {
...genInputLargeStyle(token),

View File

@ -11,6 +11,26 @@ const genTextAreaStyle: GenerateStyle<InputToken> = (token) => {
const textareaPrefixCls = `${componentCls}-textarea`;
return {
// Raw Textarea
[`textarea${componentCls}`]: {
maxWidth: '100%', // prevent textarea resize from coming out of its container
height: 'auto',
minHeight: token.controlHeight,
lineHeight: token.lineHeight,
verticalAlign: 'bottom',
transition: `all ${token.motionDurationSlow}`,
resize: 'vertical',
[`&${componentCls}-mouse-active`]: {
transition: `all ${token.motionDurationSlow}, height 0s, width 0s`,
},
},
// Wrapper for resize
[`${componentCls}-textarea-affix-wrapper-resize-dirty`]: {
width: 'auto',
},
[textareaPrefixCls]: {
position: 'relative',