Skip to content

Commit

Permalink
feat: Add Error Message (#1247)
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 5, 2024
1 parent d28b1e3 commit 1dec2ea
Show file tree
Hide file tree
Showing 15 changed files with 293 additions and 8 deletions.
11 changes: 11 additions & 0 deletions packages/css/src/components/error-message/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!-- @license CC0-1.0 -->

# Error Message

Show an error message when there is a form field validation error.
In the error message explain what went wrong and how to fix it.

For guidance and examples on using error messages in a form,
refer to the [Field](/docs/components-forms-field--docs) and [Field Set](/docs/components-forms-field-set--docs) documentation.

Read the documentation by [NL Design System](https://www.nldesignsystem.nl/richtlijnen/formulieren/foutmeldingen) and [Gov.uk](https://design-system.service.gov.uk/components/error-message/) for more information on the contents of error messages and when to show them.
22 changes: 22 additions & 0 deletions packages/css/src/components/error-message/error-message.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

@import "../../common/text-rendering";

@mixin reset {
box-sizing: border-box;
margin-block: 0;
}

.ams-error-message {
color: var(--ams-error-message-color);
font-family: var(--ams-error-message-font-family);
font-size: var(--ams-error-message-font-size);
font-weight: var(--ams-error-message-font-weight);
line-height: var(--ams-error-message-line-height);

@include text-rendering;
@include reset;
}
1 change: 1 addition & 0 deletions packages/css/src/components/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/

/* Append here */
@import "./error-message/error-message";
@import "./file-input/file-input";
@import "./field/field";
@import "./select/select";
Expand Down
51 changes: 51 additions & 0 deletions packages/react/src/ErrorMessage/ErrorMessage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react'
import { createRef } from 'react'
import { ErrorMessage } from './ErrorMessage'
import '@testing-library/jest-dom'

describe('Error message', () => {
it('renders', () => {
render(<ErrorMessage />)
const component = screen.getByRole('paragraph')

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

it('renders a design system BEM class name', () => {
render(<ErrorMessage />)
const component = screen.getByRole('paragraph')

expect(component).toHaveClass('ams-error-message')
})

it('renders an additional class name', () => {
render(<ErrorMessage className="extra" />)
const component = screen.getByRole('paragraph')

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

it('renders a Dutch prefix by default', () => {
render(<ErrorMessage />)
const component = screen.getByText('Invoerfout', { exact: false })

expect(component).toBeInTheDocument()
})

it('renders a custom prefix', () => {
render(<ErrorMessage prefix="Error" />)
const component = screen.getByText('Error', { exact: false })

expect(component).toBeInTheDocument()
})

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

render(<ErrorMessage ref={ref} />)
const component = screen.getByRole('paragraph')

expect(ref.current).toBe(component)
})
})
31 changes: 31 additions & 0 deletions packages/react/src/ErrorMessage/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import clsx from 'clsx'
import { forwardRef } from 'react'
import type { ForwardedRef, HTMLAttributes, PropsWithChildren } from 'react'
import { VisuallyHidden } from '../VisuallyHidden'

export type ErrorMessageProps = {
/** An accessible phrase that screen readers announce before the error message. Should translate to something like ‘input error’. */
prefix?: string
} & PropsWithChildren<HTMLAttributes<HTMLParagraphElement>>

export const ErrorMessage = forwardRef(
(
{ children, className, prefix = 'Invoerfout', ...restProps }: ErrorMessageProps,
ref: ForwardedRef<HTMLParagraphElement>,
) => (
<p {...restProps} ref={ref} className={clsx('ams-error-message', className)}>
<VisuallyHidden>
{prefix}
{': '}
</VisuallyHidden>
{children}
</p>
),
)

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

# React Error Message component

[Error Message documentation](../../../css/src/components/error-message/README.md)
2 changes: 2 additions & 0 deletions packages/react/src/ErrorMessage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ErrorMessage } from './ErrorMessage'
export type { ErrorMessageProps } from './ErrorMessage'
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 './ErrorMessage'
export * from './FileInput'
export * from './Field'
export * from './Select'
Expand Down
11 changes: 11 additions & 0 deletions proprietary/tokens/src/components/ams/error-message.tokens.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"ams": {
"error-message": {
"color": { "value": "{ams.color.primary-red}" },
"font-family": { "value": "{ams.text.font-family}" },
"font-size": { "value": "{ams.text.level.6.font-size}" },
"font-weight": { "value": "{ams.text.font-weight.normal}" },
"line-height": { "value": "{ams.text.level.6.line-height}" }
}
}
}
19 changes: 19 additions & 0 deletions storybook/src/components/ErrorMessage/ErrorMessage.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Canvas, Controls, Markdown, Meta, Primary } from "@storybook/blocks";
import * as ErrorMessageStories from "./ErrorMessage.stories.tsx";
import README from "../../../../packages/css/src/components/error-message/README.md?raw";

<Meta of={ErrorMessageStories} />

<Markdown>{README}</Markdown>

<Primary />

<Controls />

## With a custom prefix

Error messages are automatically prefixed with a visually hidden text, the Dutch word "Invoerfout".
This makes the error message more clear for screen reader users.
If you want to change this prefix, to support another language for example, you can use the `prefix` prop.

<Canvas of={ErrorMessageStories.WithCustomPrefix} />
33 changes: 33 additions & 0 deletions storybook/src/components/ErrorMessage/ErrorMessage.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import { ErrorMessage } from '@amsterdam/design-system-react/src'
import { Meta, StoryObj } from '@storybook/react'

const meta = {
title: 'Components/Forms/Error Message',
component: ErrorMessage,
args: {
children: 'Vul een geldig e-mailadres in, bijvoorbeeld [email protected].',
},
argTypes: {
children: {
table: { disable: false },
},
},
} satisfies Meta<typeof ErrorMessage>

export default meta

type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const WithCustomPrefix: Story = {
args: {
children: 'Enter an email address in the correct format, like [email protected]',
prefix: 'Error',
},
}
6 changes: 5 additions & 1 deletion storybook/src/components/Field/Field.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ import README from "../../../../packages/css/src/components/field/README.md?raw"

A Field can have a description.
Make sure to connect this description to the input in the Field,
otherwise this won’t be read by a screen reader.
otherwise it won’t be read by a screen reader.
Add an `aria-describedby` attribute to the input and provide the `id` of the describing element as its value.

<Canvas of={FieldStories.WithDescription} />

## With Error

A Field can indicate if the contained input has a validation error.
Use Error Message to describe the error.
Make sure to connect the error message to the input in the Field,
otherwise it won’t be read by a screen reader.
Add an `aria-describedby` attribute to the input and provide the `id` of Error Message as its value.

<Canvas of={FieldStories.WithError} />
7 changes: 4 additions & 3 deletions storybook/src/components/Field/Field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* Copyright Gemeente Amsterdam
*/

import { TextInput } from '@amsterdam/design-system-react'
import { Field, Label, Paragraph } from '@amsterdam/design-system-react/src'
import { ErrorMessage, Label, TextInput } from '@amsterdam/design-system-react'
import { Field, Paragraph } from '@amsterdam/design-system-react/src'
import { Meta, StoryObj } from '@storybook/react'

const meta = {
Expand Down Expand Up @@ -48,7 +48,8 @@ export const WithError: Story = {
<Paragraph id="description2" size="small">
Typ geen persoonsgegevens in deze omschrijving. We vragen dit later in dit formulier aan u.
</Paragraph>
<TextInput id="input3" aria-describedby="description2" invalid={args.invalid} />
<ErrorMessage id="error">Geef aan waar het om gaat.</ErrorMessage>
<TextInput id="input3" aria-describedby="description2 error" aria-required invalid={args.invalid} />
</Field>
),
}
19 changes: 18 additions & 1 deletion storybook/src/components/FieldSet/FieldSet.docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,30 @@ and provide the `id` of the describing element as its value.
## With Error

A Field Set can indicate whether any of the inputs it contains has a validation error.
Use Error Message to describe the error.
Make sure to connect the error message to the correct input in the Field Set,
otherwise it won’t be read by a screen reader.
Add an `aria-describedby` attribute to the input and provide the `id` of Error Message as its value.

<Canvas of={FieldSetStories.WithError} />

### Radio group

Use a Field Set to group radio buttons.
When grouping radio inputs, use `role="radiogroup"` on Field Set to have this grouping explicitly announced as a radio group (the default role is `group`).

Using `role="radiogroup"` also allows you to use `aria-required` on Field Set, which isn’t allowed for role `group`.
Always also set `aria-required` on the individual radio buttons though, to make sure it’s read by screen readers.

<Canvas of={FieldSetStories.RadioGroup} />

### Radio group with error

A Field Set with a radio button group can also have a validation error.
In this case, connect the error message to the Field Set instead of an input.
Add an `aria-describedby` attribute to the Field Set and provide the `id` of Error Message as its value.

<Canvas of={FieldSetStories.RadioGroupWithError} />

### Checkbox group

Use a Field Set to group checkboxes.
Expand All @@ -48,3 +59,9 @@ not report a description connected to a Field Set when it contains checkboxes.
Try to avoid using descriptions for Field Sets containing checkboxes for this reason.

<Canvas of={FieldSetStories.CheckboxGroup} />

### Checkbox group with error

Because of [the NVDA bug mentioned earlier](https://github.com/nvaccess/nvda/issues/12718),
we currently do not have a reliable way to report error messages for checkbox groups with a validation error.
We are working on adding this as soon as possible.
82 changes: 79 additions & 3 deletions storybook/src/components/FieldSet/FieldSet.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@
* Copyright Gemeente Amsterdam
*/

import { Checkbox, Column, FieldSet, Label, Paragraph, Radio, TextInput } from '@amsterdam/design-system-react/src'
import {
Checkbox,
Column,
ErrorMessage,
FieldSet,
Label,
Paragraph,
Radio,
TextInput,
} from '@amsterdam/design-system-react/src'
import { Meta, StoryObj } from '@storybook/react'

const meta = {
Expand Down Expand Up @@ -64,9 +73,11 @@ export const WithError: Story = {
</Paragraph>
<Column gap="extra-small">
<Label htmlFor="input5">Voornaam</Label>
<TextInput id="input5" invalid={args.invalid} aria-required="true" />
{args.invalid && <ErrorMessage id="error1">Vul uw voornaam in.</ErrorMessage>}
<TextInput id="input5" aria-describedby="error1" aria-required="true" invalid={args.invalid} />
<Label htmlFor="input6">Achternaam</Label>
<TextInput id="input6" invalid={args.invalid} aria-required="true" />
{args.invalid && <ErrorMessage id="error2">Vul uw achternaam in.</ErrorMessage>}
<TextInput id="input6" aria-describedby="error2" aria-required="true" invalid={args.invalid} />
</Column>
</FieldSet>
),
Expand Down Expand Up @@ -105,6 +116,45 @@ export const RadioGroup: Story = {
),
}

export const RadioGroupWithError: Story = {
args: {
legend: 'Waar gaat uw melding over?',
invalid: true,
},
render: (args) => (
<FieldSet
legend={args.legend}
aria-describedby="description5 error3"
role="radiogroup"
aria-required="true"
invalid={args.invalid}
>
<Paragraph id="description5" size="small" className="ams-mb--sm">
De laatstgenoemde melding.
</Paragraph>
{args.invalid && (
<ErrorMessage className="ams-mb--sm" id="error3">
Geef aan waar uw laatstgenoemde melding over gaat.
</ErrorMessage>
)}
<Column gap="extra-small">
<Radio name="about" value="horeca" invalid={args.invalid} aria-required="true">
Horecabedrijf
</Radio>
<Radio name="about" value="ander_bedrijf" invalid={args.invalid} aria-required="true">
Ander soort bedrijf
</Radio>
<Radio name="about" value="evenement" invalid={args.invalid} aria-required="true">
Evenement
</Radio>
<Radio name="about" value="anders" invalid={args.invalid} aria-required="true">
Iets anders
</Radio>
</Column>
</FieldSet>
),
}

export const CheckboxGroup: Story = {
args: {
legend: 'Waar gaat uw melding over?',
Expand All @@ -128,3 +178,29 @@ export const CheckboxGroup: Story = {
</FieldSet>
),
}

// export const CheckboxGroupWithError: Story = {
// args: {
// invalid: true,
// legend: 'Waar gaat uw melding over?',
// },
// render: (args) => (
// <FieldSet legend={args.legend} invalid={args.invalid}>
// {args.invalid && <ErrorMessage className="ams-mb--sm">Geef aan waar uw melding over gaat.</ErrorMessage>}
// <Column gap="extra-small" style={{ width: '100%' }}>
// <Checkbox name="about" value="horeca" invalid={args.invalid} aria-required="true">
// Horecabedrijf
// </Checkbox>
// <Checkbox name="about" value="ander_bedrijf" invalid={args.invalid} aria-required="true">
// Ander soort bedrijf
// </Checkbox>
// <Checkbox name="about" value="evenement" invalid={args.invalid} aria-required="true">
// Evenement
// </Checkbox>
// <Checkbox name="about" value="anders" invalid={args.invalid} aria-required="true">
// Iets anders
// </Checkbox>
// </Column>
// </FieldSet>
// ),
// }

0 comments on commit 1dec2ea

Please sign in to comment.