init project
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled

This commit is contained in:
how2ice
2025-12-12 14:26:25 +09:00
commit 005cf56baf
43188 changed files with 1079531 additions and 0 deletions

View File

@@ -0,0 +1,506 @@
import * as React from 'react';
import { expect } from 'chai';
import sinon, { spy, stub } from 'sinon';
import { act, screen, waitFor, createRenderer, fireEvent, isJsdom } from '@mui/internal-test-utils';
import TextareaAutosize from '@mui/material/TextareaAutosize';
function getStyleValue(value: string) {
return parseInt(value, 10) || 0;
}
// TODO: merge into a shared test helpers.
// MUI X already have one under mui-x/test/utils/helperFn.ts
function sleep(duration: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => {
resolve();
}, duration);
});
}
async function raf() {
return new Promise<void>((resolve) => {
// Chrome and Safari have a bug where calling rAF once returns the current
// frame instead of the next frame, so we need to call a double rAF here.
// See crbug.com/675795 for more.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
resolve();
});
});
});
}
describe('<TextareaAutosize />', () => {
const { clock, render } = createRenderer();
// For https://github.com/mui/material-ui/pull/33238
it('should not crash when unmounting with Suspense', async () => {
const LazyRoute = React.lazy(() => {
// Force react to show fallback suspense
return new Promise<any>((resolve) => {
setTimeout(() => {
resolve({
default: () => <div>LazyRoute</div>,
});
}, 0);
});
});
function App() {
const [toggle, setToggle] = React.useState(false);
return (
<React.Suspense fallback={null}>
<button onClick={() => setToggle((r) => !r)}>Toggle</button>
{toggle ? <LazyRoute /> : <TextareaAutosize />}
</React.Suspense>
);
}
render(<App />);
const button = screen.getByRole('button');
fireEvent.click(button);
await waitFor(() => {
expect(screen.queryByText('LazyRoute')).not.to.equal(null);
});
});
// For https://github.com/mui/material-ui/pull/33253
it('should update height without an infinite rendering loop', async () => {
function App() {
const [value, setValue] = React.useState('Controlled');
const handleChange = (event: React.ChangeEvent<any>) => {
setValue(event.target.value);
};
return <TextareaAutosize value={value} onChange={handleChange} />;
}
render(<App />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
act(() => {
input.focus();
});
const activeElement = document.activeElement!;
// set the value of the input to be 1 larger than its content width
fireEvent.change(activeElement, {
target: { value: 'Controlled\n' },
});
await sleep(0);
fireEvent.change(activeElement, {
target: { value: 'Controlled\n\n' },
});
});
// For https://github.com/mui/material-ui/pull/37135
// It depends on ResizeObserver
it.skipIf(isJsdom())('should update height without delay', async function test() {
function App() {
const ref = React.useRef<HTMLTextAreaElement>(null);
return (
<div>
<button
onClick={() => {
ref.current!.style.width = '250px';
}}
>
change
</button>
<div>
<TextareaAutosize
ref={ref}
style={{
width: 150,
padding: 0,
fontSize: 14,
lineHeight: '15px',
border: '1px solid',
}}
defaultValue="qdzqzd qzd qzd qzd qz dqz"
/>
</div>
</div>
);
}
render(<App />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const button = screen.getByRole('button');
expect(parseInt(input.style.height, 10)).to.be.within(30, 32);
fireEvent.click(button);
await raf();
await raf();
expect(parseInt(input.style.height, 10)).to.be.within(15, 17);
});
describe.skipIf(!isJsdom())('layout', () => {
const getComputedStyleStub = new Map<Element, Partial<CSSStyleDeclaration>>();
function setLayout(
input: HTMLTextAreaElement,
shadow: Element,
{
getComputedStyle,
scrollHeight: scrollHeightArg,
lineHeight: lineHeightArg,
}: {
getComputedStyle: Partial<CSSStyleDeclaration>;
scrollHeight?: number | (() => number);
lineHeight?: number | (() => number);
},
) {
const lineHeight = typeof lineHeightArg === 'function' ? lineHeightArg : () => lineHeightArg;
const scrollHeight =
typeof scrollHeightArg === 'function' ? scrollHeightArg : () => scrollHeightArg;
getComputedStyleStub.set(input, getComputedStyle);
let index = 0;
stub(shadow, 'scrollHeight').get(() => {
index += 1;
return index % 2 === 1 ? scrollHeight() : lineHeight();
});
}
beforeAll(function beforeHook() {
stub(window, 'getComputedStyle').value(
(node: Element) => getComputedStyleStub.get(node) || {},
);
});
afterAll(() => {
sinon.restore();
});
describe('resize', () => {
clock.withFakeTimers();
it('should handle the resize event', () => {
render(<TextareaAutosize />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
expect(input.style).to.have.property('height', '0px');
expect(input.style).to.have.property('overflow', 'hidden');
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'content-box',
},
scrollHeight: 30,
lineHeight: 15,
});
window.dispatchEvent(new window.Event('resize', {}));
clock.tick(166);
expect(input.style).to.have.property('height', '30px');
expect(input.style).to.have.property('overflow', 'hidden');
});
});
it('should update when uncontrolled', () => {
const handleChange = spy();
render(<TextareaAutosize onChange={handleChange} />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
expect(input.style).to.have.property('height', '0px');
expect(input.style).to.have.property('overflow', 'hidden');
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'content-box',
},
scrollHeight: 30,
lineHeight: 15,
});
act(() => {
input.focus();
});
const activeElement = document.activeElement!;
fireEvent.change(activeElement, { target: { value: 'a' } });
expect(input.style).to.have.property('height', '30px');
expect(input.style).to.have.property('overflow', 'hidden');
expect(handleChange.callCount).to.equal(1);
});
it('should take the border into account with border-box', () => {
const border = 5;
const { forceUpdate } = render(<TextareaAutosize />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
expect(input.style).to.have.property('height', '0px');
expect(input.style).to.have.property('overflow', 'hidden');
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'border-box',
borderBottomWidth: `${border}px`,
},
scrollHeight: 30,
lineHeight: 15,
});
forceUpdate();
expect(input.style).to.have.property('height', `${30 + border}px`);
expect(input.style).to.have.property('overflow', 'hidden');
});
it('should take the padding into account with content-box', () => {
const padding = 5;
const { forceUpdate } = render(<TextareaAutosize />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'border-box',
paddingTop: `${padding}px`,
},
scrollHeight: 30,
lineHeight: 15,
});
forceUpdate();
expect(input.style).to.have.property('height', `${30 + padding}px`);
expect(input.style).to.have.property('overflow', 'hidden');
});
it('should have at least height of "minRows"', () => {
const minRows = 3;
const lineHeight = 15;
const { forceUpdate } = render(<TextareaAutosize minRows={minRows} />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'content-box',
},
scrollHeight: 30,
lineHeight,
});
forceUpdate();
expect(input.style).to.have.property('height', `${lineHeight * minRows}px`);
expect(input.style).to.have.property('overflow', '');
});
it('should have at max "maxRows" rows', () => {
const maxRows = 3;
const lineHeight = 15;
const { forceUpdate } = render(<TextareaAutosize maxRows={maxRows} />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'content-box',
},
scrollHeight: 100,
lineHeight,
});
forceUpdate();
expect(input.style).to.have.property('height', `${lineHeight * maxRows}px`);
expect(input.style).to.have.property('overflow', '');
});
it('should show scrollbar when having more rows than "maxRows"', () => {
const maxRows = 3;
const lineHeight = 15;
const { forceUpdate } = render(<TextareaAutosize maxRows={maxRows} />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'border-box',
},
scrollHeight: lineHeight * 2,
lineHeight,
});
forceUpdate();
expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
expect(input.style).to.have.property('overflow', 'hidden');
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'border-box',
},
scrollHeight: lineHeight * 3,
lineHeight,
});
forceUpdate();
expect(input.style).to.have.property('height', `${lineHeight * 3}px`);
expect(input.style).to.have.property('overflow', 'hidden');
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'border-box',
},
scrollHeight: lineHeight * 4,
lineHeight,
});
forceUpdate();
expect(input.style).to.have.property('height', `${lineHeight * 3}px`);
expect(input.style).to.have.property('overflow', '');
});
it('should update its height when the "maxRows" prop changes', () => {
const lineHeight = 15;
const { forceUpdate, setProps } = render(<TextareaAutosize maxRows={3} />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'content-box',
},
scrollHeight: 100,
lineHeight,
});
forceUpdate();
expect(input.style).to.have.property('height', `${lineHeight * 3}px`);
expect(input.style).to.have.property('overflow', '');
setProps({ maxRows: 2 });
expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
expect(input.style).to.have.property('overflow', '');
});
it('should not sync height if container width is 0px', () => {
const lineHeight = 15;
const { forceUpdate } = render(<TextareaAutosize />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'content-box',
},
scrollHeight: lineHeight * 2,
lineHeight,
});
forceUpdate();
expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
expect(input.style).to.have.property('overflow', 'hidden');
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'content-box',
width: '0px',
},
scrollHeight: lineHeight * 3,
lineHeight,
});
forceUpdate();
expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
expect(input.style).to.have.property('overflow', 'hidden');
});
it('should compute the correct height if padding-right is greater than 0px', () => {
const paddingRight = 50;
const { forceUpdate } = render(<TextareaAutosize style={{ paddingRight }} />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
const shadow = screen.getAllByRole<HTMLTextAreaElement>('textbox', {
hidden: true,
})[1];
const contentWidth = 100;
const lineHeight = 15;
const width = contentWidth + paddingRight;
setLayout(input, shadow, {
getComputedStyle: {
boxSizing: 'border-box',
width: `${width}px`,
},
scrollHeight: () => {
// assuming that the width of the word is 1px, and subtract the width of the paddingRight
const lineNum = Math.ceil(
input.value.length / (width - getStyleValue(shadow.style.paddingRight)),
);
return lineNum * lineHeight;
},
lineHeight,
});
act(() => {
input.focus();
});
const activeElement = document.activeElement!;
// set the value of the input to be 1 larger than its content width
fireEvent.change(activeElement, {
target: { value: new Array(contentWidth + 1).fill('a').join('') },
});
forceUpdate();
// the input should be 2 lines
expect(input.style).to.have.property('height', `${lineHeight * 2}px`);
});
});
it.skipIf(isJsdom())('should apply the inline styles using the "style" prop', function test() {
render(<TextareaAutosize style={{ backgroundColor: 'yellow' }} />);
const input = screen.getByRole<HTMLTextAreaElement>('textbox', {
hidden: false,
});
expect(input).toHaveComputedStyle({
backgroundColor: 'rgb(255, 255, 0)',
});
});
// edge case: https://github.com/mui/material-ui/issues/45307
// document selectionchange event doesn't fire in JSDOM
it.skipIf(isJsdom())('should not infinite loop document selectionchange', async function test() {
const handleSelectionChange = spy();
function App() {
React.useEffect(() => {
document.addEventListener('selectionchange', handleSelectionChange);
return () => {
document.removeEventListener('selectionchange', handleSelectionChange);
};
}, []);
return (
<TextareaAutosize defaultValue="some long text that makes the input start with multiple rows" />
);
}
render(<App />);
await sleep(100);
// when the component mounts and idles this fires 3 times in browser tests
// and 2 times in a real browser
expect(handleSelectionChange.callCount).to.lessThanOrEqual(3);
});
});

View File

@@ -0,0 +1,295 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import debounce from '@mui/utils/debounce';
import useForkRef from '@mui/utils/useForkRef';
import useEnhancedEffect from '@mui/utils/useEnhancedEffect';
import useEventCallback from '@mui/utils/useEventCallback';
import ownerWindow from '@mui/utils/ownerWindow';
import { TextareaAutosizeProps } from './TextareaAutosize.types';
function getStyleValue(value: string) {
return parseInt(value, 10) || 0;
}
const styles: {
shadow: React.CSSProperties;
} = {
shadow: {
// Visibility needed to hide the extra text area on iPads
visibility: 'hidden',
// Remove from the content flow
position: 'absolute',
// Ignore the scrollbar width
overflow: 'hidden',
height: 0,
top: 0,
left: 0,
// Create a new layer, increase the isolation of the computed values
transform: 'translateZ(0)',
},
};
type TextareaStyles = {
outerHeightStyle: number;
overflowing: boolean;
};
function isObjectEmpty(object: TextareaStyles) {
// eslint-disable-next-line
for (const _ in object) {
return false;
}
return true;
}
function isEmpty(obj: TextareaStyles) {
return isObjectEmpty(obj) || (obj.outerHeightStyle === 0 && !obj.overflowing);
}
/**
*
* Demos:
*
* - [Textarea Autosize](https://mui.com/material-ui/react-textarea-autosize/)
*
* API:
*
* - [TextareaAutosize API](https://mui.com/material-ui/api/textarea-autosize/)
*/
const TextareaAutosize = React.forwardRef(function TextareaAutosize(
props: TextareaAutosizeProps,
forwardedRef: React.ForwardedRef<Element>,
) {
const { onChange, maxRows, minRows = 1, style, value, ...other } = props;
const { current: isControlled } = React.useRef(value != null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const handleRef = useForkRef(forwardedRef, textareaRef);
const heightRef = React.useRef<number>(null);
const hiddenTextareaRef = React.useRef<HTMLTextAreaElement>(null);
const calculateTextareaStyles = React.useCallback(() => {
const textarea = textareaRef.current;
const hiddenTextarea = hiddenTextareaRef.current;
if (!textarea || !hiddenTextarea) {
return undefined;
}
const containerWindow = ownerWindow(textarea);
const computedStyle = containerWindow.getComputedStyle(textarea);
// If input's width is shrunk and it's not visible, don't sync height.
if (computedStyle.width === '0px') {
return {
outerHeightStyle: 0,
overflowing: false,
};
}
hiddenTextarea.style.width = computedStyle.width;
hiddenTextarea.value = textarea.value || props.placeholder || 'x';
if (hiddenTextarea.value.slice(-1) === '\n') {
// Certain fonts which overflow the line height will cause the textarea
// to report a different scrollHeight depending on whether the last line
// is empty. Make it non-empty to avoid this issue.
hiddenTextarea.value += ' ';
}
const boxSizing = computedStyle.boxSizing;
const padding =
getStyleValue(computedStyle.paddingBottom) + getStyleValue(computedStyle.paddingTop);
const border =
getStyleValue(computedStyle.borderBottomWidth) + getStyleValue(computedStyle.borderTopWidth);
// The height of the inner content
const innerHeight = hiddenTextarea.scrollHeight;
// Measure height of a textarea with a single row
hiddenTextarea.value = 'x';
const singleRowHeight = hiddenTextarea.scrollHeight;
// The height of the outer content
let outerHeight = innerHeight;
if (minRows) {
outerHeight = Math.max(Number(minRows) * singleRowHeight, outerHeight);
}
if (maxRows) {
outerHeight = Math.min(Number(maxRows) * singleRowHeight, outerHeight);
}
outerHeight = Math.max(outerHeight, singleRowHeight);
// Take the box sizing into account for applying this value as a style.
const outerHeightStyle = outerHeight + (boxSizing === 'border-box' ? padding + border : 0);
const overflowing = Math.abs(outerHeight - innerHeight) <= 1;
return { outerHeightStyle, overflowing };
}, [maxRows, minRows, props.placeholder]);
const didHeightChange = useEventCallback(() => {
const textarea = textareaRef.current;
const textareaStyles = calculateTextareaStyles();
if (!textarea || !textareaStyles || isEmpty(textareaStyles)) {
return false;
}
const outerHeightStyle = textareaStyles.outerHeightStyle;
return heightRef.current != null && heightRef.current !== outerHeightStyle;
});
const syncHeight = React.useCallback(() => {
const textarea = textareaRef.current;
const textareaStyles = calculateTextareaStyles();
if (!textarea || !textareaStyles || isEmpty(textareaStyles)) {
return;
}
const outerHeightStyle = textareaStyles.outerHeightStyle;
if (heightRef.current !== outerHeightStyle) {
heightRef.current = outerHeightStyle;
textarea.style.height = `${outerHeightStyle}px`;
}
textarea.style.overflow = textareaStyles.overflowing ? 'hidden' : '';
}, [calculateTextareaStyles]);
const frameRef = React.useRef(-1);
useEnhancedEffect(() => {
const debouncedHandleResize = debounce(syncHeight);
const textarea = textareaRef?.current;
if (!textarea) {
return undefined;
}
const containerWindow = ownerWindow(textarea);
containerWindow.addEventListener('resize', debouncedHandleResize);
let resizeObserver: ResizeObserver;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
if (didHeightChange()) {
// avoid "ResizeObserver loop completed with undelivered notifications" error
// by temporarily unobserving the textarea element while manipulating the height
// and reobserving one frame later
resizeObserver.unobserve(textarea);
cancelAnimationFrame(frameRef.current);
syncHeight();
frameRef.current = requestAnimationFrame(() => {
resizeObserver.observe(textarea);
});
}
});
resizeObserver.observe(textarea);
}
return () => {
debouncedHandleResize.clear();
cancelAnimationFrame(frameRef.current);
containerWindow.removeEventListener('resize', debouncedHandleResize);
if (resizeObserver) {
resizeObserver.disconnect();
}
};
}, [calculateTextareaStyles, syncHeight, didHeightChange]);
useEnhancedEffect(() => {
syncHeight();
});
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!isControlled) {
syncHeight();
}
const textarea = event.target;
const countOfCharacters = textarea.value.length;
const isLastCharacterNewLine = textarea.value.endsWith('\n');
const isEndOfTheLine = textarea.selectionStart === countOfCharacters;
// Set the cursor position to the very end of the text.
if (isLastCharacterNewLine && isEndOfTheLine) {
textarea.setSelectionRange(countOfCharacters, countOfCharacters);
}
if (onChange) {
onChange(event);
}
};
return (
<React.Fragment>
<textarea
value={value}
onChange={handleChange}
ref={handleRef}
// Apply the rows prop to get a "correct" first SSR paint
rows={minRows as number}
style={style}
{...other}
/>
<textarea
aria-hidden
className={props.className}
readOnly
ref={hiddenTextareaRef}
tabIndex={-1}
style={{
...styles.shadow,
...style,
paddingTop: 0,
paddingBottom: 0,
}}
/>
</React.Fragment>
);
}) as React.ForwardRefExoticComponent<TextareaAutosizeProps & React.RefAttributes<Element>>;
TextareaAutosize.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
className: PropTypes.string,
/**
* Maximum number of rows to display.
*/
maxRows: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* Minimum number of rows to display.
* @default 1
*/
minRows: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* @ignore
*/
onChange: PropTypes.func,
/**
* @ignore
*/
placeholder: PropTypes.string,
/**
* @ignore
*/
style: PropTypes.object,
/**
* @ignore
*/
value: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.string),
PropTypes.number,
PropTypes.string,
]),
} as any;
export default TextareaAutosize;

View File

@@ -0,0 +1,15 @@
import * as React from 'react';
export interface TextareaAutosizeProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'children' | 'rows'> {
ref?: React.Ref<HTMLTextAreaElement>;
/**
* Maximum number of rows to display.
*/
maxRows?: string | number;
/**
* Minimum number of rows to display.
* @default 1
*/
minRows?: string | number;
}

View File

@@ -0,0 +1,3 @@
export { default } from './TextareaAutosize';
export * from './TextareaAutosize';
export * from './TextareaAutosize.types';

View File

@@ -0,0 +1 @@
export { default } from './TextareaAutosize';