Emoji text input and character counter components (#38052)

This commit is contained in:
Echo 2026-03-04 17:13:45 +01:00 committed by GitHub
parent 43b0113a4a
commit 3fbb7424fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 434 additions and 18 deletions

View File

@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CharacterCounter } from './index';
const meta = {
component: CharacterCounter,
title: 'Components/CharacterCounter',
} satisfies Meta<typeof CharacterCounter>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Required: Story = {
args: {
currentString: 'Hello, world!',
maxLength: 100,
},
};
export const ExceedingLimit: Story = {
args: {
currentString: 'Hello, world!',
maxLength: 10,
},
};
export const Recommended: Story = {
args: {
currentString: 'Hello, world!',
maxLength: 10,
recommended: true,
},
};

View File

@ -0,0 +1,63 @@
import { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { polymorphicForwardRef } from '@/types/polymorphic';
import classes from './styles.module.scss';
interface CharacterCounterProps {
currentString: string;
maxLength: number;
recommended?: boolean;
}
const segmenter = new Intl.Segmenter();
export const CharacterCounter = polymorphicForwardRef<
'span',
CharacterCounterProps
>(
(
{
currentString,
maxLength,
as: Component = 'span',
recommended = false,
...props
},
ref,
) => {
const currentLength = useMemo(
() => [...segmenter.segment(currentString)].length,
[currentString],
);
return (
<Component
{...props}
ref={ref}
className={classNames(
classes.counter,
currentLength > maxLength && !recommended && classes.counterError,
)}
>
{recommended ? (
<FormattedMessage
id='character_counter.recommended'
defaultMessage='{currentLength}/{maxLength} recommended characters'
values={{ currentLength, maxLength }}
/>
) : (
<FormattedMessage
id='character_counter.required'
defaultMessage='{currentLength}/{maxLength} characters'
values={{ currentLength, maxLength }}
/>
)}
</Component>
);
},
);
CharacterCounter.displayName = 'CharCounter';

View File

@ -0,0 +1,8 @@
.counter {
margin-top: 4px;
font-size: 13px;
}
.counterError {
color: var(--color-text-error);
}

View File

@ -0,0 +1,29 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import EmojiPickerDropdown from '@/mastodon/features/compose/containers/emoji_picker_dropdown_container';
export const EmojiPickerButton: FC<{
onPick: (emoji: string) => void;
disabled?: boolean;
}> = ({ onPick, disabled }) => {
const handlePick = useCallback(
(emoji: unknown) => {
if (disabled) {
return;
}
if (typeof emoji === 'object' && emoji !== null) {
if ('native' in emoji && typeof emoji.native === 'string') {
onPick(emoji.native);
} else if (
'shortcode' in emoji &&
typeof emoji.shortcode === 'string'
) {
onPick(`:${emoji.shortcode}:`);
}
}
},
[disabled, onPick],
);
return <EmojiPickerDropdown onPickEmoji={handlePick} disabled={disabled} />;
};

View File

@ -0,0 +1,24 @@
.fieldWrapper div:has(:global(.emoji-picker-dropdown)) {
position: relative;
> input,
> textarea {
padding-inline-end: 36px;
}
> textarea {
min-height: 40px; // Button size with 8px margin
}
}
.fieldWrapper :global(.emoji-picker-dropdown) {
position: absolute;
top: 8px;
right: 8px;
height: 24px;
z-index: 1;
:global(.icon-button) {
color: var(--color-text-secondary);
}
}

View File

@ -0,0 +1,59 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { EmojiInputProps } from './emoji_text_field';
import { EmojiTextAreaField, EmojiTextInputField } from './emoji_text_field';
const meta = {
title: 'Components/Form Fields/EmojiTextInputField',
args: {
label: 'Label',
hint: 'Hint text',
value: 'Insert text with emoji',
},
render({ value: initialValue = '', ...args }) {
const [value, setValue] = useState(initialValue);
return <EmojiTextInputField {...args} value={value} onChange={setValue} />;
},
} satisfies Meta<EmojiInputProps & { disabled?: boolean }>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {};
export const WithMaxLength: Story = {
args: {
maxLength: 20,
},
};
export const WithRecommended: Story = {
args: {
maxLength: 20,
recommended: true,
},
};
export const Disabled: Story = {
args: {
disabled: true,
},
};
export const TextArea: Story = {
render(args) {
const [value, setValue] = useState('Insert text with emoji');
return (
<EmojiTextAreaField
{...args}
value={value}
onChange={setValue}
label='Label'
maxLength={100}
/>
);
},
};

View File

@ -0,0 +1,174 @@
import type {
ChangeEvent,
ChangeEventHandler,
ComponentPropsWithoutRef,
Dispatch,
FC,
ReactNode,
RefObject,
SetStateAction,
} from 'react';
import { useCallback, useId, useRef } from 'react';
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
import type { OmitUnion } from '@/mastodon/utils/types';
import { CharacterCounter } from '../character_counter';
import { EmojiPickerButton } from '../emoji/picker_button';
import classes from './emoji_text_field.module.scss';
import type { CommonFieldWrapperProps, InputProps } from './form_field_wrapper';
import { FormFieldWrapper } from './form_field_wrapper';
import { TextArea } from './text_area_field';
import type { TextAreaProps } from './text_area_field';
import { TextInput } from './text_input_field';
export type EmojiInputProps = {
value?: string;
onChange?: Dispatch<SetStateAction<string>>;
maxLength?: number;
recommended?: boolean;
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
export const EmojiTextInputField: FC<
OmitUnion<ComponentPropsWithoutRef<'input'>, EmojiInputProps>
> = ({
onChange,
value,
label,
hint,
hasError,
maxLength,
recommended,
disabled,
...otherProps
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const wrapperProps = {
label,
hint,
hasError,
maxLength,
recommended,
disabled,
inputRef,
value,
onChange,
};
return (
<EmojiFieldWrapper {...wrapperProps}>
{(inputProps) => (
<TextInput
{...inputProps}
{...otherProps}
value={value}
ref={inputRef}
/>
)}
</EmojiFieldWrapper>
);
};
export const EmojiTextAreaField: FC<
OmitUnion<Omit<TextAreaProps, 'style'>, EmojiInputProps>
> = ({
onChange,
value,
label,
maxLength,
recommended = false,
disabled,
hint,
hasError,
...otherProps
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const wrapperProps = {
label,
hint,
hasError,
maxLength,
recommended,
disabled,
inputRef: textareaRef,
value,
onChange,
};
return (
<EmojiFieldWrapper {...wrapperProps}>
{(inputProps) => (
<TextArea
{...otherProps}
{...inputProps}
value={value}
ref={textareaRef}
/>
)}
</EmojiFieldWrapper>
);
};
const EmojiFieldWrapper: FC<
EmojiInputProps & {
disabled?: boolean;
children: (
inputProps: InputProps & { onChange: ChangeEventHandler },
) => ReactNode;
inputRef: RefObject<HTMLTextAreaElement | HTMLInputElement>;
}
> = ({
value,
onChange,
children,
disabled,
inputRef,
maxLength,
recommended = false,
...otherProps
}) => {
const counterId = useId();
const handlePickEmoji = useCallback(
(emoji: string) => {
onChange?.((prev) => {
const position = inputRef.current?.selectionStart ?? prev.length;
return insertEmojiAtPosition(prev, emoji, position);
});
},
[onChange, inputRef],
);
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
},
[onChange],
);
return (
<FormFieldWrapper
className={classes.fieldWrapper}
describedById={counterId}
{...otherProps}
>
{(inputProps) => (
<>
{children({ ...inputProps, onChange: handleChange })}
<EmojiPickerButton onPick={handlePickEmoji} disabled={disabled} />
{maxLength && (
<CharacterCounter
currentString={value ?? ''}
maxLength={maxLength}
recommended={recommended}
id={counterId}
/>
)}
</>
)}
</FormFieldWrapper>
);
};

View File

@ -5,10 +5,12 @@ import { useContext, useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { FieldsetNameContext } from './fieldset';
import classes from './form_field_wrapper.module.scss';
interface InputProps {
export interface InputProps {
id: string;
required?: boolean;
'aria-describedby'?: string;
@ -20,8 +22,10 @@ interface FieldWrapperProps {
required?: boolean;
hasError?: boolean;
inputId?: string;
describedById?: string;
inputPlacement?: 'inline-start' | 'inline-end';
children: (inputProps: InputProps) => ReactNode;
className?: string;
}
/**
@ -30,7 +34,7 @@ interface FieldWrapperProps {
export type CommonFieldWrapperProps = Pick<
FieldWrapperProps,
'label' | 'hint' | 'hasError'
>;
> & { wrapperClassName?: string };
/**
* A simple form field wrapper for adding a label and hint to enclosed components.
@ -42,10 +46,12 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
inputId: inputIdProp,
label,
hint,
describedById,
required,
hasError,
inputPlacement,
children,
className,
}) => {
const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`;
@ -59,7 +65,9 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
id: inputId,
};
if (hasHint) {
inputProps['aria-describedby'] = hintId;
inputProps['aria-describedby'] = describedById
? `${describedById} ${hintId}`
: hintId;
}
const input = (
@ -68,7 +76,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
return (
<div
className={classes.wrapper}
className={classNames(classes.wrapper, className)}
data-has-error={hasError}
data-input-placement={inputPlacement}
>

View File

@ -10,7 +10,7 @@ import { FormFieldWrapper } from './form_field_wrapper';
import type { CommonFieldWrapperProps } from './form_field_wrapper';
import classes from './text_input.module.scss';
type TextAreaProps =
export type TextAreaProps =
| ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
| ({ autoSize: true } & TextareaAutosizeProps);
@ -24,17 +24,23 @@ type TextAreaProps =
export const TextAreaField = forwardRef<
HTMLTextAreaElement,
TextAreaProps & CommonFieldWrapperProps
>(({ id, label, hint, required, hasError, ...otherProps }, ref) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
>
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
));
>(
(
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
className={wrapperClassName}
>
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>
),
);
TextAreaField.displayName = 'TextAreaField';

View File

@ -24,13 +24,17 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
*/
export const TextInputField = forwardRef<HTMLInputElement, Props>(
({ id, label, hint, hasError, required, ...otherProps }, ref) => (
(
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
ref,
) => (
<FormFieldWrapper
label={label}
hint={hint}
required={required}
hasError={hasError}
inputId={id}
className={wrapperClassName}
>
{(inputProps) => <TextInput {...otherProps} {...inputProps} ref={ref} />}
</FormFieldWrapper>

View File

@ -322,6 +322,7 @@ class EmojiPickerDropdown extends PureComponent {
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
disabled: PropTypes.bool,
};
state = {
@ -384,7 +385,7 @@ class EmojiPickerDropdown extends PureComponent {
};
render() {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, disabled } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
@ -396,6 +397,8 @@ class EmojiPickerDropdown extends PureComponent {
active={active}
iconComponent={MoodIcon}
onClick={this.onToggle}
disabled={disabled}
id="emoji"
inverted
/>

View File

@ -276,6 +276,8 @@
"callout.dismiss": "Dismiss",
"carousel.current": "<sr>Slide</sr> {current, number} / {max, number}",
"carousel.slide": "Slide {current, number} of {max, number}",
"character_counter.recommended": "{currentLength}/{maxLength} recommended characters",
"character_counter.required": "{currentLength}/{maxLength} characters",
"closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.",
"closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.",
"closed_registrations_modal.find_another_server": "Find another server",

View File

@ -10,6 +10,7 @@ import jsxA11Y from 'eslint-plugin-jsx-a11y';
import promisePlugin from 'eslint-plugin-promise';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
// @ts-expect-error -- No types available for this package
import storybook from 'eslint-plugin-storybook';
import { globalIgnores } from 'eslint/config';
import globals from 'globals';
@ -368,6 +369,7 @@ export default tseslint.config([
files: ['**/*.stories.ts', '**/*.stories.tsx', '.storybook/*'],
rules: {
'import/no-default-export': 'off',
'react-hooks/rules-of-hooks': 'off',
},
},
{