mirror of
https://github.com/ant-design/ant-design.git
synced 2026-02-09 02:49:18 +08:00
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:
@@ -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}
|
||||
/>,
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
40
components/input/hooks/useHandleResizeWrapper.ts
Normal file
40
components/input/hooks/useHandleResizeWrapper.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user