[Glitch] Allow displaying field status (error, warning, info) under form fields

Port 890b2673fcb8416ec62d71f993d57b47e257b2f8 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-03-13 14:55:17 +01:00 committed by Claire
parent 3afb58ad18
commit 8ec882df0c
30 changed files with 330 additions and 52 deletions

View File

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { A11yLiveRegion } from '.';
const meta = {
title: 'Components/A11yLiveRegion',
component: A11yLiveRegion,
} satisfies Meta<typeof A11yLiveRegion>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Polite: Story = {
args: {
children: "This field can't be empty.",
},
};
export const Assertive: Story = {
args: {
...Polite.args,
role: 'alert',
},
};

View File

@ -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 (
<Component
role={role}
aria-live={role === 'alert' ? 'assertive' : 'polite'}
ref={ref}
{...props}
>
{children}
</Component>
);
},
);

View File

@ -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<typeof CalloutInline>;
export default meta;
type Story = StoryObj<typeof meta>;
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',
},
};

View File

@ -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<FieldStatus['variant'], React.FunctionComponent> = {
error: ErrorIcon,
warning: WarningIcon,
info: InfoIcon,
success: CheckIcon,
};
export const CalloutInline: FC<
Partial<FieldStatus> & React.ComponentPropsWithoutRef<'div'>
> = ({ variant = 'error', message, className, children, ...props }) => {
return (
<div
{...props}
className={classNames(className, classes.wrapper)}
data-variant={variant}
>
<Icon id={variant} icon={iconMap[variant]} className={classes.icon} />
{message ?? children}
</div>
);
};

View File

@ -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;
}

View File

@ -76,7 +76,7 @@ export const Optional: Story = {
export const WithError: Story = { export const WithError: Story = {
args: { args: {
required: false, required: false,
hasError: true, status: 'error',
}, },
}; };

View File

@ -13,12 +13,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const CheckboxField = forwardRef< export const CheckboxField = forwardRef<
HTMLInputElement, HTMLInputElement,
Props & CommonFieldWrapperProps Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => ( >(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper <FormFieldWrapper
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} status={status}
inputId={id} inputId={id}
inputPlacement='inline-start' inputPlacement='inline-start'
> >

View File

@ -86,14 +86,14 @@ interface Props<T extends ComboboxItem>
*/ */
export const ComboboxFieldWithRef = <T extends ComboboxItem>( export const ComboboxFieldWithRef = <T extends ComboboxItem>(
{ id, label, hint, hasError, required, ...otherProps }: Props<T>, { id, label, hint, status, required, ...otherProps }: Props<T>,
ref: React.ForwardedRef<HTMLInputElement>, ref: React.ForwardedRef<HTMLInputElement>,
) => ( ) => (
<FormFieldWrapper <FormFieldWrapper
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} status={status}
inputId={id} inputId={id}
> >
{(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />} {(inputProps) => <Combobox {...otherProps} {...inputProps} ref={ref} />}

View File

@ -22,7 +22,7 @@ interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>( export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
( (
{ id, label, hint, hasError, value, required, className, ...otherProps }, { id, label, hint, status, value, required, className, ...otherProps },
ref, ref,
) => { ) => {
const intl = useIntl(); const intl = useIntl();
@ -48,7 +48,7 @@ export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} status={status}
inputId={id} inputId={id}
> >
{(inputProps) => ( {(inputProps) => (

View File

@ -37,7 +37,7 @@ export const EmojiTextInputField: FC<
value, value,
label, label,
hint, hint,
hasError, status,
maxLength, maxLength,
counterMax = maxLength, counterMax = maxLength,
recommended, recommended,
@ -49,7 +49,7 @@ export const EmojiTextInputField: FC<
const wrapperProps = { const wrapperProps = {
label, label,
hint, hint,
hasError, status,
counterMax, counterMax,
recommended, recommended,
disabled, disabled,
@ -84,7 +84,7 @@ export const EmojiTextAreaField: FC<
recommended, recommended,
disabled, disabled,
hint, hint,
hasError, status,
...otherProps ...otherProps
}) => { }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -92,7 +92,7 @@ export const EmojiTextAreaField: FC<
const wrapperProps = { const wrapperProps = {
label, label,
hint, hint,
hasError, status,
counterMax, counterMax,
recommended, recommended,
disabled, disabled,

View File

@ -1,7 +1,9 @@
.fieldset { .fieldset {
--container-gap: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: var(--container-gap);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 15px; font-size: 15px;
} }
@ -17,3 +19,11 @@
column-gap: 24px; 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));
}
}

View File

@ -3,14 +3,19 @@
import type { ReactNode, FC } from 'react'; import type { ReactNode, FC } from 'react';
import { createContext, useId } 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 classes from './fieldset.module.scss';
import { getFieldStatus } from './form_field_wrapper';
import formFieldWrapperClasses from './form_field_wrapper.module.scss'; import formFieldWrapperClasses from './form_field_wrapper.module.scss';
interface FieldsetProps { interface FieldsetProps {
legend: ReactNode; legend: ReactNode;
hint?: ReactNode; hint?: ReactNode;
name?: string; name?: string;
hasError?: boolean; status?: FieldStatus | FieldStatus['variant'];
layout?: 'vertical' | 'horizontal'; layout?: 'vertical' | 'horizontal';
children: ReactNode; children: ReactNode;
} }
@ -26,22 +31,33 @@ export const Fieldset: FC<FieldsetProps> = ({
legend, legend,
hint, hint,
name, name,
hasError, status,
layout, layout,
children, children,
}) => { }) => {
const uniqueId = useId(); const uniqueId = useId();
const labelId = `${uniqueId}-label`; const labelId = `${uniqueId}-label`;
const hintId = `${uniqueId}-hint`; const hintId = `${uniqueId}-hint`;
const statusId = `${uniqueId}-status`;
const fieldsetName = name || `${uniqueId}-fieldset-name`; const fieldsetName = name || `${uniqueId}-fieldset-name`;
const hasHint = !!hint; const hasHint = !!hint;
const fieldStatus = getFieldStatus(status);
const hasStatusMessage = !!fieldStatus?.message;
const descriptionIds = [
hasHint ? hintId : '',
hasStatusMessage ? statusId : '',
]
.filter((id) => !!id)
.join(' ');
return ( return (
<fieldset <fieldset
className={classes.fieldset} className={classes.fieldset}
data-has-error={hasError} data-has-error={status === 'error'}
aria-labelledby={labelId} aria-labelledby={labelId}
aria-describedby={hintId} aria-describedby={descriptionIds}
> >
<div className={formFieldWrapperClasses.labelWrapper}> <div className={formFieldWrapperClasses.labelWrapper}>
<div id={labelId} className={formFieldWrapperClasses.label}> <div id={labelId} className={formFieldWrapperClasses.label}>
@ -59,6 +75,11 @@ export const Fieldset: FC<FieldsetProps> = ({
{children} {children}
</FieldsetNameContext.Provider> </FieldsetNameContext.Provider>
</div> </div>
{/* Live region must be rendered even when empty */}
<A11yLiveRegion className={classes.status} id={statusId}>
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
</A11yLiveRegion>
</fieldset> </fieldset>
); );
}; };

View File

@ -46,6 +46,14 @@
font-size: 13px; 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 { .inputWrapper {
display: block; display: block;
} }

View File

@ -7,6 +7,10 @@ import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; 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 { FieldsetNameContext } from './fieldset';
import classes from './form_field_wrapper.module.scss'; import classes from './form_field_wrapper.module.scss';
@ -20,7 +24,7 @@ interface FieldWrapperProps {
label: ReactNode; label: ReactNode;
hint?: ReactNode; hint?: ReactNode;
required?: boolean; required?: boolean;
hasError?: boolean; status?: FieldStatus['variant'] | FieldStatus;
inputId?: string; inputId?: string;
describedById?: string; describedById?: string;
inputPlacement?: 'inline-start' | 'inline-end'; inputPlacement?: 'inline-start' | 'inline-end';
@ -33,7 +37,7 @@ interface FieldWrapperProps {
*/ */
export type CommonFieldWrapperProps = Pick< export type CommonFieldWrapperProps = Pick<
FieldWrapperProps, FieldWrapperProps,
'label' | 'hint' | 'hasError' 'label' | 'hint' | 'status'
> & { wrapperClassName?: string }; > & { wrapperClassName?: string };
/** /**
@ -48,27 +52,31 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
hint, hint,
describedById, describedById,
required, required,
hasError, status,
inputPlacement, inputPlacement,
children, children,
className, className,
}) => { }) => {
const uniqueId = useId(); const uniqueId = useId();
const inputId = inputIdProp || `${uniqueId}-input`; const inputId = inputIdProp || `${uniqueId}-input`;
const statusId = `${inputIdProp || uniqueId}-status`;
const hintId = `${inputIdProp || uniqueId}-hint`; const hintId = `${inputIdProp || uniqueId}-hint`;
const hasHint = !!hint; const hasHint = !!hint;
const fieldStatus = getFieldStatus(status);
const hasStatusMessage = !!fieldStatus?.message;
const hasParentFieldset = !!useContext(FieldsetNameContext); const hasParentFieldset = !!useContext(FieldsetNameContext);
const descriptionIds =
[hasHint ? hintId : '', hasStatusMessage ? statusId : '', describedById]
.filter((id) => !!id)
.join(' ') || undefined;
const inputProps: InputProps = { const inputProps: InputProps = {
required, required,
id: inputId, id: inputId,
'aria-describedby': descriptionIds,
}; };
if (hasHint) {
inputProps['aria-describedby'] = describedById
? `${describedById} ${hintId}`
: hintId;
}
const input = ( const input = (
<div className={classes.inputWrapper}>{children(inputProps)}</div> <div className={classes.inputWrapper}>{children(inputProps)}</div>
@ -77,7 +85,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
return ( return (
<div <div
className={classNames(classes.wrapper, className)} className={classNames(classes.wrapper, className)}
data-has-error={hasError} data-has-error={fieldStatus?.variant === 'error'}
data-input-placement={inputPlacement} data-input-placement={inputPlacement}
> >
{inputPlacement === 'inline-start' && input} {inputPlacement === 'inline-start' && input}
@ -100,6 +108,11 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
</div> </div>
{inputPlacement !== 'inline-start' && input} {inputPlacement !== 'inline-start' && input}
{/* Live region must be rendered even when empty */}
<A11yLiveRegion className={classes.status} id={statusId}>
{hasStatusMessage && <CalloutInline {...fieldStatus} />}
</A11yLiveRegion>
</div> </div>
); );
}; };
@ -121,3 +134,19 @@ const RequiredMark: FC<{ required?: boolean }> = ({ required }) =>
<FormattedMessage id='form_field.optional' defaultMessage='(optional)' /> <FormattedMessage id='form_field.optional' defaultMessage='(optional)' />
</> </>
); );
export function getFieldStatus(status: FieldWrapperProps['status']) {
if (!status) {
return null;
}
if (typeof status === 'string') {
const fieldStatus: FieldStatus = {
variant: status,
message: '',
};
return fieldStatus;
}
return status;
}

View File

@ -71,7 +71,7 @@ export const Optional: Story = {
export const WithError: Story = { export const WithError: Story = {
args: { args: {
required: false, required: false,
hasError: true, status: 'error',
}, },
}; };

View File

@ -15,7 +15,7 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const RadioButtonField = forwardRef< export const RadioButtonField = forwardRef<
HTMLInputElement, HTMLInputElement,
Props & CommonFieldWrapperProps Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => { >(({ id, label, hint, status, required, ...otherProps }, ref) => {
const fieldsetName = useContext(FieldsetNameContext); const fieldsetName = useContext(FieldsetNameContext);
return ( return (
@ -23,7 +23,7 @@ export const RadioButtonField = forwardRef<
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} status={status}
inputId={id} inputId={id}
inputPlacement='inline-start' inputPlacement='inline-start'
> >

View File

@ -51,7 +51,7 @@ export const Optional: Story = {
export const WithError: Story = { export const WithError: Story = {
args: { args: {
required: false, required: false,
hasError: true, status: 'error',
}, },
}; };

View File

@ -19,12 +19,12 @@ interface Props
*/ */
export const SelectField = forwardRef<HTMLSelectElement, Props>( export const SelectField = forwardRef<HTMLSelectElement, Props>(
({ id, label, hint, required, hasError, children, ...otherProps }, ref) => ( ({ id, label, hint, required, status, children, ...otherProps }, ref) => (
<FormFieldWrapper <FormFieldWrapper
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} status={status}
inputId={id} inputId={id}
> >
{(inputProps) => ( {(inputProps) => (

View File

@ -38,7 +38,17 @@ export const Optional: Story = {
export const WithError: Story = { export const WithError: Story = {
args: { args: {
required: false, 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',
},
}, },
}; };

View File

@ -26,14 +26,14 @@ export const TextAreaField = forwardRef<
TextAreaProps & CommonFieldWrapperProps TextAreaProps & CommonFieldWrapperProps
>( >(
( (
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps }, { id, label, hint, required, status, wrapperClassName, ...otherProps },
ref, ref,
) => ( ) => (
<FormFieldWrapper <FormFieldWrapper
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} status={status}
inputId={id} inputId={id}
className={wrapperClassName} className={wrapperClassName}
> >

View File

@ -29,16 +29,16 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
&:focus {
outline-color: var(--color-text-brand);
}
&:focus:user-invalid, &:focus:user-invalid,
&:required:user-invalid, &:required:user-invalid,
[data-has-error='true'] & { [data-has-error='true'] & {
outline-color: var(--color-text-error); outline-color: var(--color-text-error);
} }
&:focus {
outline-color: var(--color-text-brand);
}
&:required:user-valid { &:required:user-valid {
outline-color: var(--color-text-success); outline-color: var(--color-text-success);
} }

View File

@ -40,7 +40,17 @@ export const Optional: Story = {
export const WithError: Story = { export const WithError: Story = {
args: { args: {
required: false, required: false,
hasError: true, status: 'error',
},
};
export const WithWarning: Story = {
args: {
required: false,
status: {
variant: 'warning',
message: 'Special characters are not allowed',
},
}, },
}; };

View File

@ -25,14 +25,14 @@ interface Props extends TextInputProps, CommonFieldWrapperProps {}
export const TextInputField = forwardRef<HTMLInputElement, Props>( export const TextInputField = forwardRef<HTMLInputElement, Props>(
( (
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps }, { id, label, hint, status, required, wrapperClassName, ...otherProps },
ref, ref,
) => ( ) => (
<FormFieldWrapper <FormFieldWrapper
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} status={status}
inputId={id} inputId={id}
className={wrapperClassName} className={wrapperClassName}
> >

View File

@ -45,7 +45,7 @@ export const Optional: Story = {
export const WithError: Story = { export const WithError: Story = {
args: { args: {
required: false, required: false,
hasError: true, status: 'error',
}, },
}; };

View File

@ -14,12 +14,12 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & {
export const ToggleField = forwardRef< export const ToggleField = forwardRef<
HTMLInputElement, HTMLInputElement,
Props & CommonFieldWrapperProps Props & CommonFieldWrapperProps
>(({ id, label, hint, hasError, required, ...otherProps }, ref) => ( >(({ id, label, hint, status, required, ...otherProps }, ref) => (
<FormFieldWrapper <FormFieldWrapper
label={label} label={label}
hint={hint} hint={hint}
required={required} required={required}
hasError={hasError} status={status}
inputId={id} inputId={id}
inputPlacement='inline-end' inputPlacement='inline-end'
> >

View File

@ -141,8 +141,11 @@ const InnerNodeModal: FC<{
onChange={handleChange} onChange={handleChange}
label={intl.formatMessage(messages.fieldLabel)} label={intl.formatMessage(messages.fieldLabel)}
className={classes.noteInput} className={classes.noteInput}
hasError={state === 'error'} status={
hint={errorText} 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. // eslint-disable-next-line jsx-a11y/no-autofocus -- We want to focus here as it's a modal.
autoFocus autoFocus
/> />

View File

@ -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 { useHistory } from 'react-router-dom';
import { isFulfilled } from '@reduxjs/toolkit'; import { isFulfilled } from '@reduxjs/toolkit';
import { inputToHashtag } from '@/flavours/glitch/utils/hashtags'; import {
hasSpecialCharacters,
inputToHashtag,
} from '@/flavours/glitch/utils/hashtags';
import type { import type {
ApiCreateCollectionPayload, ApiCreateCollectionPayload,
ApiUpdateCollectionPayload, ApiUpdateCollectionPayload,
@ -31,6 +34,7 @@ import classes from './styles.module.scss';
import { WizardStepHeader } from './wizard_step_header'; import { WizardStepHeader } from './wizard_step_header';
export const CollectionDetails: React.FC = () => { export const CollectionDetails: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const history = useHistory(); const history = useHistory();
const { id, name, description, topic, discoverable, sensitive, accountIds } = const { id, name, description, topic, discoverable, sensitive, accountIds } =
@ -152,6 +156,11 @@ export const CollectionDetails: React.FC = () => {
], ],
); );
const topicHasSpecialCharacters = useMemo(
() => hasSpecialCharacters(topic),
[topic],
);
return ( return (
<form onSubmit={handleSubmit} className={classes.form}> <form onSubmit={handleSubmit} className={classes.form}>
<FormStack className={classes.formFieldStack}> <FormStack className={classes.formFieldStack}>
@ -224,6 +233,18 @@ export const CollectionDetails: React.FC = () => {
autoCorrect='off' autoCorrect='off'
spellCheck='false' spellCheck='false'
maxLength={40} maxLength={40}
status={
topicHasSpecialCharacters
? {
variant: 'warning',
message: intl.formatMessage({
id: 'collections.topic_special_chars_hint',
defaultMessage:
'Special characters will be removed when saving',
}),
}
: undefined
}
/> />
<Fieldset <Fieldset

View File

@ -233,7 +233,7 @@ export const Profile: React.FC<{
} }
value={displayName} value={displayName}
onChange={handleDisplayNameChange} onChange={handleDisplayNameChange}
hasError={!!errors?.display_name} status={errors?.display_name ? 'error' : undefined}
id='display_name' id='display_name'
/> />
</div> </div>
@ -255,7 +255,7 @@ export const Profile: React.FC<{
} }
value={note} value={note}
onChange={handleNoteChange} onChange={handleNoteChange}
hasError={!!errors?.note} status={errors?.note ? 'error' : undefined}
id='note' id='note'
/> />
</div> </div>

View File

@ -23,6 +23,7 @@ import {
createAppSelector, createAppSelector,
createDataLoadingThunk, createDataLoadingThunk,
} from '@/flavours/glitch/store/typed_functions'; } from '@/flavours/glitch/store/typed_functions';
import { inputToHashtag } from '@/flavours/glitch/utils/hashtags';
type QueryStatus = 'idle' | 'loading' | 'error'; type QueryStatus = 'idle' | 'loading' | 'error';
@ -82,7 +83,7 @@ const collectionSlice = createSlice({
id: collection?.id ?? null, id: collection?.id ?? null,
name: collection?.name ?? '', name: collection?.name ?? '',
description: collection?.description ?? '', description: collection?.description ?? '',
topic: collection?.tag?.name ?? '', topic: inputToHashtag(collection?.tag?.name ?? ''),
language: collection?.language ?? '', language: collection?.language ?? '',
discoverable: collection?.discoverable ?? true, discoverable: collection?.discoverable ?? true,
sensitive: collection?.sensitive ?? false, sensitive: collection?.sensitive ?? false,

View File

@ -59,3 +59,8 @@ export const inputToHashtag = (input: string): string => {
return `#${words.join('')}${trailingSpace}`; 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);
};