mirror of
https://github.com/ant-design/ant-design.git
synced 2025-08-06 16:06: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 { FormItemInputContext } from '../form/context';
|
||||||
import useVariant from '../form/hooks/useVariants';
|
import useVariant from '../form/hooks/useVariants';
|
||||||
import { useCompactItemContext } from '../space/Compact';
|
import { useCompactItemContext } from '../space/Compact';
|
||||||
import useHandleResizeWrapper from './hooks/useHandleResizeWrapper';
|
|
||||||
import type { InputFocusOptions } from './Input';
|
import type { InputFocusOptions } from './Input';
|
||||||
import { triggerFocus } from './Input';
|
import { triggerFocus } from './Input';
|
||||||
import { useSharedStyle } from './style';
|
import { useSharedStyle } from './style';
|
||||||
@ -57,6 +56,8 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
|
|||||||
styles,
|
styles,
|
||||||
variant: customVariant,
|
variant: customVariant,
|
||||||
showCount,
|
showCount,
|
||||||
|
onMouseDown,
|
||||||
|
onResize,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@ -76,11 +77,11 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
|
|||||||
styles: contextStyles,
|
styles: contextStyles,
|
||||||
} = useComponentConfig('textArea');
|
} = useComponentConfig('textArea');
|
||||||
|
|
||||||
// ===================== Disabled =====================
|
// =================== Disabled ===================
|
||||||
const disabled = React.useContext(DisabledContext);
|
const disabled = React.useContext(DisabledContext);
|
||||||
const mergedDisabled = customDisabled ?? disabled;
|
const mergedDisabled = customDisabled ?? disabled;
|
||||||
|
|
||||||
// ===================== Status =====================
|
// ==================== Status ====================
|
||||||
const {
|
const {
|
||||||
status: contextStatus,
|
status: contextStatus,
|
||||||
hasFeedback,
|
hasFeedback,
|
||||||
@ -88,7 +89,7 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
|
|||||||
} = React.useContext(FormItemInputContext);
|
} = React.useContext(FormItemInputContext);
|
||||||
const mergedStatus = getMergedStatus(contextStatus, customStatus);
|
const mergedStatus = getMergedStatus(contextStatus, customStatus);
|
||||||
|
|
||||||
// ===================== Ref =====================
|
// ===================== Ref ======================
|
||||||
const innerRef = React.useRef<RcTextAreaRef>(null);
|
const innerRef = React.useRef<RcTextAreaRef>(null);
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
@ -101,12 +102,12 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
|
|||||||
|
|
||||||
const prefixCls = getPrefixCls('input', customizePrefixCls);
|
const prefixCls = getPrefixCls('input', customizePrefixCls);
|
||||||
|
|
||||||
// ===================== Style =====================
|
// ==================== Style =====================
|
||||||
const rootCls = useCSSVarCls(prefixCls);
|
const rootCls = useCSSVarCls(prefixCls);
|
||||||
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls, rootClassName);
|
const [wrapSharedCSSVar, hashId, cssVarCls] = useSharedStyle(prefixCls, rootClassName);
|
||||||
const [wrapCSSVar] = useStyle(prefixCls, rootCls);
|
const [wrapCSSVar] = useStyle(prefixCls, rootCls);
|
||||||
|
|
||||||
// ===================== Compact Item =====================
|
// ================= Compact Item =================
|
||||||
const { compactSize, compactItemClassnames } = useCompactItemContext(prefixCls, direction);
|
const { compactSize, compactItemClassnames } = useCompactItemContext(prefixCls, direction);
|
||||||
|
|
||||||
// ===================== Size =====================
|
// ===================== Size =====================
|
||||||
@ -116,8 +117,39 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
|
|||||||
|
|
||||||
const mergedAllowClear = getAllowClear(allowClear ?? contextAllowClear);
|
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(
|
return wrapSharedCSSVar(
|
||||||
wrapCSSVar(
|
wrapCSSVar(
|
||||||
<RcTextArea
|
<RcTextArea
|
||||||
@ -134,6 +166,8 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
|
|||||||
rootClassName,
|
rootClassName,
|
||||||
compactItemClassnames,
|
compactItemClassnames,
|
||||||
contextClassName,
|
contextClassName,
|
||||||
|
// Only for wrapper
|
||||||
|
resizeDirty && `${prefixCls}-textarea-affix-wrapper-resize-dirty`,
|
||||||
)}
|
)}
|
||||||
classNames={{
|
classNames={{
|
||||||
...classes,
|
...classes,
|
||||||
@ -146,6 +180,7 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
|
|||||||
hashId,
|
hashId,
|
||||||
classes?.textarea,
|
classes?.textarea,
|
||||||
contextClassNames.textarea,
|
contextClassNames.textarea,
|
||||||
|
isMouseDown && `${prefixCls}-mouse-active`,
|
||||||
),
|
),
|
||||||
variant: classNames(
|
variant: classNames(
|
||||||
{
|
{
|
||||||
@ -170,10 +205,8 @@ const TextArea = forwardRef<TextAreaRef, TextAreaProps>((props, ref) => {
|
|||||||
}
|
}
|
||||||
showCount={showCount}
|
showCount={showCount}
|
||||||
ref={innerRef}
|
ref={innerRef}
|
||||||
onResize={(size) => {
|
onResize={onInternalResize}
|
||||||
rest.onResize?.(size);
|
onMouseDown={onInternalMouseDown}
|
||||||
showCount && handleResizeWrapper(innerRef.current);
|
|
||||||
}}
|
|
||||||
/>,
|
/>,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -11956,6 +11956,26 @@ Array [
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</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>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</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 type { ChangeEventHandler, TextareaHTMLAttributes } from 'react';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { TextAreaRef as RcTextAreaRef } from 'rc-textarea';
|
|
||||||
import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
|
import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
|
||||||
|
|
||||||
import Input from '..';
|
import Input from '..';
|
||||||
@ -10,12 +9,10 @@ import {
|
|||||||
fireEvent,
|
fireEvent,
|
||||||
pureRender,
|
pureRender,
|
||||||
render,
|
render,
|
||||||
renderHook,
|
|
||||||
triggerResize,
|
triggerResize,
|
||||||
waitFakeTimer,
|
waitFakeTimer,
|
||||||
waitFakeTimer19,
|
waitFakeTimer19,
|
||||||
} from '../../../tests/utils';
|
} from '../../../tests/utils';
|
||||||
import useHandleResizeWrapper from '../hooks/useHandleResizeWrapper';
|
|
||||||
import type { TextAreaRef } from '../TextArea';
|
import type { TextAreaRef } from '../TextArea';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@ -533,113 +530,21 @@ describe('TextArea allowClear', () => {
|
|||||||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('`bordered` is deprecated'));
|
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('`bordered` is deprecated'));
|
||||||
errSpy.mockRestore();
|
errSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('TextArea useHandleResizeWrapper', () => {
|
it('resize: both', async () => {
|
||||||
let requestAnimationFrameSpy: jest.SpyInstance;
|
const { container } = render(<TextArea showCount style={{ resize: 'both' }} />);
|
||||||
|
|
||||||
beforeAll(() => {
|
fireEvent.mouseDown(container.querySelector('textarea')!);
|
||||||
// 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(() => {
|
triggerResize(container.querySelector('textarea')!);
|
||||||
jest.useRealTimers();
|
await waitFakeTimer();
|
||||||
requestAnimationFrameSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing when rcTextArea is null', () => {
|
expect(container.querySelector('.ant-input-textarea-affix-wrapper')).toHaveClass(
|
||||||
const { result } = renderHook(useHandleResizeWrapper);
|
'ant-input-textarea-affix-wrapper-resize-dirty',
|
||||||
// Calling with null should not throw or change anything.
|
);
|
||||||
expect(() => result.current?.(null)).not.toThrow();
|
expect(container.querySelector('.ant-input-mouse-active')).toBeTruthy();
|
||||||
});
|
|
||||||
|
|
||||||
it('does nothing when style width does not include "px"', () => {
|
fireEvent.mouseUp(container.querySelector('textarea')!);
|
||||||
const { result } = renderHook(useHandleResizeWrapper);
|
expect(container.querySelector('.ant-input-mouse-active')).toBeFalsy();
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -16,6 +16,13 @@ const App: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<TextArea rows={4} autoSize={autoResize} defaultValue={defaultValue} />
|
<TextArea rows={4} autoSize={autoResize} defaultValue={defaultValue} />
|
||||||
<TextArea allowClear style={{ width: 93 }} />
|
<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}`,
|
transition: `all ${token.motionDurationMid}`,
|
||||||
...genPlaceholderStyle(token.colorTextPlaceholder),
|
...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
|
// Size
|
||||||
'&-lg': {
|
'&-lg': {
|
||||||
...genInputLargeStyle(token),
|
...genInputLargeStyle(token),
|
||||||
|
@ -11,6 +11,26 @@ const genTextAreaStyle: GenerateStyle<InputToken> = (token) => {
|
|||||||
const textareaPrefixCls = `${componentCls}-textarea`;
|
const textareaPrefixCls = `${componentCls}-textarea`;
|
||||||
|
|
||||||
return {
|
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]: {
|
[textareaPrefixCls]: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user