diff --git a/packages/conform-dom/index.ts b/packages/conform-dom/index.ts index 7a6c2d3a..deabda66 100644 --- a/packages/conform-dom/index.ts +++ b/packages/conform-dom/index.ts @@ -12,6 +12,7 @@ export interface FieldConfig extends FieldConstraint { defaultValue?: FieldValue; initialError?: Record; form?: string; + descriptionId?: string; errorId?: string; /** diff --git a/packages/conform-react/helpers.ts b/packages/conform-react/helpers.ts index 650a6cc0..214a2b81 100644 --- a/packages/conform-react/helpers.ts +++ b/packages/conform-react/helpers.ts @@ -45,17 +45,22 @@ interface TextareaProps extends FormControlProps { defaultValue?: string; } -type InputOptions = - | { - type: 'checkbox' | 'radio'; - hidden?: boolean; - value?: string; - } - | { - type?: Exclude; - hidden?: boolean; - value?: never; - }; +type BaseOptions = { + description?: boolean; + hidden?: boolean; +}; + +type InputOptions = BaseOptions & + ( + | { + type: 'checkbox' | 'radio'; + value?: string; + } + | { + type?: Exclude; + value?: never; + } + ); /** * Style to make the input element visually hidden @@ -75,7 +80,7 @@ const hiddenStyle: CSSProperties = { function getFormControlProps( config: FieldConfig, - options?: { hidden?: boolean }, + options?: BaseOptions, ): FormControlProps { const props: FormControlProps = { id: config.id, @@ -86,11 +91,18 @@ function getFormControlProps( if (config.id) { props.id = config.id; - props['aria-describedby'] = config.errorId; + } + + if (config.descriptionId && options?.description) { + props['aria-describedby'] = config.descriptionId; } if (config.errorId && config.error?.length) { props['aria-invalid'] = true; + props['aria-describedby'] = + config.descriptionId && options?.description + ? `${config.errorId} ${config.descriptionId}` + : config.errorId; } if (config.initialError && Object.entries(config.initialError).length > 0) { @@ -108,7 +120,7 @@ function getFormControlProps( export function input( config: FieldConfig, - options: { type: 'file' }, + options: InputOptions & { type: 'file' }, ): InputProps; export function input( config: FieldConfig, @@ -142,7 +154,7 @@ export function input( export function select( config: FieldConfig, - options?: { hidden?: boolean }, + options?: BaseOptions, ): SelectProps { const props: SelectProps = { ...getFormControlProps(config, options), @@ -155,7 +167,7 @@ export function select( export function textarea( config: FieldConfig, - options?: { hidden?: boolean }, + options?: BaseOptions, ): TextareaProps { const props: TextareaProps = { ...getFormControlProps(config, options), diff --git a/packages/conform-react/hooks.ts b/packages/conform-react/hooks.ts index 1c787e7b..f576056a 100644 --- a/packages/conform-react/hooks.ts +++ b/packages/conform-react/hooks.ts @@ -404,11 +404,11 @@ export function useForm< form.id = config.id; form.errorId = `${config.id}-error`; form.props.id = form.id; - form.props['aria-describedby'] = form.errorId; } if (form.errorId && form.errors.length > 0) { form.props['aria-invalid'] = 'true'; + form.props['aria-describedby'] = form.errorId; } return [form, fieldset]; @@ -615,6 +615,7 @@ export function useFieldset>( field.form = fieldsetConfig.form; field.id = `${fieldsetConfig.form}-${field.name}`; field.errorId = `${field.id}-error`; + field.descriptionId = `${field.id}-description`; } return field; @@ -811,6 +812,7 @@ export function useFieldList( fieldConfig.form = config.form; fieldConfig.id = `${config.form}-${config.name}`; fieldConfig.errorId = `${fieldConfig.id}-error`; + fieldConfig.descriptionId = `${fieldConfig.id}-description`; } return { diff --git a/playground/app/routes/input-attributes.tsx b/playground/app/routes/input-attributes.tsx index daebc748..52650bc8 100644 --- a/playground/app/routes/input-attributes.tsx +++ b/playground/app/routes/input-attributes.tsx @@ -1,6 +1,6 @@ import { conform, useForm, parse } from '@conform-to/react'; -import { json, type ActionArgs } from '@remix-run/node'; -import { Form, useActionData } from '@remix-run/react'; +import { json, type ActionArgs, type LoaderArgs } from '@remix-run/node'; +import { Form, useActionData, useLoaderData } from '@remix-run/react'; import { useRef } from 'react'; import { Playground, Field, Alert } from '~/components'; @@ -12,6 +12,14 @@ interface Schema { tags: string[]; } +export async function loader({ request }: LoaderArgs) { + const url = new URL(request.url); + + return json({ + enableDescription: url.searchParams.get('enableDescription') === 'yes', + }); +} + export async function action({ request }: ActionArgs) { const formData = await request.formData(); const submission = parse(formData); @@ -25,6 +33,7 @@ export async function action({ request }: ActionArgs) { } export default function Example() { + const { enableDescription } = useLoaderData(); const lastSubmission = useActionData(); const ref = useRef(null); const [form, { title, description, images, rating, tags }] = useForm({ @@ -69,16 +78,30 @@ export default function Example() { - + -