mirror of
https://github.com/ant-design/ant-design.git
synced 2025-08-05 23:46:28 +08:00
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:
parent
79425c5dc1
commit
a1eb00bb9b
@ -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}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
@ -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>,
|
||||
]
|
||||
`;
|
||||
|
||||
|
@ -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>,
|
||||
]
|
||||
`;
|
||||
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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),
|
||||
|
@ -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',
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user