[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:
parent
3afb58ad18
commit
8ec882df0c
@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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} />}
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user