Merge branch 'input-rtl' into 'main'

Add RTL support to Input and Textarea, delete AutosuggestTextarea

Closes #1558

See merge request soapbox-pub/soapbox!2798
environments/review-main-yi2y9f/deployments/4146
Alex Gleason 2023-10-11 19:50:11 +00:00
commit 04214c5075
11 zmienionych plików z 30 dodań i 368 usunięć

Wyświetl plik

@ -152,7 +152,6 @@
"react-sparklines": "^1.7.0",
"react-sticky-box": "^2.0.0",
"react-swipeable-views": "^0.14.0",
"react-textarea-autosize": "^8.3.4",
"react-virtuoso": "^4.3.11",
"redux": "^4.1.1",
"redux-immutable": "^4.0.0",

Wyświetl plik

@ -7,7 +7,6 @@ import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji';
import Icon from 'soapbox/components/icon';
import { Input, Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/utils/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import type { Menu, MenuItem } from 'soapbox/components/dropdown-menu';
@ -264,15 +263,9 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
render() {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength, menu, theme } = this.props;
const { suggestionsHidden } = this.state;
const style: React.CSSProperties = { direction: 'ltr' };
const visible = !suggestionsHidden && (!suggestions.isEmpty() || (menu && value));
// TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text.
if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) {
style.direction = 'rtl';
}
return [
<div key='input' className='relative w-full'>
<label className='sr-only'>{placeholder}</label>
@ -291,7 +284,6 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
style={style}
aria-autocomplete='list'
id={id}
maxLength={maxLength}

Wyświetl plik

@ -1,288 +0,0 @@
import clsx from 'clsx';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize';
import { Portal } from 'soapbox/components/ui';
import AutosuggestAccount from 'soapbox/features/compose/components/autosuggest-account';
import { isRtl } from 'soapbox/utils/rtl';
import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions';
import AutosuggestEmoji from './autosuggest-emoji';
import type { List as ImmutableList } from 'immutable';
import type { Emoji } from 'soapbox/features/emoji';
interface IAutosuggesteTextarea {
id?: string;
value: string;
suggestions: ImmutableList<string>;
disabled: boolean;
placeholder: string;
onSuggestionSelected: (tokenStart: number, token: string | null, value: string | undefined) => void;
onSuggestionsClearRequested: () => void;
onSuggestionsFetchRequested: (token: string | number) => void;
onChange: React.ChangeEventHandler<HTMLTextAreaElement>;
onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
onPaste: (files: FileList) => void;
autoFocus: boolean;
onFocus: () => void;
onBlur?: () => void;
condensed?: boolean;
children: React.ReactNode;
}
class AutosuggestTextarea extends ImmutablePureComponent<IAutosuggesteTextarea> {
textarea: HTMLTextAreaElement | null = null;
static defaultProps = {
autoFocus: true,
};
state = {
suggestionsHidden: true,
focused: false,
selectedSuggestion: 0,
lastToken: null,
tokenStart: 0,
};
onChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
const [tokenStart, token] = textAtCursorMatchesToken(
e.target.value,
e.target.selectionStart,
['@', ':', '#'],
);
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
} else if (token === null) {
this.setState({ lastToken: null });
this.props.onSuggestionsClearRequested();
}
this.props.onChange(e);
};
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
const { suggestions, disabled } = this.props;
const { selectedSuggestion, suggestionsHidden } = this.state;
if (disabled) {
e.preventDefault();
return;
}
if (e.which === 229 || (e as any).isComposing) {
// Ignore key events during text composition
// e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
return;
}
switch (e.key) {
case 'Escape':
if (suggestions.size === 0 || suggestionsHidden) {
document.querySelector('.ui')?.parentElement?.focus();
} else {
e.preventDefault();
this.setState({ suggestionsHidden: true });
}
break;
case 'ArrowDown':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
}
break;
case 'ArrowUp':
if (suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
}
break;
case 'Enter':
case 'Tab':
// Select suggestion
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
e.preventDefault();
e.stopPropagation();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
}
break;
}
if (e.defaultPrevented || !this.props.onKeyDown) {
return;
}
this.props.onKeyDown(e);
};
onBlur = () => {
this.setState({ suggestionsHidden: true, focused: false });
if (this.props.onBlur) {
this.props.onBlur();
}
};
onFocus = () => {
this.setState({ focused: true });
if (this.props.onFocus) {
this.props.onFocus();
}
};
onSuggestionClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index') as any);
e.preventDefault();
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
this.textarea?.focus();
};
shouldComponentUpdate(nextProps: IAutosuggesteTextarea, nextState: any) {
// Skip updating when only the lastToken changes so the
// cursor doesn't jump around due to re-rendering unnecessarily
const lastTokenUpdated = this.state.lastToken !== nextState.lastToken;
const valueUpdated = this.props.value !== nextProps.value;
if (lastTokenUpdated && !valueUpdated) {
return false;
} else {
// https://stackoverflow.com/a/35962835
return super.shouldComponentUpdate!.bind(this)(nextProps, nextState, undefined);
}
}
componentDidUpdate(prevProps: IAutosuggesteTextarea, prevState: any) {
const { suggestions } = this.props;
if (suggestions !== prevProps.suggestions && suggestions.size > 0 && prevState.suggestionsHidden && prevState.focused) {
this.setState({ suggestionsHidden: false });
}
}
setTextarea: React.Ref<HTMLTextAreaElement> = (c) => {
this.textarea = c;
};
onPaste: React.ClipboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.clipboardData && e.clipboardData.files.length === 1) {
this.props.onPaste(e.clipboardData.files);
e.preventDefault();
}
};
renderSuggestion = (suggestion: string | Emoji, i: number) => {
const { selectedSuggestion } = this.state;
let inner, key;
if (typeof suggestion === 'object') {
inner = <AutosuggestEmoji emoji={suggestion} />;
key = suggestion.id;
} else if (suggestion[0] === '#') {
inner = suggestion;
key = suggestion;
} else {
inner = <AutosuggestAccount id={suggestion} />;
key = suggestion;
}
return (
<div
role='button'
tabIndex={0}
key={key}
data-index={i}
className={clsx({
'px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 group': true,
'bg-gray-100 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-800': i === selectedSuggestion,
})}
onMouseDown={this.onSuggestionClick}
>
{inner}
</div>
);
};
setPortalPosition() {
if (!this.textarea) {
return {};
}
const { top, height, left, width } = this.textarea.getBoundingClientRect();
return {
top: top + height,
left,
width,
};
}
render() {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children, condensed, id } = this.props;
const { suggestionsHidden } = this.state;
const style = { direction: 'ltr', minRows: 10 };
// TODO: convert to functional component and use `useLocale()` hook instead of checking placeholder text.
if (isRtl(value) || (!value && placeholder && isRtl(placeholder))) {
style.direction = 'rtl';
}
return [
<div key='textarea'>
<div className='relative'>
<label>
<span style={{ display: 'none' }}>{placeholder}</span>
<Textarea
ref={this.setTextarea}
className={clsx('w-full resize-none border-0 px-0 text-gray-800 transition-[min-height] placeholder:text-gray-600 focus:border-0 focus:shadow-none focus:ring-0 motion-reduce:transition-none dark:bg-transparent dark:text-white dark:placeholder:text-gray-600', {
'min-h-[40px]': condensed,
'min-h-[100px]': !condensed,
})}
id={id}
disabled={disabled}
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur}
onPaste={this.onPaste}
style={style as any}
aria-autocomplete='list'
/>
</label>
</div>
{children}
</div>,
<Portal key='portal'>
<div
style={this.setPortalPosition()}
className={clsx({
'fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
hidden: suggestionsHidden || suggestions.isEmpty(),
block: !suggestionsHidden && !suggestions.isEmpty(),
})}
>
{suggestions.map(this.renderSuggestion)}
</div>
</Portal>,
];
}
}
export default AutosuggestTextarea;

Wyświetl plik

@ -2,6 +2,9 @@ import clsx from 'clsx';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useLocale } from 'soapbox/hooks';
import { getTextDirection } from 'soapbox/utils/rtl';
import Icon from '../icon/icon';
import SvgIcon from '../icon/svg-icon';
import Tooltip from '../tooltip/tooltip';
@ -45,6 +48,7 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
const Input = React.forwardRef<HTMLInputElement, IInput>(
(props, ref) => {
const intl = useIntl();
const locale = useLocale();
const { type = 'text', icon, className, outerClassName, append, prepend, theme = 'normal', ...filteredProps } = props;
@ -90,10 +94,11 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
'text-gray-600': props.disabled,
'rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800': theme === 'normal',
'rounded-full bg-gray-200 border-gray-200 dark:bg-gray-800 dark:border-gray-800 focus:bg-white': theme === 'search',
'pr-7 rtl:pl-7 rtl:pr-3': isPassword || append,
'pr-10 rtl:pl-10 rtl:pr-3': isPassword || append,
'pl-8': typeof icon !== 'undefined',
'pl-16': typeof prepend !== 'undefined',
}, className)}
dir={typeof props.value === 'string' ? getTextDirection(props.value, { fallback: locale.direction }) : undefined}
/>
{append ? (

Wyświetl plik

@ -2,10 +2,13 @@ import clsx from 'clsx';
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useLocale } from 'soapbox/hooks';
import { getTextDirection } from 'soapbox/utils/rtl';
import Stack from '../stack/stack';
import Text from '../text/text';
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'id' | 'maxLength' | 'onChange' | 'onKeyDown' | 'onPaste' | 'required' | 'disabled' | 'rows' | 'readOnly'> {
/** Put the cursor into the input on mount. */
autoFocus?: boolean;
/** Allows the textarea height to grow while typing */
@ -52,6 +55,7 @@ const Textarea = React.forwardRef(({
}: ITextarea, ref: React.ForwardedRef<HTMLTextAreaElement>) => {
const length = value?.length || 0;
const [rows, setRows] = useState<number>(autoGrow ? 1 : 4);
const locale = useLocale();
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
if (autoGrow) {
@ -94,6 +98,7 @@ const Textarea = React.forwardRef(({
'text-red-600 border-red-600': hasError,
'resize-none': !isResizeable,
})}
dir={value?.length ? getTextDirection(value, { fallback: locale.direction }) : undefined}
/>
{maxLength && (

Wyświetl plik

@ -41,7 +41,7 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => {
<Text weight='bold' size='sm' truncate>{account.display_name}</Text>
{account.verified && <VerificationBadge />}
</div>
<Text size='sm' weight='medium' theme='muted' truncate>@{account.acct}</Text>
<Text size='sm' weight='medium' theme='muted' direction='ltr' truncate>@{account.acct}</Text>
</Stack>
</HStack>
</button>

Wyświetl plik

@ -14,7 +14,6 @@ import {
uploadCompose,
} from 'soapbox/actions/compose';
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest-input';
import AutosuggestTextarea from 'soapbox/components/autosuggest-textarea';
import { Button, HStack, Stack } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/emoji/containers/emoji-picker-dropdown-container';
import { ComposeEditor } from 'soapbox/features/ui/util/async-components';
@ -82,8 +81,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
spoiler,
spoiler_text: spoilerText,
privacy,
focusDate,
caretPosition,
is_submitting: isSubmitting,
is_changing_upload:
isChangingUpload,
@ -104,7 +101,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
const firstRender = useRef(true);
const formRef = useRef<HTMLDivElement>(null);
const spoilerTextRef = useRef<AutosuggestInput>(null);
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
const editorRef = useRef<LexicalEditor>(null);
const { isDraggedOver } = useDraggedFiles(formRef);
@ -172,11 +168,6 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
};
const setCursor = (start: number, end: number = start) => {
if (!autosuggestTextareaRef.current?.textarea) return;
autosuggestTextareaRef.current.textarea.setSelectionRange(start, end);
};
const handleEmojiPick = (data: Emoji) => {
const editor = editorRef.current;
if (!editor) return;
@ -194,18 +185,9 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
spoilerTextRef.current?.input?.focus();
};
const focusTextarea = () => {
autosuggestTextareaRef.current?.textarea?.focus();
};
useEffect(() => {
const length = text.length;
document.addEventListener('click', handleClick, true);
if (length > 0) {
setCursor(length); // Set cursor at end
}
return () => {
document.removeEventListener('click', handleClick, true);
};
@ -213,21 +195,14 @@ const ComposeForm = <ID extends string>({ id, shouldCondense, autoFocus, clickab
useEffect(() => {
if (spoiler && firstRender.current) {
focusTextarea();
firstRender.current = false;
} else if (!spoiler && prevSpoiler) {
focusTextarea();
//
} else if (spoiler && !prevSpoiler) {
focusSpoilerInput();
}
}, [spoiler]);
useEffect(() => {
if (typeof caretPosition === 'number') {
setCursor(caretPosition);
}
}, [focusDate]);
const renderButtons = useCallback(() => (
<HStack alignItems='center' space={2}>
{features.media && <UploadButtonContainer composeId={id} />}

Wyświetl plik

@ -1,10 +1,9 @@
import debounce from 'lodash/debounce';
import React, { useEffect, useRef, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import Textarea from 'react-textarea-autosize';
import { submitAccountNote } from 'soapbox/actions/account-notes';
import { HStack, Text, Widget } from 'soapbox/components/ui';
import { HStack, Text, Textarea, Widget } from 'soapbox/components/ui';
import { useAppDispatch } from 'soapbox/hooks';
import type { Account as AccountEntity } from 'soapbox/schemas';
@ -64,14 +63,17 @@ const AccountNotePanel: React.FC<IAccountNotePanel> = ({ account }) => {
)}
</HStack>}
>
<Textarea
id={`account-note-${account.id}`}
className='mx-[-8px] w-full resize-none rounded-md border-0 bg-transparent p-2 text-sm text-gray-800 transition-colors placeholder:text-gray-600 focus:border-0 focus:bg-white focus:shadow-none focus:ring-0 motion-reduce:transition-none dark:text-white dark:placeholder:text-gray-600 focus:dark:bg-primary-900'
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={handleChange}
ref={textarea}
/>
<div className='-mx-2'>
<Textarea
id={`account-note-${account.id}`}
theme='transparent'
placeholder={intl.formatMessage(messages.placeholder)}
value={value || ''}
onChange={handleChange}
ref={textarea}
autoGrow
/>
</div>
</Widget>
);
};

Wyświetl plik

@ -2,21 +2,19 @@ import { getLocale } from 'soapbox/actions/settings';
import { useAppSelector } from './useAppSelector';
import type { CSSProperties } from 'react';
/** Locales which should be presented in right-to-left. */
const RTL_LOCALES = ['ar', 'ckb', 'fa', 'he'];
interface UseLocaleResult {
locale: string;
direction: CSSProperties['direction'];
direction: 'ltr' | 'rtl';
}
/** Get valid locale from settings. */
const useLocale = (fallback = 'en'): UseLocaleResult => {
const locale = useAppSelector((state) => getLocale(state, fallback));
const direction: CSSProperties['direction'] =
const direction: 'ltr' | 'rtl' =
RTL_LOCALES.includes(locale)
? 'rtl'
: 'ltr';

Wyświetl plik

@ -45,7 +45,7 @@ function isRtl(text: string, confidence = 0.3): boolean {
interface GetTextDirectionOpts {
/** The default direction to return if the text is empty. */
fallback?: 'ltr' | 'rtl';
fallback?: 'ltr' | 'rtl' | undefined;
/** The confidence threshold (0-1) to use when determining the direction. */
confidence?: number;
}

Wyświetl plik

@ -1051,7 +1051,7 @@
dependencies:
regenerator-runtime "^0.12.0"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
"@babel/runtime@^7.1.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.2.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
version "7.22.15"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.15.tgz#38f46494ccf6cf020bd4eed7124b425e83e523b8"
integrity sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==
@ -7546,15 +7546,6 @@ react-swipeable-views@^0.14.0:
react-swipeable-views-utils "^0.14.0"
warning "^4.0.1"
react-textarea-autosize@^8.3.4:
version "8.3.4"
resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524"
integrity sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ==
dependencies:
"@babel/runtime" "^7.10.2"
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
react-transition-group@^2.2.1:
version "2.9.0"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
@ -8913,23 +8904,6 @@ url-parse@^1.5.3:
querystringify "^2.1.1"
requires-port "^1.0.0"
use-composed-ref@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda"
integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==
use-isomorphic-layout-effect@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-latest@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2"
integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==
dependencies:
use-isomorphic-layout-effect "^1.1.1"
use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"