Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Form Error List #1252

Merged
merged 16 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
dlnr marked this conversation as resolved.
Show resolved Hide resolved

## Guidelines

- Always show a Form Error List when there is a validation error, even if there’s only one.
dlnr marked this conversation as resolved.
Show resolved Hide resolved
- 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>`.
dlnr marked this conversation as resolved.
Show resolved Hide resolved

## 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="Testheading" />)

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

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
dlnr marked this conversation as resolved.
Show resolved Hide resolved
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',
dlnr marked this conversation as resolved.
Show resolved Hide resolved
headingLevel = 2,
...restProps
}: FormErrorListProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
if (errors.length === 0) return undefined

return (
<Alert
{...restProps}
className={clsx('ams-form-error-list', className)}
headingLevel={headingLevel}
ref={ref}
role="alert"
severity="error"
title={heading}
>
<LinkList>
dlnr marked this conversation as resolved.
Show resolved Hide resolved
{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 = {}
Loading