diff --git a/app/javascript/mastodon/components/character_counter/character_counter.stories.tsx b/app/javascript/mastodon/components/character_counter/character_counter.stories.tsx new file mode 100644 index 00000000000..a37a74af45e --- /dev/null +++ b/app/javascript/mastodon/components/character_counter/character_counter.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +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, + }, +}; diff --git a/app/javascript/mastodon/components/character_counter/index.tsx b/app/javascript/mastodon/components/character_counter/index.tsx new file mode 100644 index 00000000000..dce410a7c13 --- /dev/null +++ b/app/javascript/mastodon/components/character_counter/index.tsx @@ -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 ( + maxLength && !recommended && classes.counterError, + )} + > + {recommended ? ( + + ) : ( + + )} + + ); + }, +); +CharacterCounter.displayName = 'CharCounter'; diff --git a/app/javascript/mastodon/components/character_counter/styles.module.scss b/app/javascript/mastodon/components/character_counter/styles.module.scss new file mode 100644 index 00000000000..05c9446545a --- /dev/null +++ b/app/javascript/mastodon/components/character_counter/styles.module.scss @@ -0,0 +1,8 @@ +.counter { + margin-top: 4px; + font-size: 13px; +} + +.counterError { + color: var(--color-text-error); +} diff --git a/app/javascript/mastodon/components/emoji/picker_button.tsx b/app/javascript/mastodon/components/emoji/picker_button.tsx new file mode 100644 index 00000000000..6440ad34b55 --- /dev/null +++ b/app/javascript/mastodon/components/emoji/picker_button.tsx @@ -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 ; +}; diff --git a/app/javascript/mastodon/components/form_fields/emoji_text_field.module.scss b/app/javascript/mastodon/components/form_fields/emoji_text_field.module.scss new file mode 100644 index 00000000000..e214f9aed56 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/emoji_text_field.module.scss @@ -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); + } +} diff --git a/app/javascript/mastodon/components/form_fields/emoji_text_field.stories.tsx b/app/javascript/mastodon/components/form_fields/emoji_text_field.stories.tsx new file mode 100644 index 00000000000..aed04bbcbf6 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/emoji_text_field.stories.tsx @@ -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 ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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 ( + + ); + }, +}; diff --git a/app/javascript/mastodon/components/form_fields/emoji_text_field.tsx b/app/javascript/mastodon/components/form_fields/emoji_text_field.tsx new file mode 100644 index 00000000000..9c29bcbf915 --- /dev/null +++ b/app/javascript/mastodon/components/form_fields/emoji_text_field.tsx @@ -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>; + maxLength?: number; + recommended?: boolean; +} & Omit; + +export const EmojiTextInputField: FC< + OmitUnion, EmojiInputProps> +> = ({ + onChange, + value, + label, + hint, + hasError, + maxLength, + recommended, + disabled, + ...otherProps +}) => { + const inputRef = useRef(null); + + const wrapperProps = { + label, + hint, + hasError, + maxLength, + recommended, + disabled, + inputRef, + value, + onChange, + }; + + return ( + + {(inputProps) => ( + + )} + + ); +}; + +export const EmojiTextAreaField: FC< + OmitUnion, EmojiInputProps> +> = ({ + onChange, + value, + label, + maxLength, + recommended = false, + disabled, + hint, + hasError, + ...otherProps +}) => { + const textareaRef = useRef(null); + + const wrapperProps = { + label, + hint, + hasError, + maxLength, + recommended, + disabled, + inputRef: textareaRef, + value, + onChange, + }; + + return ( + + {(inputProps) => ( +