fix: dynamically resize the textarea's wrapper (#53024)

* fix: dynamically resize the textarea's wrapper

* fix: use requestAnimationFrame to wrap style updates

* test: modify test simulate requestAnimationFrame

* test: correct the test due to wrong logic

---------

Co-authored-by: afc163 <afc163@gmail.com>
This commit is contained in:
triyys
2025-03-14 09:51:34 +07:00
committed by GitHub
parent 14fc373111
commit 855c0a01ab
3 changed files with 160 additions and 0 deletions

View File

@@ -21,6 +21,7 @@ import type { InputFocusOptions } from './Input';
import { triggerFocus } from './Input';
import { useSharedStyle } from './style';
import useStyle from './style/textarea';
import useHandleResizeWrapper from './hooks/useHandleResizeWrapper';
export interface TextAreaProps extends Omit<RcTextAreaProps, 'suffix'> {
/** @deprecated Use `variant` instead */
@@ -55,6 +56,7 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
style,
styles,
variant: customVariant,
showCount,
...rest
} = props;
@@ -113,6 +115,7 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
const [variant, enableVariantCls] = useVariant('textArea', customVariant, bordered);
const mergedAllowClear = getAllowClear(allowClear ?? contextAllowClear);
const { handleResizeWrapper } = useHandleResizeWrapper();
return wrapSharedCSSVar(
wrapCSSVar(
@@ -164,6 +167,11 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
suffix={
hasFeedback && <span className={`${prefixCls}-textarea-suffix`}>{feedbackIcon}</span>
}
showCount={showCount}
onResize={(size) => {
rest.onResize?.(size);
showCount && handleResizeWrapper(innerRef.current);
}}
ref={innerRef}
/>,
),

View File

@@ -9,11 +9,14 @@ import {
fireEvent,
pureRender,
render,
renderHook,
triggerResize,
waitFakeTimer,
waitFakeTimer19,
} from '../../../tests/utils';
import type { TextAreaRef } from '../TextArea';
import useHandleResizeWrapper from '../hooks/useHandleResizeWrapper';
import type { TextAreaRef as RcTextAreaRef } from 'rc-textarea';
const { TextArea } = Input;
@@ -531,3 +534,112 @@ describe('TextArea allowClear', () => {
errSpy.mockRestore();
});
});
describe('TextArea useHandleResizeWrapper', () => {
let requestAnimationFrameSpy: jest.SpyInstance;
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);
});
});
afterAll(() => {
jest.useRealTimers();
requestAnimationFrameSpy.mockRestore();
});
it('does nothing when rcTextArea is null', () => {
const { result } = renderHook(() => useHandleResizeWrapper());
// Calling with null should not throw or change anything.
expect(() => result.current?.handleResizeWrapper(null)).not.toThrow();
});
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 any,
},
} as unknown as RcTextAreaRef;
result.current?.handleResizeWrapper(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 any,
},
} as unknown as RcTextAreaRef;
// Immediately after calling handleResizeWrapper, the update is scheduled.
expect(fakeRcTextArea.nativeElement.style.width).toBeUndefined();
result.current?.handleResizeWrapper(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 any,
},
} as unknown as RcTextAreaRef;
// Immediately after calling handleResizeWrapper, the update is scheduled.
expect(fakeRcTextArea.nativeElement.style.width).toBeUndefined();
result.current?.handleResizeWrapper(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');
});
});

View File

@@ -0,0 +1,40 @@
import { TextAreaRef } from 'rc-textarea';
import React from 'react';
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`;
}
};
let isScheduled = false;
const requestAnimationFrameDecorator = (callback: () => void) => {
if (!isScheduled) {
isScheduled = true;
requestAnimationFrame(() => {
callback();
isScheduled = false;
});
}
};
export default function useHandleResizeWrapper(): { handleResizeWrapper: ResizeWrapperHandler } {
const handleResizeWrapper: ResizeWrapperHandler = React.useCallback((rcTextArea) => {
if (!rcTextArea) return;
if (rcTextArea.resizableTextArea.textArea.style.width.includes('px')) {
const width = parseInt(rcTextArea.resizableTextArea.textArea.style.width.replace('px', ''));
requestAnimationFrameDecorator(() => adjustElementWidth(width, rcTextArea.nativeElement));
}
}, []);
return { handleResizeWrapper };
}