signal-desktop/ts/components/Input.tsx

270 lines
7.1 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ClipboardEvent, ReactNode } from 'react';
2021-07-19 19:26:06 +00:00
import React, {
forwardRef,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
import * as grapheme from '../util/grapheme';
import type { LocalizerType } from '../types/Util';
2021-07-19 19:26:06 +00:00
import { getClassNamesFor } from '../util/getClassNamesFor';
import { useRefMerger } from '../hooks/useRefMerger';
import { byteLength } from '../Bytes';
2021-07-19 19:26:06 +00:00
export type PropsType = {
countBytes?: (value: string) => number;
countLength?: (value: string) => number;
2021-07-19 19:26:06 +00:00
disabled?: boolean;
disableSpellcheck?: boolean;
2021-07-19 19:26:06 +00:00
expandable?: boolean;
hasClearButton?: boolean;
i18n: LocalizerType;
icon?: ReactNode;
maxByteCount?: number;
maxLengthCount?: number;
2021-07-19 19:26:06 +00:00
moduleClassName?: string;
onChange: (value: string) => unknown;
onEnter?: () => unknown;
2021-07-19 19:26:06 +00:00
placeholder: string;
value?: string;
whenToShowRemainingCount?: number;
2022-10-18 17:12:02 +00:00
children?: ReactNode;
2021-07-19 19:26:06 +00:00
};
/**
* Some inputs must have fewer than maxLengthCount glyphs. Ideally, we'd use the
2021-07-19 19:26:06 +00:00
* `maxLength` property on inputs, but that doesn't account for glyphs that are more than
* one UTF-16 code units. For example: `'💩💩'.length === 4`.
*
* This component effectively implements a "max grapheme length" on an input.
*
* At a high level, this component handles two methods of input:
*
* - `onChange`. *Before* the value is changed (in `onKeyDown`), we save the value and the
* cursor position. Then, in `onChange`, we see if the new value is too long. If it is,
* we revert the value and selection. Otherwise, we fire `onChangeValue`.
*
* - `onPaste`. If you're pasting something that will fit, we fall back to normal browser
* behavior, which calls `onChange`. If you're pasting something that won't fit, it's a
* noop.
*/
export const Input = forwardRef<
HTMLInputElement | HTMLTextAreaElement,
PropsType
2022-11-18 00:45:19 +00:00
>(function InputInner(
{
countBytes = byteLength,
countLength = grapheme.count,
disabled,
disableSpellcheck,
expandable,
hasClearButton,
i18n,
icon,
maxByteCount = 0,
maxLengthCount = 0,
moduleClassName,
onChange,
onEnter,
placeholder,
value = '',
whenToShowRemainingCount = Infinity,
children,
},
ref
) {
const innerRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const valueOnKeydownRef = useRef<string>(value);
const selectionStartOnKeydownRef = useRef<number>(value.length);
const [isLarge, setIsLarge] = useState(false);
const refMerger = useRefMerger();
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
const maybeSetLarge = useCallback(() => {
if (!expandable) {
return;
}
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
if (
inputEl.scrollHeight > inputEl.clientHeight ||
inputEl.scrollWidth > inputEl.clientWidth
) {
setIsLarge(true);
}
}, [expandable]);
const handleKeyDown = useCallback(
event => {
if (onEnter && event.key === 'Enter') {
onEnter();
2021-07-19 19:26:06 +00:00
}
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
2022-11-18 00:45:19 +00:00
valueOnKeydownRef.current = inputEl.value;
selectionStartOnKeydownRef.current = inputEl.selectionStart || 0;
},
[onEnter]
);
const handleChange = useCallback(() => {
const inputEl = innerRef.current;
if (!inputEl) {
return;
}
const newValue = inputEl.value;
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
const newLengthCount = maxLengthCount ? countLength(newValue) : 0;
const newByteCount = maxByteCount ? countBytes(newValue) : 0;
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
if (newLengthCount <= maxLengthCount && newByteCount <= maxByteCount) {
onChange(newValue);
} else {
inputEl.value = valueOnKeydownRef.current;
inputEl.selectionStart = selectionStartOnKeydownRef.current;
inputEl.selectionEnd = selectionStartOnKeydownRef.current;
}
2022-11-18 00:45:19 +00:00
maybeSetLarge();
}, [
countLength,
countBytes,
maxLengthCount,
maxByteCount,
maybeSetLarge,
onChange,
]);
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
const handlePaste = useCallback(
(event: ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
2021-07-19 19:26:06 +00:00
const inputEl = innerRef.current;
2022-11-18 00:45:19 +00:00
if (!inputEl || !maxLengthCount || !maxByteCount) {
2021-07-19 19:26:06 +00:00
return;
}
2022-11-18 00:45:19 +00:00
const selectionStart = inputEl.selectionStart || 0;
const selectionEnd = inputEl.selectionEnd || inputEl.selectionStart || 0;
const textBeforeSelection = value.slice(0, selectionStart);
const textAfterSelection = value.slice(selectionEnd);
const pastedText = event.clipboardData.getData('Text');
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
const newLengthCount =
countLength(textBeforeSelection) +
countLength(pastedText) +
countLength(textAfterSelection);
const newByteCount =
countBytes(textBeforeSelection) +
countBytes(pastedText) +
countBytes(textAfterSelection);
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
if (newLengthCount > maxLengthCount || newByteCount > maxByteCount) {
event.preventDefault();
2021-07-19 19:26:06 +00:00
}
maybeSetLarge();
2022-11-18 00:45:19 +00:00
},
[
countLength,
countBytes,
maxLengthCount,
maxByteCount,
maybeSetLarge,
2022-11-18 00:45:19 +00:00
value,
]
);
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
useEffect(() => {
maybeSetLarge();
}, [maybeSetLarge]);
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
const lengthCount = maxLengthCount ? countLength(value) : -1;
const getClassName = getClassNamesFor('Input', moduleClassName);
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
const inputProps = {
className: classNames(
getClassName('__input'),
icon && getClassName('__input--with-icon'),
isLarge && getClassName('__input--large'),
expandable && getClassName('__input--expandable')
),
disabled: Boolean(disabled),
spellCheck: !disableSpellcheck,
onChange: handleChange,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
placeholder,
ref: refMerger<HTMLInputElement | HTMLTextAreaElement | null>(
ref,
innerRef
),
type: 'text',
value,
};
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
const clearButtonElement =
hasClearButton && value ? (
<button
tabIndex={-1}
className={getClassName('__clear-icon')}
onClick={() => onChange('')}
type="button"
2023-03-30 00:03:25 +00:00
aria-label={i18n('icu:cancel')}
2022-11-18 00:45:19 +00:00
/>
) : null;
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
const lengthCountElement = lengthCount >= whenToShowRemainingCount && (
<div className={getClassName('__remaining-count')}>
{maxLengthCount - lengthCount}
</div>
);
2021-07-19 19:26:06 +00:00
2022-11-18 00:45:19 +00:00
return (
<div
className={classNames(
getClassName('__container'),
expandable && getClassName('__container--expandable'),
disabled && getClassName('__container--disabled')
)}
>
{icon ? <div className={getClassName('__icon')}>{icon}</div> : null}
{expandable ? (
<textarea rows={1} {...inputProps} />
) : (
<input {...inputProps} />
)}
{isLarge ? (
<>
2021-07-19 19:26:06 +00:00
<div className={getClassName('__controls')}>
{clearButtonElement}
2022-10-18 17:12:02 +00:00
{children}
2021-07-19 19:26:06 +00:00
</div>
2022-11-18 00:45:19 +00:00
<div className={getClassName('__remaining-count--large')}>
{lengthCountElement}
</div>
</>
) : (
<div className={getClassName('__controls')}>
{lengthCountElement}
{clearButtonElement}
{children}
</div>
)}
</div>
);
});