diff --git a/packages/css/src/components/form-error-list/README.md b/packages/css/src/components/form-error-list/README.md new file mode 100644 index 0000000000..9ddd4542fd --- /dev/null +++ b/packages/css/src/components/form-error-list/README.md @@ -0,0 +1,35 @@ + + +# Form Error List + +Use this component at the top of a page to summarise any errors a user has made. +When a user makes an error, you must show both a Form Error List and an Error Message above each answer that contains an error. + +## Guidelines + +- Always show a Form Error List when there is a validation error, even if there’s only one. +- You must link the errors in the Form Error List to the answer they relate to (see below). + +## Linking from the Form Error List to each answer + +For questions that require a user to answer using a single field, like a file upload, select, textarea, text input or character count, link to the `id` of that field. + +When a user has to enter their answer into multiple fields, such as day, month and year fields, link to the `id` of the first field that contains an error. +If you do not know which field contains an error, link to the `id` of the first field. + +For questions that require a user to select one or more options from a list using Radios or Checkboxes, link to the `id` of the first Radio or Checkbox. + +## Where to put the Form Error List + +Put the Form Error List at the top of the main container, outside of the `
`-tag. If your page includes breadcrumbs or a back link, place it below these, but above the `

`. + +## Relevant WCAG requirements + +Pay extra attention to these parts: + +- [WCAG requirement 1.3.1](https://www.w3.org/TR/WCAG21/#info-and-relationships): the heading level of the Form Error List depends on where in the page it is placed, this may differ per page. + +## References + +- [Show an error summary above the form - NL Design System](https://www.nldesignsystem.nl/richtlijnen/formulieren/foutmeldingen#zet-een-samenvatting-van-de-foutmeldingen-boven-het-formulier) +- [Error Summary component - Gov.uk](https://design-system.service.gov.uk/components/error-summary/) diff --git a/packages/react/src/Alert/Alert.tsx b/packages/react/src/Alert/Alert.tsx index 5e87bb4b3e..7c476fe39d 100644 --- a/packages/react/src/Alert/Alert.tsx +++ b/packages/react/src/Alert/Alert.tsx @@ -8,7 +8,7 @@ import clsx from 'clsx' import { forwardRef } from 'react' import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' import { Heading } from '../Heading' -import type { HeadingProps } from '../Heading' +import type { HeadingLevel } from '../Heading' import { Icon } from '../Icon' import { IconButton } from '../IconButton' @@ -19,8 +19,11 @@ export type AlertProps = { closeButtonLabel?: string /** The text for the Heading. */ heading?: string - /** The hierarchical level of the Alert’s heading within the document. */ - headingLevel?: HeadingProps['level'] + /** + * The hierarchical level of the Heading within the document. + * Note: this intentionally does not change the font size. + */ + headingLevel?: HeadingLevel /** A function to run when dismissing. */ onClose?: () => void /** The significance of the message conveyed. */ diff --git a/packages/react/src/FormErrorList/FormErrorList.test.tsx b/packages/react/src/FormErrorList/FormErrorList.test.tsx new file mode 100644 index 0000000000..e1b16dd591 --- /dev/null +++ b/packages/react/src/FormErrorList/FormErrorList.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { FormErrorList } from './FormErrorList' +import '@testing-library/jest-dom' + +describe('Form error list', () => { + const testErrors = [ + { id: '#', label: 'Vul een geldige datum in (bijvoorbeeld 6 januari 2030).' }, + { id: '#', label: 'De geldigheidsdatum van uw paspoort moet in de toekomst liggen.' }, + ] + + it('renders', () => { + render() + + const component = screen.getByRole('alert') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('does not render when there are no errors', () => { + render() + + const component = screen.queryByRole('alert') + + expect(component).not.toBeInTheDocument() + }) + + it('renders a design system BEM class name', () => { + render() + + const component = screen.getByRole('alert') + + expect(component).toHaveClass('ams-form-error-list') + }) + + it('renders an additional class name', () => { + render() + + const component = screen.getByRole('alert') + + expect(component).toHaveClass('ams-form-error-list extra') + }) + + it('renders a list item and link for every error', () => { + render() + + const listitems = screen.getAllByRole('listitem') + const links = screen.getAllByRole('link') + + expect(listitems.length).toBe(2) + expect(links.length).toBe(2) + }) + + it('renders a link with the correct name and href for every error', () => { + render() + + const link1 = screen.getByRole('link', { name: testErrors[0].label }) + const link2 = screen.getByRole('link', { name: testErrors[1].label }) + + expect(link1).toHaveAttribute('href', testErrors[0].id) + expect(link2).toHaveAttribute('href', testErrors[1].id) + }) + + it('renders a custom heading', () => { + render() + + const component = screen.getByRole('heading', { name: 'Test heading' }) + + expect(component).toBeInTheDocument() + }) + + it('renders the correct heading level', () => { + render() + + const component = screen.getByRole('heading', { level: 4 }) + + expect(component).toBeInTheDocument() + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render() + + const component = screen.getByRole('alert') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/FormErrorList/FormErrorList.tsx b/packages/react/src/FormErrorList/FormErrorList.tsx new file mode 100644 index 0000000000..15dc5c3c8b --- /dev/null +++ b/packages/react/src/FormErrorList/FormErrorList.tsx @@ -0,0 +1,65 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import clsx from 'clsx' +import { forwardRef } from 'react' +import type { ForwardedRef, HTMLAttributes } from 'react' +import { Alert } from '../Alert' +import type { HeadingLevel } from '../Heading' +import { LinkList } from '../LinkList' + +export type FormError = { + id: string + label: string +} + +export type FormErrorListProps = { + /** The list of error messages to display. */ + errors: FormError[] + /** The text for the Heading. */ + heading?: string + /** + * The hierarchical level of the Heading within the document. + * Note: this intentionally does not change the font size. + */ + headingLevel?: HeadingLevel +} & HTMLAttributes + +export const FormErrorList = forwardRef( + ( + { + className, + errors, + heading = 'Verbeter de fouten voor u verder gaat', + headingLevel = 2, + ...restProps + }: FormErrorListProps, + ref: ForwardedRef, + ) => { + if (errors.length === 0) return undefined + + return ( + + + {errors.map(({ id, label }) => ( + + {label} + + ))} + + + ) + }, +) + +FormErrorList.displayName = 'FormErrorList' diff --git a/packages/react/src/FormErrorList/README.md b/packages/react/src/FormErrorList/README.md new file mode 100644 index 0000000000..6ecaab7111 --- /dev/null +++ b/packages/react/src/FormErrorList/README.md @@ -0,0 +1,5 @@ + + +# React Form Error List component + +[Form Error List documentation](../../../css/src/components/form-error-list/README.md) diff --git a/packages/react/src/FormErrorList/index.ts b/packages/react/src/FormErrorList/index.ts new file mode 100644 index 0000000000..9f699d683f --- /dev/null +++ b/packages/react/src/FormErrorList/index.ts @@ -0,0 +1,2 @@ +export { FormErrorList } from './FormErrorList' +export type { FormError, FormErrorListProps } from './FormErrorList' diff --git a/packages/react/src/Heading/index.ts b/packages/react/src/Heading/index.ts index 4c4d1020ec..0f3e45e47e 100644 --- a/packages/react/src/Heading/index.ts +++ b/packages/react/src/Heading/index.ts @@ -1,2 +1,2 @@ -export type { HeadingProps } from './Heading' export { Heading } from './Heading' +export type { HeadingLevel, HeadingProps } from './Heading' diff --git a/packages/react/src/TableOfContents/TableOfContents.tsx b/packages/react/src/TableOfContents/TableOfContents.tsx index 0ea362c1be..db7c78c303 100644 --- a/packages/react/src/TableOfContents/TableOfContents.tsx +++ b/packages/react/src/TableOfContents/TableOfContents.tsx @@ -8,13 +8,17 @@ import { forwardRef } from 'react' import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react' import { TableOfContentsLink } from './TableOfContentsLink' import { TableOfContentsList } from './TableOfContentsList' -import { Heading, type HeadingProps } from '../Heading' +import { Heading } from '../Heading' +import type { HeadingLevel } from '../Heading' export type TableOfContentsProps = { /** The text for the Heading. */ heading?: string - /** The hierarchical level of the Alert’s heading within the document. */ - headingLevel?: HeadingProps['level'] + /** + * The hierarchical level of the Heading within the document. + * Note: this intentionally does not change the font size. + */ + headingLevel?: HeadingLevel } & PropsWithChildren> const TableOfContentsRoot = forwardRef( diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d5908bfe67..cf6e5be48c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -4,6 +4,7 @@ */ /* Append here */ +export * from './FormErrorList' export * from './TableOfContents' export * from './ErrorMessage' export * from './FileInput' diff --git a/storybook/src/components/FormErrorList/FormErrorList.docs.mdx b/storybook/src/components/FormErrorList/FormErrorList.docs.mdx new file mode 100644 index 0000000000..fedbf60997 --- /dev/null +++ b/storybook/src/components/FormErrorList/FormErrorList.docs.mdx @@ -0,0 +1,11 @@ +import { Controls, Markdown, Meta, Primary } from "@storybook/blocks"; +import * as FormErrorListStories from "./FormErrorList.stories.tsx"; +import README from "../../../../packages/css/src/components/form-error-list/README.md?raw"; + + + +{README} + + + + diff --git a/storybook/src/components/FormErrorList/FormErrorList.stories.tsx b/storybook/src/components/FormErrorList/FormErrorList.stories.tsx new file mode 100644 index 0000000000..93104cd252 --- /dev/null +++ b/storybook/src/components/FormErrorList/FormErrorList.stories.tsx @@ -0,0 +1,24 @@ +/** + * @license EUPL-1.2+ + * Copyright Gemeente Amsterdam + */ + +import { FormErrorList } from '@amsterdam/design-system-react/src' +import { Meta, StoryObj } from '@storybook/react' + +const meta = { + title: 'Components/Forms/Form Error List', + component: FormErrorList, + args: { + errors: [ + { id: '#', label: 'Vul een geldige datum in (bijvoorbeeld 6 januari 2030).' }, + { id: '#', label: 'De geldigheidsdatum van uw paspoort moet in de toekomst liggen.' }, + ], + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {}