Composer redesign: Autocomplete (#40276)

This commit is contained in:
Echo
2026-08-27 13:51:51 +00:00
committed by GitHub
parent 7cab718ef8
commit 2798c0894f
10 changed files with 853 additions and 85 deletions

View File

@@ -0,0 +1,119 @@
import { useRef, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { accountFactoryImmutable } from '@/testing/factories';
import { TextArea } from '../form_fields';
import menuClasses from '../menu/styles.module.scss';
import { useAutosuggestMenu } from './hooks';
import { AutosuggestItem } from './items';
import { AutosuggestMenu } from './list';
import type {
AccountSuggestion,
EmojiSuggestion,
HashtagSuggestion,
Suggestion,
} from './types';
type SuggestTypes = Suggestion['type'];
const suggestionsMap = {
account: [{ type: 'account', id: '1' }] satisfies AccountSuggestion[],
emoji: [
{ type: 'emoji', id: '+1', native: '👍' },
{ type: 'emoji', id: '-1', native: '👎' },
{ type: 'emoji', id: 'smile', native: '🙂' },
] satisfies EmojiSuggestion[],
hashtag: [
{ type: 'hashtag', name: 'Testing', id: '1', totalUses: 0 },
{ type: 'hashtag', name: 'Test', id: '2', totalUses: 10 },
] satisfies HashtagSuggestion[],
} satisfies Record<SuggestTypes, Suggestion[]>;
const fetchCb = fn().mockName('fetching token');
const selectCb = fn().mockName('selected item');
const clearCb = fn().mockName('cleared suggestions');
const meta = {
title: 'Redesign/Autosuggest',
render() {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { onTextChange, suggestProps, sourceProps } = useAutosuggestMenu({
suggestions,
onSelect: selectCb,
onFetch(token) {
const newSuggestions = tokenToSuggestions(token);
fetchCb(token, newSuggestions);
setSuggestions(newSuggestions);
},
onClear() {
clearCb();
setSuggestions([]);
},
});
return (
<div>
<TextArea {...sourceProps} onChange={onTextChange} ref={textareaRef} />
<AutosuggestMenu
{...suggestProps}
reference={textareaRef.current}
maxWidth={200}
/>
</div>
);
},
parameters: {
state: {
accounts: {
'1': accountFactoryImmutable(),
},
},
redesign: true,
},
} satisfies Meta;
function tokenToSuggestions(token: string) {
let suggestions: Suggestion[] = [];
switch (token.charAt(0)) {
case '@':
suggestions = suggestionsMap.account;
break;
case ':':
suggestions = suggestionsMap.emoji;
break;
case '#':
suggestions = suggestionsMap.hashtag;
}
return suggestions;
}
export default meta;
type Story = StoryObj<typeof meta>;
export const Textarea: Story = {};
export const Static: Story = {
render() {
return (
<div className={menuClasses.card} style={{ width: '300px' }}>
{Object.values(suggestionsMap)
.flat()
.map((suggestion) => (
<AutosuggestItem
suggestion={suggestion}
key={suggestion.id}
className={menuClasses.item}
/>
))}
</div>
);
},
};

View File

@@ -0,0 +1,233 @@
import { useCallback, useRef, useState } from 'react';
import classNames from 'classnames';
import type { Simplify } from 'type-fest';
import { useThrottledCallback } from 'use-debounce';
import { getAllMenuItems } from '../menu';
import type { AutosuggestMenuProps } from './list';
import classes from './styles.module.scss';
import type { AutosuggestSourceElements, Source, Suggestion } from './types';
import { sourceToElement, textAtCursorMatchesToken } from './utils';
export type OnSuggestionSelect = (
start: number,
token: string,
suggestion: Suggestion,
) => void;
interface UseAutosuggestMenuOptions {
suggestions: Suggestion[];
onSelect: OnSuggestionSelect;
onFetch?: (token: string) => void;
onClear?: () => void;
}
type SourceProps = React.DetailedHTMLProps<
React.HTMLAttributes<AutosuggestSourceElements>,
AutosuggestSourceElements
>;
interface UseAutosuggestReturn {
onTextChange: React.ChangeEventHandler<AutosuggestSourceElements>;
focus: (event?: React.SyntheticEvent) => void;
getToken: () => { token: string | null; startPosition: number };
mirror?: React.JSX.Element;
suggestProps: Simplify<Omit<AutosuggestMenuProps, 'children'>>;
sourceProps: Simplify<
Pick<SourceProps, 'onSelect' | 'onScroll' | 'aria-autocomplete'>
>;
}
export function useAutosuggestMenu({
suggestions,
onSelect,
onFetch,
onClear,
}: UseAutosuggestMenuOptions): UseAutosuggestReturn {
const lastTokenRef = useRef<string | null>(null); // The last suggestion token encountered.
const tokenStartRef = useRef(0); // Character location of the token start.
const listRef = useRef<HTMLDivElement>(null);
const onTextChange: React.ChangeEventHandler<AutosuggestSourceElements> =
useCallback(
(event) => {
// Detect a token, and if so fetch suggestions, or dismiss them if not.
const [tokenStart, token] = textAtCursorMatchesToken(
event.target.value,
event.target.selectionStart ?? 0,
['@', '', ':', '#', ''],
);
if (token !== null && lastTokenRef.current !== token) {
tokenStartRef.current = tokenStart;
lastTokenRef.current = token;
onFetch?.(token);
} else if (token === null) {
lastTokenRef.current = null;
onClear?.();
}
},
[onClear, onFetch],
);
const focus = useCallback(
(event?: React.SyntheticEvent) => {
if (suggestions.length > 0 && listRef.current) {
event?.preventDefault();
(getAllMenuItems(listRef.current).at(0) ?? listRef.current).focus();
}
},
[suggestions.length],
);
const getToken = useCallback(
() => ({
token: lastTokenRef.current,
startPosition: tokenStartRef.current,
}),
[],
);
const tokenCb = useCallback(() => lastTokenRef.current, []);
const onSuggestionClick: React.MouseEventHandler<HTMLButtonElement> =
useCallback(
(event) => {
const { id, index, type } = event.currentTarget.dataset;
const suggestion = suggestions.find((suggestion, i) =>
suggestion.type === type && suggestion.id
? suggestion.id === id
: index && Number.parseInt(index) === i,
);
if (!suggestion || !lastTokenRef.current) {
return;
}
onSelect(tokenStartRef.current, lastTokenRef.current, suggestion);
},
[onSelect, suggestions],
);
return {
// Used by the parent.
onTextChange,
focus,
getToken,
// For the component below.
suggestProps: {
suggestions,
onSuggestionClick,
listRef,
tokenCb,
} satisfies AutosuggestMenuProps,
sourceProps: {
'aria-autocomplete': 'list',
} satisfies SourceProps,
};
}
interface UseAutosuggestFloatingMenuOptions extends UseAutosuggestMenuOptions {
text?: string;
sourceRef: Source;
className?: string;
}
export function useAutosuggestFloatingMenu({
text,
sourceRef,
className,
...suggestOptions
}: UseAutosuggestFloatingMenuOptions): UseAutosuggestReturn {
const [mirrorElement, setMirrorElement] = useState<HTMLElement | null>(null); // Reference to the mirror element.
const [selectedText, setSelectedText] = useState(text ?? ''); // The actual selected text inside the mirror.
const updatePopover = useRef<() => void>(null); // Reference to the popover update callback.
const { getToken, ...autosuggestProps } = useAutosuggestMenu(suggestOptions);
const { onClear } = suggestOptions;
const source = sourceToElement(sourceRef);
// Update the popover on scroll or select.
const onUpdate = useCallback(() => {
// Call the popover update callback. This enables the popover to adjust position with scroll.
updatePopover.current?.();
if (source && mirrorElement) {
// Set top to scroll offset so bottom edge looks right.
mirrorElement.style.setProperty('top', `${-1 * source.scrollTop}px`);
const { height } = source.getBoundingClientRect();
const offset = mirrorElement.offsetHeight - source.scrollTop;
if (offset < 0 || offset > height) {
onClear?.();
}
}
}, [mirrorElement, onClear, source, updatePopover]);
// When the caret or selection changes, update the selected text and clear the composer if it doesn't include a token.
const onSelect: React.ReactEventHandler<AutosuggestSourceElements> =
useCallback(
(event) => {
const { token, startPosition } = getToken();
if (token === null) {
return;
}
onUpdate();
const { selectionStart: rawStart, value } = event.currentTarget;
const selectionStart = rawStart ?? 0;
const tokenEnd = startPosition + token.length;
setSelectedText(value.slice(0, tokenEnd)); // Only set the text up to the selected end point.
if (selectionStart < startPosition || selectionStart > tokenEnd) {
onClear?.();
}
},
[getToken, onClear, onUpdate],
);
const onScroll = useThrottledCallback(onUpdate, 0);
const updatePopoverCb = useCallback((update: () => void) => {
updatePopover.current = () => update;
}, []);
const mirror = (
<div
className={classNames(
classes.mirror,
source instanceof HTMLTextAreaElement && classes.textAreaMirror,
className,
)}
ref={setMirrorElement}
>
{selectedText}
</div>
);
return {
mirror,
getToken,
...autosuggestProps,
sourceProps: {
...autosuggestProps.sourceProps,
onScroll,
onSelect,
} satisfies SourceProps,
suggestProps: {
...autosuggestProps.suggestProps,
reference: mirrorElement,
updatePopoverCb,
} satisfies AutosuggestMenuProps,
};
}

View File

@@ -0,0 +1,97 @@
import type React from 'react';
import classNames from 'classnames';
import { selectPlainAccount } from '@/mastodon/selectors/accounts';
import { useAppSelector } from '@/mastodon/store';
import { Avatar } from '../avatar';
import { DisplayName } from '../display_name';
import { Emoji } from '../emoji';
import { MenuItem } from '../menu';
import { ShortNumber } from '../short_number';
import classes from './styles.module.scss';
import type {
EmojiSuggestion,
HashtagSuggestion,
LocalHashtagSuggestion,
Suggestion,
} from './types';
export const AutosuggestItem: React.FC<
{
suggestion: Suggestion;
className?: string;
} & React.ComponentPropsWithoutRef<'button'>
> = ({ suggestion, className, ...props }) => {
let suggestComp: React.ReactNode = null;
if (suggestion.type === 'account') {
suggestComp = <AutosuggestAccount {...suggestion} />;
} else if (suggestion.type === 'hashtag') {
suggestComp = <AutosuggestHashtag {...suggestion} />;
} else {
suggestComp = <AutosuggestEmoji {...suggestion} />;
}
return (
<MenuItem
{...props}
className={classNames(classes.item, className)}
data-id={suggestion.id}
data-type={suggestion.type}
>
{suggestComp}
</MenuItem>
);
};
const AutosuggestAccount: React.FC<{ id: string }> = ({ id }) => {
const account = useAppSelector((state) => selectPlainAccount(state, id));
if (!account) {
return null;
}
return (
<>
<Avatar account={account} className={classes.itemIcon} size={32} />
<div>
<DisplayName
account={account}
variant='noDomain'
className={classes.itemAccountName}
/>
<span className={classes.itemAccountHandle}>{account.acct}</span>
</div>
</>
);
};
const AutosuggestEmoji: React.FC<EmojiSuggestion> = ({ id, native }) => {
const colons = `:${id}:`;
return (
<>
<span className={classes.itemIcon}>
<Emoji code={native ?? colons} />
</span>
<span>{colons}</span>
</>
);
};
const AutosuggestHashtag: React.FC<
HashtagSuggestion | LocalHashtagSuggestion
> = ({ name, ...props }) => {
return (
<>
#{name}
{'totalUses' in props ? (
<span className={classes.itemHashUses}>
<ShortNumber value={props.totalUses} />
</span>
) : null}{' '}
</>
);
};

View File

@@ -0,0 +1,114 @@
import { useEffect } from 'react';
import classNames from 'classnames';
import { useMergedRefs } from '@/mastodon/hooks/useMergedRefs';
import { usePrevious } from '@/mastodon/hooks/usePrevious';
import { LocalCustomEmojiProvider } from '../emoji/context';
import { Menu, useMenuContext } from '../menu';
import { MenuCard } from '../menu/card';
import { Popover } from '../popover';
import { AutosuggestItem } from './items';
import classes from './styles.module.scss';
import type { Suggestion } from './types';
export interface AutosuggestMenuProps {
suggestions: Suggestion[];
tokenCb: () => string | null;
onSuggestionClick: React.MouseEventHandler;
children?: React.ReactNode;
listRef?: React.Ref<HTMLDivElement>;
reference?: HTMLElement | null;
updatePopoverCb?: (update: () => void) => void;
maxWidth?: number | string;
}
export const AutosuggestMenu: React.FC<AutosuggestMenuProps> = ({
suggestions,
onSuggestionClick,
children,
...menuProps
}) => {
return (
<Menu noFocus>
{suggestions.length > 0 && (
<AutosuggestMenuList {...menuProps} suggestions={suggestions}>
{children ??
suggestions.map((suggestion, index) => (
<AutosuggestItem
key={`${suggestion.type}:${suggestion.id}`}
onClick={onSuggestionClick}
suggestion={suggestion}
data-index={index}
/>
))}
</AutosuggestMenuList>
)}
</Menu>
);
};
type AutosuggestMenuListProps = Omit<
AutosuggestMenuProps,
'onSuggestionClick'
> & {
children: React.ReactNode;
};
const AutosuggestMenuList: React.FC<AutosuggestMenuListProps> = ({
children,
listRef,
tokenCb,
suggestions,
reference,
updatePopoverCb,
maxWidth,
}) => {
const token = tokenCb();
const lastToken = usePrevious(token);
const { popover, menuListProps } = useMenuContext();
useEffect(() => {
if (!popover.isMenuOpen && token !== lastToken && suggestions.length > 0) {
popover.openMenu();
}
}, [lastToken, popover, suggestions.length, token]);
const mergedRef = useMergedRefs(menuListProps.ref, listRef);
return (
<Popover
isOpen={popover.isMenuOpen}
onClose={popover.closeMenu}
reference={reference ?? null}
popoverElement={popover.popover}
container={null}
placement='bottom-start'
>
{({ props: popoverChildProps, update }) => {
updatePopoverCb?.(update);
return (
<MenuCard
{...popoverChildProps}
{...menuListProps}
style={
{
'--_max-card-width':
typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
...popoverChildProps.style,
} as React.CSSProperties
}
ref={mergedRef}
className={classNames(maxWidth && classes.menuWidth)}
>
<LocalCustomEmojiProvider>{children}</LocalCustomEmojiProvider>
</MenuCard>
);
}}
</Popover>
);
};

View File

@@ -0,0 +1,59 @@
@use '@/styles/mastodon/mixins';
.mirror {
visibility: hidden;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
right: 0;
}
.textAreaMirror {
white-space: pre-wrap; // This makes the formatting match the text area.
}
.debug.mirror {
visibility: visible;
opacity: 0.2;
background-color: red;
color: white;
}
.menuWidth {
width: 100%;
max-width: var(--_max-card-width);
}
.item {
display: flex;
gap: var(--space-xs);
min-height: var(--space-3xl);
}
.itemIcon {
width: var(--space-3xl);
height: var(--space-3xl);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-round);
overflow: hidden;
background-color: var(--color-bg-highlight);
}
.itemHashUses {
color: var(--color-text-tertiary);
margin-inline-start: auto;
font-size: var(--fs-xs);
}
.itemAccountName {
@include mixins.type-label-lg;
}
.itemAccountHandle {
@include mixins.type-label-sm;
color: var(--color-text-tertiary);
}

View File

@@ -0,0 +1,35 @@
export interface AccountSuggestion {
type: 'account';
id: string;
}
export interface EmojiSuggestion {
type: 'emoji';
id: string;
custom?: boolean;
native?: string;
imageUrl?: string;
}
export interface LocalHashtagSuggestion {
type: 'hashtag';
id?: string;
name: string;
}
export interface HashtagSuggestion {
type: 'hashtag';
id: string;
name: string;
totalUses: number;
}
export type Suggestion =
| AccountSuggestion
| EmojiSuggestion
| LocalHashtagSuggestion
| HashtagSuggestion;
export type AutosuggestSourceElements = HTMLInputElement | HTMLTextAreaElement;
export type Source =
| React.RefObject<AutosuggestSourceElements | null>
| AutosuggestSourceElements
| null;

View File

@@ -1,10 +1,15 @@
import { isRecordObject } from '@/mastodon/utils/objects';
import { stringOrUndefined } from '@/mastodon/utils/strings';
import { WORD } from '../../utils/hashtags';
export const textAtCursorMatchesToken = (
import type { Source, Suggestion } from './types';
export function textAtCursorMatchesToken(
str: string,
caretPosition: number,
searchTokens: string[],
) => {
) {
let word: string;
const regex = new RegExp(
@@ -31,4 +36,72 @@ export const textAtCursorMatchesToken = (
} else {
return [null, null] as const;
}
};
}
export function immutableListToSuggestions(list: Immutable.List<unknown>) {
const suggestions: Suggestion[] = [];
const hashtagSet = new Set<string>();
let fakeId = 0;
for (const suggestion of list.toArray()) {
if (!isRecordObject(suggestion)) {
continue;
}
const type = suggestion.type;
const id = stringOrUndefined(suggestion.id) ?? `fake-${fakeId++}`; // Fake ID so we don't have React key issues.
switch (type) {
case 'account':
suggestions.push({
type,
id,
});
break;
case 'emoji':
suggestions.push({
type,
id,
custom: !!suggestion.custom,
native: stringOrUndefined(suggestion.native),
imageUrl: stringOrUndefined(suggestion.imageUrl),
});
break;
case 'hashtag': {
const name = stringOrUndefined(suggestion.name);
if (!name || hashtagSet.has(name)) {
continue;
}
hashtagSet.add(name);
suggestions.push({
type,
name,
id,
totalUses: tagHistoryToUses(suggestion.history),
});
}
}
}
return suggestions;
}
export function tagHistoryToUses(history: unknown) {
if (!Array.isArray(history)) {
return 0;
}
return history.reduce<number>(
(total, current) =>
isRecordObject(current) && typeof current.uses === 'number'
? total + current.uses
: total,
0,
);
}
export function sourceToElement(source: Source) {
return !source || source instanceof HTMLElement ? source : source.current;
}

View File

@@ -1,6 +1,7 @@
import { length } from 'stringz';
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import { immutableListToSuggestions } from '@/mastodon/components/autosuggest/utils';
import type { StatusVisibility } from '@/mastodon/models/status';
import type { ComposeType } from '@/mastodon/reducers/slices/composer';
import { createAppSelector } from '@/mastodon/store';
@@ -239,3 +240,11 @@ export const selectComposePoll = createAppSelector(
};
},
);
export const selectSuggestions = createAppSelector(
[
(state) =>
state.compose.get('suggestions') as unknown as Immutable.List<unknown>,
],
(list) => immutableListToSuggestions(list),
);

View File

@@ -114,27 +114,50 @@
.textareaWrapper {
flex-grow: 1;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
.textarea,
.textareaMirror {
@include mixins.type-body-lg;
padding: var(--space-xs);
}
.textarea {
flex-grow: 1;
border-radius: var(--radius-xs);
transition: border 200ms;
cursor: text;
border: none;
width: 100%;
outline: none;
&:focus-within {
&:focus {
outline: 2px solid var(--color-border-brand);
outline-offset: -2px;
textarea::placeholder {
&::placeholder {
color: transparent;
}
}
}
textarea {
@include mixins.type-body-lg;
textarea.textarea {
resize: none;
}
border: none;
width: 100%;
outline: none;
}
.textareaMirror {
visibility: hidden;
pointer-events: none;
position: absolute;
top: 0;
left: 0;
right: 0;
white-space: pre-wrap; // This makes the formatting match the text area.
}
.attachments {

View File

@@ -1,12 +1,9 @@
import type React from 'react';
import { useCallback, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
import {
changeCompose,
fetchComposeSuggestions,
@@ -14,15 +11,22 @@ import {
selectComposeSuggestion,
} from '@/mastodon/actions/compose';
import { processPasteOrDrop } from '@/mastodon/actions/compose_typed';
import AutosuggestTextareaOriginal from '@/mastodon/components/autosuggest_textarea';
import { COMPOSER_TEXTAREA_ID } from '@/mastodon/reducers/slices/composer';
import type { OnSuggestionSelect } from '@/mastodon/components/autosuggest/hooks';
import { useAutosuggestFloatingMenu } from '@/mastodon/components/autosuggest/hooks';
import { AutosuggestMenu } from '@/mastodon/components/autosuggest/list';
import { TextArea } from '@/mastodon/components/form_fields';
import { normalizeKey } from '@/mastodon/components/hotkeys/utils';
import {
COMPOSER_TEXTAREA_ID,
focusComposerTextarea,
} from '@/mastodon/reducers/slices/composer';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { selectComposeType } from './selectors';
import { selectComposeType, selectSuggestions } from './selectors';
import classes from './styles.module.scss';
const messages = defineMessages({
@@ -38,25 +42,8 @@ const messages = defineMessages({
},
});
type SuggestSelectedHandler = (
position: number,
token: string,
suggestion: unknown,
) => void;
const AutosuggestTextarea =
AutosuggestTextareaOriginal as React.ForwardRefExoticComponent<
{
suggestions: Immutable.List<unknown>;
onSuggestionSelected: SuggestSelectedHandler;
onSuggestionsClearRequested: () => void;
onSuggestionsFetchRequested: (token: string) => void;
} & TextareaAutosizeProps &
React.RefAttributes<HTMLTextAreaElement>
>;
type ComposeTextareaProps = Omit<
TextareaAutosizeProps,
React.ComponentPropsWithoutRef<'textarea'>,
| 'placeholder'
| 'onFocus'
| 'onBlur'
@@ -71,9 +58,6 @@ const selectComposeTextState = createAppSelector(
(compose) => ({
text: compose.get('text') as string,
lang: compose.get('language') as string,
suggestions: compose.get(
'suggestions',
) as unknown as Immutable.List<unknown>,
isSubmitting: !!compose.get('is_submitting'),
}),
);
@@ -86,38 +70,78 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
}) => {
const intl = useIntl();
// Selectors
const type = useAppSelector(selectComposeType);
const { suggestions, text, lang, isSubmitting } = useAppSelector(
selectComposeTextState,
);
const { text, lang, isSubmitting } = useAppSelector(selectComposeTextState);
const dispatch = useAppDispatch();
const onClickWrapper: React.MouseEventHandler<HTMLDivElement> = useCallback(
(event) => {
if (event.target instanceof HTMLDivElement) {
event.target.querySelector('textarea')?.focus();
}
},
[],
);
const onChange: React.ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
dispatch(changeCompose(event.target.value));
// Suggestion logic
const onSuggestionFetch = useCallback(
(token: string) => {
dispatch(fetchComposeSuggestions(token));
},
[dispatch],
);
const onSuggestion: OnSuggestionSelect = useCallback(
(tokenStart, token, suggestion) => {
dispatch(
selectComposeSuggestion(tokenStart, token, suggestion, ['text']),
);
focusComposerTextarea(true);
},
[dispatch],
);
const onSuggestionClear = useCallback(() => {
dispatch(clearComposeSuggestions());
}, [dispatch]);
const suggestions = useAppSelector(selectSuggestions);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const { onTextChange, focus, mirror, sourceProps, suggestProps } =
useAutosuggestFloatingMenu({
suggestions,
text,
className: classes.textareaMirror,
sourceRef: textAreaRef,
onSelect: onSuggestion,
onFetch: onSuggestionFetch,
onClear: onSuggestionClear,
});
// Update the composer text and trigger suggestions.
const onChange: React.ChangeEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
dispatch(changeCompose(event.target.value));
onTextChange(event);
},
[dispatch, onTextChange],
);
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> =
useCallback(
(event) => {
const key = event.key.toLowerCase();
const key = normalizeKey(event.key);
if (key === 'enter' && (event.ctrlKey || event.metaKey)) {
onSubmit();
event.preventDefault();
} else if (['esc', 'escape'].includes(key)) {
event.currentTarget.blur();
onSuggestionClear();
} else if (key === 'escape') {
// Dismiss the suggestions if we're displaying any.
if (suggestions.length > 0) {
onSuggestionClear();
} else {
// Otherwise lose focus on the textarea.
event.currentTarget.blur();
}
} else if (key === 'down') {
focus(event);
}
},
[onSubmit],
[onSubmit, onSuggestionClear, suggestions.length, focus],
);
const onPasteOrDrop = useCallback(
@@ -131,34 +155,15 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
},
[dispatch],
);
const onSuggestionsFetchRequested = useCallback(
(token: string) => {
dispatch(fetchComposeSuggestions(token));
},
[dispatch],
);
const onSuggestionsClearRequested = useCallback(() => {
dispatch(clearComposeSuggestions());
}, [dispatch]);
const onSuggestionSelected: SuggestSelectedHandler = useCallback(
(position, token, suggestion) => {
dispatch(selectComposeSuggestion(position, token, suggestion, ['text']));
},
[dispatch],
);
const textareaRef = useRef<HTMLTextAreaElement>(null);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- This just moves focus to the textarea.
<div
onClick={onClickWrapper}
className={classNames(className, classes.textareaWrapper)}
>
<AutosuggestTextarea
<div className={classes.textareaWrapper}>
<TextArea
{...props}
dir='auto'
id={COMPOSER_TEXTAREA_ID}
ref={textareaRef}
className={classNames(className, classes.textarea)}
ref={textAreaRef}
value={text}
lang={lang}
placeholder={intl.formatMessage(
@@ -167,15 +172,16 @@ export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
: messages.placeholder,
)}
disabled={disabled || isSubmitting}
suggestions={suggestions}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
onKeyDown={onKeyDown}
onDrop={onPasteOrDrop}
onPaste={onPasteOrDrop}
onChange={onChange}
{...sourceProps}
/>
{mirror}
<AutosuggestMenu {...suggestProps} maxWidth={280} />
</div>
);
};