Skip to content

Commit

Permalink
feat: Add Form Error List (#1252)
Browse files Browse the repository at this point in the history
Co-authored-by: Vincent Smedinga <[email protected]>
  • Loading branch information
alimpens and VincentSmedinga authored Jun 7, 2024
1 parent bbec4de commit e879942
Show file tree
Hide file tree
Showing 11 changed files with 247 additions and 7 deletions.
35 changes: 35 additions & 0 deletions packages/css/src/components/form-error-list/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!-- @license CC0-1.0 -->

# 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 `<form>`-tag. If your page includes breadcrumbs or a back link, place it below these, but above the `<h1>`.

## 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/)
9 changes: 6 additions & 3 deletions packages/react/src/Alert/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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. */
Expand Down
90 changes: 90 additions & 0 deletions packages/react/src/FormErrorList/FormErrorList.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<FormErrorList errors={testErrors} />)

const component = screen.getByRole('alert')

expect(component).toBeInTheDocument()
expect(component).toBeVisible()
})

it('does not render when there are no errors', () => {
render(<FormErrorList errors={[]} />)

const component = screen.queryByRole('alert')

expect(component).not.toBeInTheDocument()
})

it('renders a design system BEM class name', () => {
render(<FormErrorList errors={testErrors} />)

const component = screen.getByRole('alert')

expect(component).toHaveClass('ams-form-error-list')
})

it('renders an additional class name', () => {
render(<FormErrorList errors={testErrors} className="extra" />)

const component = screen.getByRole('alert')

expect(component).toHaveClass('ams-form-error-list extra')
})

it('renders a list item and link for every error', () => {
render(<FormErrorList errors={testErrors} />)

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(<FormErrorList errors={testErrors} />)

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(<FormErrorList errors={testErrors} heading="Test heading" />)

const component = screen.getByRole('heading', { name: 'Test heading' })

expect(component).toBeInTheDocument()
})

it('renders the correct heading level', () => {
render(<FormErrorList errors={testErrors} headingLevel={4} />)

const component = screen.getByRole('heading', { level: 4 })

expect(component).toBeInTheDocument()
})

it('supports ForwardRef in React', () => {
const ref = createRef<HTMLDivElement>()

render(<FormErrorList errors={testErrors} ref={ref} />)

const component = screen.getByRole('alert')

expect(ref.current).toBe(component)
})
})
65 changes: 65 additions & 0 deletions packages/react/src/FormErrorList/FormErrorList.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>

export const FormErrorList = forwardRef(
(
{
className,
errors,
heading = 'Verbeter de fouten voor u verder gaat',
headingLevel = 2,
...restProps
}: FormErrorListProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
if (errors.length === 0) return undefined

return (
<Alert
{...restProps}
className={clsx('ams-form-error-list', className)}
heading={heading}
headingLevel={headingLevel}
ref={ref}
role="alert"
severity="error"
>
<LinkList>
{errors.map(({ id, label }) => (
<LinkList.Link href={id} key={`${id}-${label}`}>
{label}
</LinkList.Link>
))}
</LinkList>
</Alert>
)
},
)

FormErrorList.displayName = 'FormErrorList'
5 changes: 5 additions & 0 deletions packages/react/src/FormErrorList/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!-- @license CC0-1.0 -->

# React Form Error List component

[Form Error List documentation](../../../css/src/components/form-error-list/README.md)
2 changes: 2 additions & 0 deletions packages/react/src/FormErrorList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { FormErrorList } from './FormErrorList'
export type { FormError, FormErrorListProps } from './FormErrorList'
2 changes: 1 addition & 1 deletion packages/react/src/Heading/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export type { HeadingProps } from './Heading'
export { Heading } from './Heading'
export type { HeadingLevel, HeadingProps } from './Heading'
10 changes: 7 additions & 3 deletions packages/react/src/TableOfContents/TableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLAttributes<HTMLElement>>

const TableOfContentsRoot = forwardRef(
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

/* Append here */
export * from './FormErrorList'
export * from './TableOfContents'
export * from './ErrorMessage'
export * from './FileInput'
Expand Down
11 changes: 11 additions & 0 deletions storybook/src/components/FormErrorList/FormErrorList.docs.mdx
Original file line number Diff line number Diff line change
@@ -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";

<Meta of={FormErrorListStories} />

<Markdown>{README}</Markdown>

<Primary />

<Controls />
24 changes: 24 additions & 0 deletions storybook/src/components/FormErrorList/FormErrorList.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof FormErrorList>

export default meta

type Story = StoryObj<typeof meta>

export const Default: Story = {}

0 comments on commit e879942

Please sign in to comment.