Class to functional component example 1 (#24181)

* Class to functional component example 1

* Fix lint and tests

* Fix

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García 2023-10-05 09:39:54 +02:00 committed by GitHub
parent 45ccd50f8c
commit e3823a3263
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 215 additions and 160 deletions

View File

@ -36,8 +36,24 @@ exports[`components/TextBox should match snapshot with required props 1`] = `
id="someid"
inputComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"render": [Function],
"$$typeof": Symbol(react.memo),
"compare": null,
"type": Object {
"$$typeof": Symbol(react.forward_ref),
"propTypes": Object {
"className": [Function],
"defaultValue": [Function],
"disabled": [Function],
"id": [Function],
"onChange": [Function],
"onHeightChange": [Function],
"onInput": [Function],
"onWidthChange": [Function],
"placeholder": [Function],
"value": [Function],
},
"render": [Function],
},
}
}
listComponent={[Function]}
@ -127,8 +143,24 @@ exports[`components/TextBox should throw error when new property is too long 1`]
id="someid"
inputComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"render": [Function],
"$$typeof": Symbol(react.memo),
"compare": null,
"type": Object {
"$$typeof": Symbol(react.forward_ref),
"propTypes": Object {
"className": [Function],
"defaultValue": [Function],
"disabled": [Function],
"id": [Function],
"onChange": [Function],
"onHeightChange": [Function],
"onInput": [Function],
"onWidthChange": [Function],
"placeholder": [Function],
"value": [Function],
},
"render": [Function],
},
}
}
listComponent={[Function]}
@ -218,8 +250,24 @@ exports[`components/TextBox should throw error when value is too long 1`] = `
id="someid"
inputComponent={
Object {
"$$typeof": Symbol(react.forward_ref),
"render": [Function],
"$$typeof": Symbol(react.memo),
"compare": null,
"type": Object {
"$$typeof": Symbol(react.forward_ref),
"propTypes": Object {
"className": [Function],
"defaultValue": [Function],
"disabled": [Function],
"id": [Function],
"onChange": [Function],
"onHeightChange": [Function],
"onInput": [Function],
"onWidthChange": [Function],
"placeholder": [Function],
"value": [Function],
},
"render": [Function],
},
}
}
listComponent={[Function]}

View File

@ -4,7 +4,7 @@
import {shallow} from 'enzyme';
import React from 'react';
import {AutosizeTextarea} from 'components/autosize_textarea';
import AutosizeTextarea from 'components/autosize_textarea';
describe('components/AutosizeTextarea', () => {
test('should match snapshot, init', () => {

View File

@ -1,8 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {ChangeEvent, FormEvent, CSSProperties} from 'react';
import type {ChangeEvent, FormEvent, HTMLProps} from 'react';
import React, {useRef, useEffect, useCallback} from 'react';
import type {Intersection} from '@mattermost/types/utilities';
type Props = {
id?: string;
@ -15,191 +17,173 @@ type Props = {
onWidthChange?: (width: number) => void;
onInput?: (e: FormEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
forwardedRef?: ((instance: HTMLTextAreaElement | null) => void) | React.MutableRefObject<HTMLTextAreaElement | null> | null;
}
} & Intersection<HTMLProps<HTMLTextAreaElement>, HTMLProps<HTMLDivElement>>;
export class AutosizeTextarea extends React.PureComponent<Props> {
private height: number;
const styles = {
container: {
height: 0,
overflow: 'hidden',
},
reference: {
display: 'inline-block',
height: 'auto',
width: 'auto',
},
placeholder: {
overflow: 'hidden',
textOverflow: 'ellipsis',
opacity: 0.5,
pointerEvents: 'none' as const,
position: 'absolute' as const,
whiteSpace: 'nowrap' as const,
background: 'none',
borderColor: 'transparent',
},
};
private textarea?: HTMLTextAreaElement;
private referenceRef: React.RefObject<HTMLDivElement>;
const AutosizeTextarea = React.forwardRef<HTMLTextAreaElement, Props>(({
constructor(props: Props) {
super(props);
// TODO: The provided `id` is sometimes hard-coded and used to interface with the
// component, e.g. `post_textbox`, so it can't be changed. This would ideally be
// abstracted to avoid passing in an `id` prop at all, but we intentionally maintain
// the old behaviour to address ABC-213.
id = 'autosize_textarea',
disabled,
value,
defaultValue,
onChange,
onHeightChange,
onWidthChange,
onInput,
placeholder,
...otherProps
}: Props, ref) => {
const height = useRef(0);
const textarea = useRef<HTMLTextAreaElement>();
const referenceRef = useRef<HTMLDivElement>(null);
this.height = 0;
this.referenceRef = React.createRef();
}
componentDidMount() {
this.recalculateHeight();
this.recalculateWidth();
}
componentDidUpdate() {
this.recalculateHeight();
this.recalculateWidth();
}
private recalculateHeight = () => {
if (!this.referenceRef.current || !this.textarea) {
const recalculateHeight = () => {
if (!referenceRef.current || !textarea.current) {
return;
}
const height = (this.referenceRef.current).scrollHeight;
const textarea = this.textarea;
const scrollHeight = referenceRef.current.scrollHeight;
const currentTextarea = textarea.current;
if (height > 0 && height !== this.height) {
const style = getComputedStyle(textarea);
if (scrollHeight > 0 && scrollHeight !== height.current) {
const style = getComputedStyle(currentTextarea);
// Directly change the height to avoid circular rerenders
textarea.style.height = `${height}px`;
currentTextarea.style.height = `${scrollHeight}px`;
this.height = height;
height.current = scrollHeight;
this.props.onHeightChange?.(height, parseInt(style.maxHeight || '0', 10));
onHeightChange?.(scrollHeight, parseInt(style.maxHeight || '0', 10));
}
};
private recalculateWidth = () => {
if (!this.referenceRef) {
const recalculateWidth = () => {
if (!referenceRef.current) {
return;
}
const width = this.referenceRef.current?.offsetWidth || -1;
const width = referenceRef.current?.offsetWidth || -1;
if (width >= 0) {
window.requestAnimationFrame(() => {
this.props.onWidthChange?.(width);
onWidthChange?.(width);
});
}
};
private setTextareaRef = (textarea: HTMLTextAreaElement) => {
if (this.props.forwardedRef) {
if (typeof this.props.forwardedRef === 'function') {
this.props.forwardedRef(textarea);
const setTextareaRef = useCallback((textareaRef: HTMLTextAreaElement) => {
if (ref) {
if (typeof ref === 'function') {
ref(textareaRef);
} else {
this.props.forwardedRef.current = textarea;
ref.current = textareaRef;
}
}
this.textarea = textarea;
textarea.current = textareaRef;
}, [ref]);
useEffect(() => {
recalculateHeight();
recalculateWidth();
});
const heightProps = {
rows: 0,
height: 0,
};
render() {
const props = {...this.props};
if (height.current <= 0) {
// Set an initial number of rows so that the textarea doesn't appear too large when its first rendered
heightProps.rows = 1;
} else {
heightProps.height = height.current;
}
Reflect.deleteProperty(props, 'onHeightChange');
Reflect.deleteProperty(props, 'providers');
Reflect.deleteProperty(props, 'channelId');
Reflect.deleteProperty(props, 'forwardedRef');
const {
value,
defaultValue,
placeholder,
disabled,
onInput,
// TODO: The provided `id` is sometimes hard-coded and used to interface with the
// component, e.g. `post_textbox`, so it can't be changed. This would ideally be
// abstracted to avoid passing in an `id` prop at all, but we intentionally maintain
// the old behaviour to address ABC-213.
id = 'autosize_textarea',
...otherProps
} = props;
const heightProps = {
rows: 0,
height: 0,
};
Reflect.deleteProperty(otherProps, 'onWidthChange');
if (this.height <= 0) {
// Set an initial number of rows so that the textarea doesn't appear too large when its first rendered
heightProps.rows = 1;
} else {
heightProps.height = this.height;
}
let textareaPlaceholder = null;
const placeholderAriaLabel = placeholder ? placeholder.toLowerCase() : '';
if (!this.props.value && !this.props.defaultValue) {
textareaPlaceholder = (
<div
{...otherProps as any}
id={`${id}_placeholder`}
data-testid={`${id}_placeholder`}
style={styles.placeholder}
>
{placeholder}
</div>
);
}
let referenceValue = value || defaultValue;
if (referenceValue?.endsWith('\n')) {
// In a div, the browser doesn't always count characters at the end of a line when measuring the dimensions
// of text. In the spec, they refer to those characters as "hanging". No matter what value we set for the
// `white-space` of a div, a single newline at the end of the div will always hang.
//
// The textarea doesn't have that behaviour, so we need to trick the reference div into measuring that
// newline, and it seems like the best way to do that is by adding a second newline because only the final
// one hangs.
referenceValue += '\n';
}
return (
<div>
{textareaPlaceholder}
<textarea
ref={this.setTextareaRef}
data-testid={id}
id={id}
{...heightProps}
{...otherProps}
role='textbox'
aria-label={placeholderAriaLabel}
dir='auto'
disabled={disabled}
onChange={this.props.onChange}
onInput={onInput}
value={value}
defaultValue={defaultValue}
/>
<div style={styles.container}>
<div
ref={this.referenceRef}
id={id + '-reference'}
className={otherProps.className}
style={styles.reference}
dir='auto'
disabled={true}
aria-hidden={true}
>
{referenceValue}
</div>
</div>
let textareaPlaceholder = null;
const placeholderAriaLabel = placeholder ? placeholder.toLowerCase() : '';
if (!value && !defaultValue) {
textareaPlaceholder = (
<div
{...otherProps}
id={`${id}_placeholder`}
data-testid={`${id}_placeholder`}
style={styles.placeholder}
>
{placeholder}
</div>
);
}
}
const styles: { [Key: string]: CSSProperties} = {
container: {height: 0, overflow: 'hidden'},
reference: {display: 'inline-block', height: 'auto', width: 'auto'},
placeholder: {overflow: 'hidden', textOverflow: 'ellipsis', opacity: 0.5, pointerEvents: 'none', position: 'absolute', whiteSpace: 'nowrap', background: 'none', borderColor: 'transparent'},
};
let referenceValue = value || defaultValue;
if (referenceValue?.endsWith('\n')) {
// In a div, the browser doesn't always count characters at the end of a line when measuring the dimensions
// of text. In the spec, they refer to those characters as "hanging". No matter what value we set for the
// `white-space` of a div, a single newline at the end of the div will always hang.
//
// The textarea doesn't have that behaviour, so we need to trick the reference div into measuring that
// newline, and it seems like the best way to do that is by adding a second newline because only the final
// one hangs.
referenceValue += '\n';
}
const forwarded = React.forwardRef<HTMLTextAreaElement>((props, ref) => (
<AutosizeTextarea
forwardedRef={ref}
{...props}
/>
));
return (
<div>
{textareaPlaceholder}
<textarea
ref={setTextareaRef}
data-testid={id}
id={id}
{...heightProps}
{...otherProps}
role='textbox'
aria-label={placeholderAriaLabel}
dir='auto'
disabled={disabled}
onChange={onChange}
onInput={onInput}
value={value}
defaultValue={defaultValue}
/>
<div style={styles.container}>
<div
ref={referenceRef}
id={id + '-reference'}
className={otherProps.className}
style={styles.reference}
dir='auto'
disabled={true}
aria-hidden={true}
>
{referenceValue}
</div>
</div>
</div>
);
});
forwarded.displayName = 'AutosizeTextarea';
export default forwarded;
export default React.memo(AutosizeTextarea);

View File

@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Disable consistent return since the effectCallback allows for non consistent returns.
/* eslint-disable consistent-return */
import {useEffect, useRef} from 'react';
const useDidUpdate: typeof useEffect = (effect, deps) => {
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) {
return effect();
}
mounted.current = true;
}, deps);
};
export default useDidUpdate;

View File

@ -24,3 +24,6 @@ export type ValueOf<T> = T[keyof T];
*/
export type RequireOnlyOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>> & {[K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>}[Keys];
export type Intersection<T1, T2> =
Omit<Omit<T1&T2, keyof(Omit<T1, keyof(T2)>)>, keyof(Omit<T2, keyof(T1)>)>;