Skip to content

Commit

Permalink
feat: Add focus on initial render to Form Error List (#1328)
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 Jul 19, 2024
1 parent 1b9c269 commit 09387b7
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 40 deletions.
7 changes: 7 additions & 0 deletions packages/css/src/components/form-error-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ Put the Form Error List directly above the first question on the page. Place it
This component adds the error count to the document title,
in line with [GOV.UK guidelines for informing users about validation errors](https://design-system.service.gov.uk/patterns/validation/#how-to-tell-the-user-about-validation-errors).

## Focus on initial render

This component receives focus the first time it gets displayed on a page.
This allows keyboard users to quickly navigate to the errors in the form.
It also scrolls the component into view if it isn't already.
Note: this functionality has been disabled on this page, to prevent unexpected focus behaviour.

## Relevant WCAG requirements

Pay extra attention to these parts:
Expand Down
14 changes: 14 additions & 0 deletions packages/css/src/components/form-error-list/form-error-list.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.ams-form-error-list {
outline-offset: var(--ams-form-error-list-outline-offset);

// In Chromium browsers, the outline overlaps with the border in this component.
// We're not sure why, but to fix this we double the offset for Chromium browsers here.
@supports (contain: paint) and (not (-moz-appearance: none)) {
outline-offset: calc(var(--ams-form-error-list-outline-offset) * 2);

// Reset for Safari
@supports (font: -apple-system-body) {
outline-offset: var(--ams-form-error-list-outline-offset);
}
}
}
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 "./form-error-list/form-error-list";
@import "./table-of-contents/table-of-contents";
@import "./error-message/error-message";
@import "./file-input/file-input";
Expand Down
36 changes: 26 additions & 10 deletions packages/react/src/FormErrorList/FormErrorList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,34 @@ describe('Form error list', () => {
]

it('renders', () => {
render(<FormErrorList errors={testErrors} />)
const { container } = render(<FormErrorList errors={testErrors} />)

const component = screen.getByRole('alert')
const component = container.querySelector(':only-child')

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

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

const component = screen.queryByRole('alert')
const component = container.querySelector(':only-child')

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

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

const component = screen.getByRole('alert')
const component = container.querySelector(':only-child')

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

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

const component = screen.getByRole('alert')
const component = container.querySelector(':only-child')

expect(component).toHaveClass('ams-form-error-list extra')
})
Expand Down Expand Up @@ -119,12 +119,28 @@ describe('Form error list', () => {
})
})

it('has focus on render', async () => {
const { container } = render(<FormErrorList errors={testErrors} />)

const component = container.querySelector(':only-child')

expect(component).toHaveFocus()
})

it('can disable automatic focus', async () => {
const { container } = render(<FormErrorList errors={testErrors} focusOnRender={false} />)

const component = container.querySelector(':only-child')

expect(component).not.toHaveFocus()
})

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

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

const component = screen.getByRole('alert')
const component = container.querySelector(':only-child')

expect(ref.current).toBe(component)
})
Expand Down
46 changes: 16 additions & 30 deletions packages/react/src/FormErrorList/FormErrorList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@
* Copyright Gemeente Amsterdam
*/

import clsx from 'clsx'
import { forwardRef } from 'react'
import { forwardRef, useState } from 'react'
import type { ForwardedRef, HTMLAttributes } from 'react'
import { FormErrorListWithErrors } from './FormErrorListWithErrors'
import { useAddErrorCountToDocumentTitle } from './useAddErrorCountToDocumentTitle'
import { Alert } from '../Alert'
import type { HeadingLevel } from '../Heading'
import { LinkList } from '../LinkList'

export type FormError = {
id: string
Expand All @@ -24,6 +22,8 @@ export type FormErrorListProps = {
errorCountLabel?: { plural: string; singular: string }
/** The list of error messages to display. */
errors: FormError[]
/** Whether the component receives focus on first render */
focusOnRender?: boolean
/** The text for the Heading. */
heading?: string
/**
Expand All @@ -34,39 +34,25 @@ export type FormErrorListProps = {
} & HTMLAttributes<HTMLDivElement>

export const FormErrorList = forwardRef(
(
{
className,
errorCountLabel,
errors,
heading = 'Verbeter de fouten voor u verder gaat',
headingLevel = 2,
...restProps
}: FormErrorListProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
({ errors, errorCountLabel, ...restProps }: FormErrorListProps, ref: ForwardedRef<HTMLDivElement>) => {
// A Form Error List without errors only resets the document title.
// With errors, it renders the FormErrorListWithErrors component.
useAddErrorCountToDocumentTitle(errors, errorCountLabel)

// Focus should only be set on first render of FormErrorListWithErrors.
// Subsequent renders should not set focus.
const [hasFocusedOnce, setHasFocusedOnce] = useState(false)

if (errors.length === 0) return undefined

return (
<Alert
<FormErrorListWithErrors
{...restProps}
className={clsx('ams-form-error-list', className)}
heading={heading}
headingLevel={headingLevel}
errors={errors}
hasFocusedOnce={hasFocusedOnce}
ref={ref}
role="alert"
severity="error"
>
<LinkList>
{errors.map(({ id, label }) => (
<LinkList.Link href={id} key={`${id}-${label}`}>
{label}
</LinkList.Link>
))}
</LinkList>
</Alert>
setHasFocusedOnce={setHasFocusedOnce}
/>
)
},
)
Expand Down
68 changes: 68 additions & 0 deletions packages/react/src/FormErrorList/FormErrorListWithErrors.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* @license EUPL-1.2+
* Copyright Gemeente Amsterdam
*/

import clsx from 'clsx'
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import type { Dispatch, ForwardedRef, SetStateAction } from 'react'
import type { FormErrorListProps } from './FormErrorList'
import { Alert } from '../Alert'
import { LinkList } from '../LinkList'

type FormErrorListWithErrorsProps = Omit<FormErrorListProps, 'errorCountLabel'> & {
/** Whether the component has set focus once. */
hasFocusedOnce: boolean
/** Callback to let parent component know whether focus has been set once. */
setHasFocusedOnce: Dispatch<SetStateAction<boolean>>
}

export const FormErrorListWithErrors = forwardRef(
(
{
className,
errors,
focusOnRender = true,
hasFocusedOnce,
heading = 'Verbeter de fouten voor u verder gaat',
headingLevel = 2,
setHasFocusedOnce,
...restProps
}: FormErrorListWithErrorsProps,
ref: ForwardedRef<HTMLDivElement>,
) => {
const innerRef = useRef<HTMLDivElement>(null)

// use a passed ref if it's there, otherwise use innerRef
useImperativeHandle(ref, () => innerRef.current as HTMLDivElement)

useEffect(() => {
if (innerRef.current && focusOnRender && !hasFocusedOnce) {
innerRef.current.focus()
setHasFocusedOnce(true)
}
}, [innerRef])

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

FormErrorListWithErrors.displayName = 'FormErrorListWithErrors'
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"ams": {
"form-error-list": {
"outline-offset": { "value": "{ams.focus.outline-offset}" }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const meta = {
{ id: '#', label: 'Vul een geldige datum in (bijvoorbeeld 6 januari 2030).' },
{ id: '#', label: 'De geldigheidsdatum van uw paspoort moet in de toekomst liggen.' },
],
focusOnRender: false,
},
} satisfies Meta<typeof FormErrorList>

Expand Down

0 comments on commit 09387b7

Please sign in to comment.