diff --git a/app/javascript/flavours/glitch/components/a11y_live_region/a11y_live_region.stories.tsx b/app/javascript/flavours/glitch/components/a11y_live_region/a11y_live_region.stories.tsx new file mode 100644 index 0000000000..00804d685b --- /dev/null +++ b/app/javascript/flavours/glitch/components/a11y_live_region/a11y_live_region.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { A11yLiveRegion } from '.'; + +const meta = { + title: 'Components/A11yLiveRegion', + component: A11yLiveRegion, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Polite: Story = { + args: { + children: "This field can't be empty.", + }, +}; + +export const Assertive: Story = { + args: { + ...Polite.args, + role: 'alert', + }, +}; diff --git a/app/javascript/flavours/glitch/components/a11y_live_region/index.tsx b/app/javascript/flavours/glitch/components/a11y_live_region/index.tsx new file mode 100644 index 0000000000..51fee5e4b9 --- /dev/null +++ b/app/javascript/flavours/glitch/components/a11y_live_region/index.tsx @@ -0,0 +1,28 @@ +import { polymorphicForwardRef } from '@/types/polymorphic'; + +/** + * A live region is a content region that announces changes of its contents + * to users of assistive technology like screen readers. + * + * Dynamically added warnings, errors, or live status updates should be wrapped + * in a live region to ensure they are not missed when they appear. + * + * **Important:** + * Live regions must be present in the DOM _before_ + * the to-be announced content is rendered into it. + */ + +export const A11yLiveRegion = polymorphicForwardRef<'div'>( + ({ role = 'status', as: Component = 'div', children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); diff --git a/app/javascript/flavours/glitch/components/callout_inline/callout_inline.stories.tsx b/app/javascript/flavours/glitch/components/callout_inline/callout_inline.stories.tsx new file mode 100644 index 0000000000..f18af41dc0 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout_inline/callout_inline.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { CalloutInline } from '.'; + +const meta = { + title: 'Components/CalloutInline', + args: { + children: 'Contents here', + }, + component: CalloutInline, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Error: Story = { + args: { + variant: 'error', + }, +}; + +export const Warning: Story = { + args: { + variant: 'warning', + }, +}; + +export const Success: Story = { + args: { + variant: 'success', + }, +}; + +export const Info: Story = { + args: { + variant: 'info', + }, +}; diff --git a/app/javascript/flavours/glitch/components/callout_inline/index.tsx b/app/javascript/flavours/glitch/components/callout_inline/index.tsx new file mode 100644 index 0000000000..e2e6791963 --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout_inline/index.tsx @@ -0,0 +1,39 @@ +import type { FC } from 'react'; + +import classNames from 'classnames'; + +import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import ErrorIcon from '@/material-icons/400-24px/error.svg?react'; +import InfoIcon from '@/material-icons/400-24px/info.svg?react'; +import WarningIcon from '@/material-icons/400-24px/warning.svg?react'; + +import { Icon } from '../icon'; + +import classes from './styles.module.css'; + +export interface FieldStatus { + variant: 'error' | 'warning' | 'info' | 'success'; + message?: string; +} + +const iconMap: Record = { + error: ErrorIcon, + warning: WarningIcon, + info: InfoIcon, + success: CheckIcon, +}; + +export const CalloutInline: FC< + Partial & React.ComponentPropsWithoutRef<'div'> +> = ({ variant = 'error', message, className, children, ...props }) => { + return ( +
+ + {message ?? children} +
+ ); +}; diff --git a/app/javascript/flavours/glitch/components/callout_inline/styles.module.css b/app/javascript/flavours/glitch/components/callout_inline/styles.module.css new file mode 100644 index 0000000000..8d32f7df9b --- /dev/null +++ b/app/javascript/flavours/glitch/components/callout_inline/styles.module.css @@ -0,0 +1,29 @@ +.wrapper { + display: flex; + align-items: start; + gap: 4px; + font-size: 13px; + font-weight: 500; + + &[data-variant='success'] { + color: var(--color-text-success); + } + + &[data-variant='warning'] { + color: var(--color-text-warning); + } + + &[data-variant='error'] { + color: var(--color-text-error); + } + + &[data-variant='info'] { + color: var(--color-text-primary); + } +} + +.icon { + width: 16px; + height: 16px; + margin-top: 1px; +} diff --git a/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx index 4d208cf21b..16b3a53f0b 100644 --- a/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/checkbox_field.stories.tsx @@ -76,7 +76,7 @@ export const Optional: Story = { export const WithError: Story = { args: { required: false, - hasError: true, + status: 'error', }, }; diff --git a/app/javascript/flavours/glitch/components/form_fields/checkbox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/checkbox_field.tsx index 2b6933c847..c08b81ca36 100644 --- a/app/javascript/flavours/glitch/components/form_fields/checkbox_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/checkbox_field.tsx @@ -13,12 +13,12 @@ type Props = Omit, 'type'> & { export const CheckboxField = forwardRef< HTMLInputElement, Props & CommonFieldWrapperProps ->(({ id, label, hint, hasError, required, ...otherProps }, ref) => ( +>(({ id, label, hint, status, required, ...otherProps }, ref) => ( diff --git a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx index 3a011e5782..494003a22b 100644 --- a/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/combobox_field.tsx @@ -86,14 +86,14 @@ interface Props */ export const ComboboxFieldWithRef = ( - { id, label, hint, hasError, required, ...otherProps }: Props, + { id, label, hint, status, required, ...otherProps }: Props, ref: React.ForwardedRef, ) => ( {(inputProps) => } diff --git a/app/javascript/flavours/glitch/components/form_fields/copy_link_field.tsx b/app/javascript/flavours/glitch/components/form_fields/copy_link_field.tsx index b12bb8f5ad..6404e5ed56 100644 --- a/app/javascript/flavours/glitch/components/form_fields/copy_link_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/copy_link_field.tsx @@ -22,7 +22,7 @@ interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps { export const CopyLinkField = forwardRef( ( - { id, label, hint, hasError, value, required, className, ...otherProps }, + { id, label, hint, status, value, required, className, ...otherProps }, ref, ) => { const intl = useIntl(); @@ -48,7 +48,7 @@ export const CopyLinkField = forwardRef( label={label} hint={hint} required={required} - hasError={hasError} + status={status} inputId={id} > {(inputProps) => ( diff --git a/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx index 93deaf0dd6..5eb3e24ae4 100644 --- a/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/emoji_text_field.tsx @@ -37,7 +37,7 @@ export const EmojiTextInputField: FC< value, label, hint, - hasError, + status, maxLength, counterMax = maxLength, recommended, @@ -49,7 +49,7 @@ export const EmojiTextInputField: FC< const wrapperProps = { label, hint, - hasError, + status, counterMax, recommended, disabled, @@ -84,7 +84,7 @@ export const EmojiTextAreaField: FC< recommended, disabled, hint, - hasError, + status, ...otherProps }) => { const textareaRef = useRef(null); @@ -92,7 +92,7 @@ export const EmojiTextAreaField: FC< const wrapperProps = { label, hint, - hasError, + status, counterMax, recommended, disabled, diff --git a/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss b/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss index f222762af5..2751b3c8a0 100644 --- a/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/fieldset.module.scss @@ -1,7 +1,9 @@ .fieldset { + --container-gap: 12px; + display: flex; flex-direction: column; - gap: 12px; + gap: var(--container-gap); color: var(--color-text-primary); font-size: 15px; } @@ -17,3 +19,11 @@ column-gap: 24px; } } + +.status { + // If there's no content, we need to compensate for the parent's + // flex gap to avoid extra spacing below the field. + &:empty { + margin-top: calc(-1 * var(--container-gap)); + } +} diff --git a/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx b/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx index d52a95130b..6a8aa8cccd 100644 --- a/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/fieldset.tsx @@ -3,14 +3,19 @@ import type { ReactNode, FC } from 'react'; import { createContext, useId } from 'react'; +import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region'; +import type { FieldStatus } from 'flavours/glitch/components/callout_inline'; +import { CalloutInline } from 'flavours/glitch/components/callout_inline'; + import classes from './fieldset.module.scss'; +import { getFieldStatus } from './form_field_wrapper'; import formFieldWrapperClasses from './form_field_wrapper.module.scss'; interface FieldsetProps { legend: ReactNode; hint?: ReactNode; name?: string; - hasError?: boolean; + status?: FieldStatus | FieldStatus['variant']; layout?: 'vertical' | 'horizontal'; children: ReactNode; } @@ -26,22 +31,33 @@ export const Fieldset: FC = ({ legend, hint, name, - hasError, + status, layout, children, }) => { const uniqueId = useId(); const labelId = `${uniqueId}-label`; const hintId = `${uniqueId}-hint`; + const statusId = `${uniqueId}-status`; const fieldsetName = name || `${uniqueId}-fieldset-name`; const hasHint = !!hint; + const fieldStatus = getFieldStatus(status); + const hasStatusMessage = !!fieldStatus?.message; + + const descriptionIds = [ + hasHint ? hintId : '', + hasStatusMessage ? statusId : '', + ] + .filter((id) => !!id) + .join(' '); + return (
@@ -59,6 +75,11 @@ export const Fieldset: FC = ({ {children}
+ + {/* Live region must be rendered even when empty */} + + {hasStatusMessage && } +
); }; diff --git a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.module.scss b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.module.scss index faeb48aae4..cff93be8a6 100644 --- a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.module.scss @@ -46,6 +46,14 @@ font-size: 13px; } +.status { + // If there's no content, we need to compensate for the parent's + // flex gap to avoid extra spacing below the field. + &:empty { + margin-top: calc(-1 * var(--form-field-label-gap)); + } +} + .inputWrapper { display: block; } diff --git a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx index 6454153ab8..24ec3764ee 100644 --- a/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/form_field_wrapper.tsx @@ -7,6 +7,10 @@ import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import { A11yLiveRegion } from 'flavours/glitch/components/a11y_live_region'; +import type { FieldStatus } from 'flavours/glitch/components/callout_inline'; +import { CalloutInline } from 'flavours/glitch/components/callout_inline'; + import { FieldsetNameContext } from './fieldset'; import classes from './form_field_wrapper.module.scss'; @@ -20,7 +24,7 @@ interface FieldWrapperProps { label: ReactNode; hint?: ReactNode; required?: boolean; - hasError?: boolean; + status?: FieldStatus['variant'] | FieldStatus; inputId?: string; describedById?: string; inputPlacement?: 'inline-start' | 'inline-end'; @@ -33,7 +37,7 @@ interface FieldWrapperProps { */ export type CommonFieldWrapperProps = Pick< FieldWrapperProps, - 'label' | 'hint' | 'hasError' + 'label' | 'hint' | 'status' > & { wrapperClassName?: string }; /** @@ -48,27 +52,31 @@ export const FormFieldWrapper: FC = ({ hint, describedById, required, - hasError, + status, inputPlacement, children, className, }) => { const uniqueId = useId(); const inputId = inputIdProp || `${uniqueId}-input`; + const statusId = `${inputIdProp || uniqueId}-status`; const hintId = `${inputIdProp || uniqueId}-hint`; const hasHint = !!hint; + const fieldStatus = getFieldStatus(status); + const hasStatusMessage = !!fieldStatus?.message; const hasParentFieldset = !!useContext(FieldsetNameContext); + const descriptionIds = + [hasHint ? hintId : '', hasStatusMessage ? statusId : '', describedById] + .filter((id) => !!id) + .join(' ') || undefined; + const inputProps: InputProps = { required, id: inputId, + 'aria-describedby': descriptionIds, }; - if (hasHint) { - inputProps['aria-describedby'] = describedById - ? `${describedById} ${hintId}` - : hintId; - } const input = (
{children(inputProps)}
@@ -77,7 +85,7 @@ export const FormFieldWrapper: FC = ({ return (
{inputPlacement === 'inline-start' && input} @@ -100,6 +108,11 @@ export const FormFieldWrapper: FC = ({
{inputPlacement !== 'inline-start' && input} + + {/* Live region must be rendered even when empty */} + + {hasStatusMessage && } + ); }; @@ -121,3 +134,19 @@ const RequiredMark: FC<{ required?: boolean }> = ({ required }) => ); + +export function getFieldStatus(status: FieldWrapperProps['status']) { + if (!status) { + return null; + } + + if (typeof status === 'string') { + const fieldStatus: FieldStatus = { + variant: status, + message: '', + }; + return fieldStatus; + } + + return status; +} diff --git a/app/javascript/flavours/glitch/components/form_fields/radio_button_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/radio_button_field.stories.tsx index 95687abff3..1292b85724 100644 --- a/app/javascript/flavours/glitch/components/form_fields/radio_button_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/radio_button_field.stories.tsx @@ -71,7 +71,7 @@ export const Optional: Story = { export const WithError: Story = { args: { required: false, - hasError: true, + status: 'error', }, }; diff --git a/app/javascript/flavours/glitch/components/form_fields/radio_button_field.tsx b/app/javascript/flavours/glitch/components/form_fields/radio_button_field.tsx index 51f52168e0..cbc9020ca7 100644 --- a/app/javascript/flavours/glitch/components/form_fields/radio_button_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/radio_button_field.tsx @@ -15,7 +15,7 @@ type Props = Omit, 'type'> & { export const RadioButtonField = forwardRef< HTMLInputElement, Props & CommonFieldWrapperProps ->(({ id, label, hint, hasError, required, ...otherProps }, ref) => { +>(({ id, label, hint, status, required, ...otherProps }, ref) => { const fieldsetName = useContext(FieldsetNameContext); return ( @@ -23,7 +23,7 @@ export const RadioButtonField = forwardRef< label={label} hint={hint} required={required} - hasError={hasError} + status={status} inputId={id} inputPlacement='inline-start' > diff --git a/app/javascript/flavours/glitch/components/form_fields/select_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/select_field.stories.tsx index 469238dd44..c215a6e04a 100644 --- a/app/javascript/flavours/glitch/components/form_fields/select_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/select_field.stories.tsx @@ -51,7 +51,7 @@ export const Optional: Story = { export const WithError: Story = { args: { required: false, - hasError: true, + status: 'error', }, }; diff --git a/app/javascript/flavours/glitch/components/form_fields/select_field.tsx b/app/javascript/flavours/glitch/components/form_fields/select_field.tsx index 59854b578e..7c1bfdf47d 100644 --- a/app/javascript/flavours/glitch/components/form_fields/select_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/select_field.tsx @@ -19,12 +19,12 @@ interface Props */ export const SelectField = forwardRef( - ({ id, label, hint, required, hasError, children, ...otherProps }, ref) => ( + ({ id, label, hint, required, status, children, ...otherProps }, ref) => ( {(inputProps) => ( diff --git a/app/javascript/flavours/glitch/components/form_fields/text_area_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/text_area_field.stories.tsx index 190239aee2..f06d7bbdcf 100644 --- a/app/javascript/flavours/glitch/components/form_fields/text_area_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/text_area_field.stories.tsx @@ -38,7 +38,17 @@ export const Optional: Story = { export const WithError: Story = { args: { required: false, - hasError: true, + status: { variant: 'error', message: "This field can't be empty" }, + }, +}; + +export const WithWarning: Story = { + args: { + required: false, + status: { + variant: 'warning', + message: 'Special characters are not allowed', + }, }, }; diff --git a/app/javascript/flavours/glitch/components/form_fields/text_area_field.tsx b/app/javascript/flavours/glitch/components/form_fields/text_area_field.tsx index 1e4bacc041..1284aa9276 100644 --- a/app/javascript/flavours/glitch/components/form_fields/text_area_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/text_area_field.tsx @@ -26,14 +26,14 @@ export const TextAreaField = forwardRef< TextAreaProps & CommonFieldWrapperProps >( ( - { id, label, hint, required, hasError, wrapperClassName, ...otherProps }, + { id, label, hint, required, status, wrapperClassName, ...otherProps }, ref, ) => ( diff --git a/app/javascript/flavours/glitch/components/form_fields/text_input.module.scss b/app/javascript/flavours/glitch/components/form_fields/text_input.module.scss index 289ff1333a..f432f57055 100644 --- a/app/javascript/flavours/glitch/components/form_fields/text_input.module.scss +++ b/app/javascript/flavours/glitch/components/form_fields/text_input.module.scss @@ -29,16 +29,16 @@ color: var(--color-text-secondary); } - &:focus { - outline-color: var(--color-text-brand); - } - &:focus:user-invalid, &:required:user-invalid, [data-has-error='true'] & { outline-color: var(--color-text-error); } + &:focus { + outline-color: var(--color-text-brand); + } + &:required:user-valid { outline-color: var(--color-text-success); } diff --git a/app/javascript/flavours/glitch/components/form_fields/text_input_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/text_input_field.stories.tsx index 8e8d7e9923..702597a0c1 100644 --- a/app/javascript/flavours/glitch/components/form_fields/text_input_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/text_input_field.stories.tsx @@ -40,7 +40,17 @@ export const Optional: Story = { export const WithError: Story = { args: { required: false, - hasError: true, + status: 'error', + }, +}; + +export const WithWarning: Story = { + args: { + required: false, + status: { + variant: 'warning', + message: 'Special characters are not allowed', + }, }, }; diff --git a/app/javascript/flavours/glitch/components/form_fields/text_input_field.tsx b/app/javascript/flavours/glitch/components/form_fields/text_input_field.tsx index 7c61bd91ef..2a47b33db9 100644 --- a/app/javascript/flavours/glitch/components/form_fields/text_input_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/text_input_field.tsx @@ -25,14 +25,14 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {} export const TextInputField = forwardRef( ( - { id, label, hint, hasError, required, wrapperClassName, ...otherProps }, + { id, label, hint, status, required, wrapperClassName, ...otherProps }, ref, ) => ( diff --git a/app/javascript/flavours/glitch/components/form_fields/toggle_field.stories.tsx b/app/javascript/flavours/glitch/components/form_fields/toggle_field.stories.tsx index 924c18aa74..295600a3fd 100644 --- a/app/javascript/flavours/glitch/components/form_fields/toggle_field.stories.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/toggle_field.stories.tsx @@ -45,7 +45,7 @@ export const Optional: Story = { export const WithError: Story = { args: { required: false, - hasError: true, + status: 'error', }, }; diff --git a/app/javascript/flavours/glitch/components/form_fields/toggle_field.tsx b/app/javascript/flavours/glitch/components/form_fields/toggle_field.tsx index 6cafbcdc36..75fdb8f21b 100644 --- a/app/javascript/flavours/glitch/components/form_fields/toggle_field.tsx +++ b/app/javascript/flavours/glitch/components/form_fields/toggle_field.tsx @@ -14,12 +14,12 @@ type Props = Omit, 'type'> & { export const ToggleField = forwardRef< HTMLInputElement, Props & CommonFieldWrapperProps ->(({ id, label, hint, hasError, required, ...otherProps }, ref) => ( +>(({ id, label, hint, status, required, ...otherProps }, ref) => ( diff --git a/app/javascript/flavours/glitch/features/account_timeline/modals/note_modal.tsx b/app/javascript/flavours/glitch/features/account_timeline/modals/note_modal.tsx index 3c55d15024..2a45d5fab4 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/modals/note_modal.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/modals/note_modal.tsx @@ -141,8 +141,11 @@ const InnerNodeModal: FC<{ onChange={handleChange} label={intl.formatMessage(messages.fieldLabel)} className={classes.noteInput} - hasError={state === 'error'} - hint={errorText} + status={ + state === 'error' + ? { variant: 'error', message: errorText } + : undefined + } // eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal. autoFocus /> diff --git a/app/javascript/flavours/glitch/features/collections/editor/details.tsx b/app/javascript/flavours/glitch/features/collections/editor/details.tsx index 4e886f985a..22dda49795 100644 --- a/app/javascript/flavours/glitch/features/collections/editor/details.tsx +++ b/app/javascript/flavours/glitch/features/collections/editor/details.tsx @@ -1,12 +1,15 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { isFulfilled } from '@reduxjs/toolkit'; -import { inputToHashtag } from '@/flavours/glitch/utils/hashtags'; +import { + hasSpecialCharacters, + inputToHashtag, +} from '@/flavours/glitch/utils/hashtags'; import type { ApiCreateCollectionPayload, ApiUpdateCollectionPayload, @@ -31,6 +34,7 @@ import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; export const CollectionDetails: React.FC = () => { + const intl = useIntl(); const dispatch = useAppDispatch(); const history = useHistory(); const { id, name, description, topic, discoverable, sensitive, accountIds } = @@ -152,6 +156,11 @@ export const CollectionDetails: React.FC = () => { ], ); + const topicHasSpecialCharacters = useMemo( + () => hasSpecialCharacters(topic), + [topic], + ); + return (
@@ -224,6 +233,18 @@ export const CollectionDetails: React.FC = () => { autoCorrect='off' spellCheck='false' maxLength={40} + status={ + topicHasSpecialCharacters + ? { + variant: 'warning', + message: intl.formatMessage({ + id: 'collections.topic_special_chars_hint', + defaultMessage: + 'Special characters will be removed when saving', + }), + } + : undefined + } />
@@ -255,7 +255,7 @@ export const Profile: React.FC<{ } value={note} onChange={handleNoteChange} - hasError={!!errors?.note} + status={errors?.note ? 'error' : undefined} id='note' /> diff --git a/app/javascript/flavours/glitch/reducers/slices/collections.ts b/app/javascript/flavours/glitch/reducers/slices/collections.ts index 5db5530894..8d0e9ad147 100644 --- a/app/javascript/flavours/glitch/reducers/slices/collections.ts +++ b/app/javascript/flavours/glitch/reducers/slices/collections.ts @@ -23,6 +23,7 @@ import { createAppSelector, createDataLoadingThunk, } from '@/flavours/glitch/store/typed_functions'; +import { inputToHashtag } from '@/flavours/glitch/utils/hashtags'; type QueryStatus = 'idle' | 'loading' | 'error'; @@ -82,7 +83,7 @@ const collectionSlice = createSlice({ id: collection?.id ?? null, name: collection?.name ?? '', description: collection?.description ?? '', - topic: collection?.tag?.name ?? '', + topic: inputToHashtag(collection?.tag?.name ?? ''), language: collection?.language ?? '', discoverable: collection?.discoverable ?? true, sensitive: collection?.sensitive ?? false, diff --git a/app/javascript/flavours/glitch/utils/hashtags.ts b/app/javascript/flavours/glitch/utils/hashtags.ts index d14efe5db3..ff90a88465 100644 --- a/app/javascript/flavours/glitch/utils/hashtags.ts +++ b/app/javascript/flavours/glitch/utils/hashtags.ts @@ -59,3 +59,8 @@ export const inputToHashtag = (input: string): string => { return `#${words.join('')}${trailingSpace}`; }; + +export const hasSpecialCharacters = (input: string) => { + // Regex matches any character NOT a letter/digit, except the # + return /[^a-zA-Z0-9# ]/.test(input); +};