Skip to content

Commit 46ab2ef

Browse files
authored
Associate library-generated description, hint and validationMessage nodes with aria-describedby (#1324)
1 parent 13b6725 commit 46ab2ef

11 files changed

Lines changed: 241 additions & 7 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React from 'react'
2+
import { render } from '@testing-library/react'
3+
import FormField from '../src/FormField'
4+
5+
const TEST_ID = 'foo'
6+
7+
const makeFormFieldFixture = (props = {}) => (
8+
<FormField label="hi" labelFor={TEST_ID} data-testid={TEST_ID} {...props} />
9+
)
10+
11+
describe('<FormField />', () => {
12+
it('Should render without crashing', () => {
13+
expect(() => render(makeFormFieldFixture())).not.toThrow()
14+
})
15+
16+
it('renders a label associated with its provided ID', async () => {
17+
const { container } = render(makeFormFieldFixture())
18+
19+
const label = container.querySelector('label')
20+
expect(label).toHaveAttribute('for', 'foo')
21+
expect(label.textContent).toBe('hi')
22+
})
23+
24+
it('renders a description with correct ID', async () => {
25+
const { container } = render(makeFormFieldFixture({ description: 'some content' }))
26+
27+
const description = container.querySelector(`#${TEST_ID}__description`)
28+
expect(description).toBeDefined()
29+
expect(description.textContent).toBe('some content')
30+
})
31+
32+
it('renders a hint with correct ID', async () => {
33+
const { container } = render(makeFormFieldFixture({ hint: 'some content' }))
34+
35+
const hint = container.querySelector(`#${TEST_ID}__hint`)
36+
expect(hint).toBeDefined()
37+
expect(hint.textContent).toBe('some content')
38+
})
39+
40+
it('renders a validation message with correct ID', async () => {
41+
const { container } = render(makeFormFieldFixture({ validationMessage: 'some content' }))
42+
43+
const validationMessage = container.querySelector(`#${TEST_ID}__validation-message`)
44+
expect(validationMessage).toBeDefined()
45+
expect(validationMessage.textContent).toBe('some content')
46+
})
47+
})

src/form-field/src/FormField.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,27 @@ const FormField = memo(
2626
<FormFieldLabel htmlFor={labelFor} isAstrixShown={isRequired} {...labelProps}>
2727
{label}
2828
</FormFieldLabel>
29-
{typeof description === 'string' ? <FormFieldDescription>{description}</FormFieldDescription> : description}
29+
{typeof description === 'string' ? (
30+
<FormFieldDescription id={labelFor + '__description'}>{description}</FormFieldDescription>
31+
) : (
32+
description
33+
)}
3034
</Box>
3135
{children}
3236
{typeof validationMessage === 'string' ? (
33-
<FormFieldValidationMessage marginTop={8}>{validationMessage}</FormFieldValidationMessage>
37+
<FormFieldValidationMessage marginTop={8} id={labelFor + '__validation-message'}>
38+
{validationMessage}
39+
</FormFieldValidationMessage>
3440
) : (
3541
validationMessage
3642
)}
37-
{typeof hint === 'string' ? <FormFieldHint marginTop={6}>{hint}</FormFieldHint> : hint}
43+
{typeof hint === 'string' ? (
44+
<FormFieldHint marginTop={6} id={labelFor + '__hint'}>
45+
{hint}
46+
</FormFieldHint>
47+
) : (
48+
hint
49+
)}
3850
</Box>
3951
)
4052
})

src/form-field/src/FormFieldLabel.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ const FormFieldLabel = memo(
77
const { children, isAstrixShown, ...rest } = props
88
return (
99
<Label display="block" marginBottom={0} {...rest} ref={ref}>
10-
{children} {isAstrixShown && <span title="This field is required.">*</span>}
10+
{children}
11+
{isAstrixShown && (
12+
<>
13+
{' '}
14+
<span title="This field is required.">*</span>
15+
</>
16+
)}
1117
</Label>
1218
)
1319
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Generates a token value for `aria-describedby` when the author provides `description`, `hint`, or `validationMessage` strings.
3+
* @param {String} id
4+
* @param {Object} helperTextCandidates
5+
* @param {String | React.ReactNode} [helperTextCandidates.description]
6+
* @param {String | React.ReactNode} [helperTextCandidates.hint]
7+
* @param {String | React.ReactNode} [helperTextCandidates.validationMessage]
8+
* @returns {String | null}
9+
*/
10+
export function generateAriaDescribedBy(id, { description, hint, validationMessage }) {
11+
let tokens = ''
12+
13+
// Only add each of the following to aria-describedby if
14+
// it was provided as a string. When the author provides
15+
// a React element, we assume they want to manage aria-describedby themselves.
16+
17+
// NB: Token order matches the order that these hint texts appear in the DOM.
18+
// If the order of the hint texts changes, this should also change.
19+
20+
if (typeof description === 'string') {
21+
tokens += ' ' + id + '__description'
22+
}
23+
24+
if (typeof validationMessage === 'string') {
25+
tokens += ' ' + id + '__validation-message'
26+
}
27+
28+
if (typeof hint === 'string') {
29+
tokens += ' ' + id + '__hint'
30+
}
31+
32+
if (tokens.length) {
33+
return tokens.trim()
34+
}
35+
return null
36+
}
Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,88 @@
11
import React from 'react'
22
import { render } from '@testing-library/react'
3-
import { SelectField } from '../'
3+
import userEvent from '@testing-library/user-event'
4+
import { Select, SelectField } from '../'
45
import { mockRef } from '../../test/utils'
56

6-
const makeSelectFieldFixture = (props = {}) => <SelectField data-testid="select-field" label="SelectField" {...props} />
7+
function makeSelectFixture(props = {}) {
8+
return (
9+
<Select data-testid="select" defaultValue="foo" name="select" {...props}>
10+
<option value="foo">Foo</option>
11+
<option value="bar">Bar</option>
12+
</Select>
13+
)
14+
}
15+
16+
function makeSelectFieldFixture(props = {}) {
17+
return (
18+
<SelectField data-testid="select" defaultValue="foo" label="Select" {...props}>
19+
<option value="foo">Foo</option>
20+
<option value="bar">Bar</option>
21+
</SelectField>
22+
)
23+
}
24+
25+
describe('Select', () => {
26+
it('Should render without crashing', () => {
27+
expect(() => render(makeSelectFixture())).not.toThrow()
28+
})
29+
30+
it('Should set an invalid state if `isInvalid` is `true`', () => {
31+
const { container } = render(makeSelectFixture({ isInvalid: true }))
32+
const input = container.querySelector('select')
33+
expect(input).toHaveAttribute('aria-invalid', 'true')
34+
})
35+
36+
it('Should not be interactive if `disabled` is passed in', () => {
37+
const { container } = render(makeSelectFixture({ disabled: true }))
38+
const select = container.querySelector('select')
39+
40+
expect(document.body).toHaveFocus()
41+
userEvent.tab()
42+
expect(select).not.toHaveFocus()
43+
})
44+
})
745

846
describe('SelectField', () => {
47+
it('Should render without crashing', () => {
48+
expect(() => render(makeSelectFieldFixture())).not.toThrow()
49+
})
50+
951
it('should forward ref to underlying <select />', () => {
1052
const ref = mockRef()
1153

1254
render(makeSelectFieldFixture({ ref }))
1355

1456
expect(ref.current).toBeInstanceOf(HTMLSelectElement)
1557
})
58+
59+
it('Should have expected accessible name when `label` prop passed in', () => {
60+
const { container, getByLabelText } = render(makeSelectFieldFixture())
61+
const select = container.querySelector('select')
62+
expect(getByLabelText('Select')).toBeInTheDocument()
63+
expect(select).toHaveAccessibleName('Select')
64+
})
65+
66+
it('Should add hint text to accessible description when `hint` prop provided', () => {
67+
const { container, getByText } = render(makeSelectFieldFixture({ hint: 'Some description.' }))
68+
expect(getByText('Some description.')).toBeInTheDocument()
69+
expect(container.querySelector('select')).toHaveAccessibleDescription('Some description.')
70+
})
71+
72+
it('Should render an astrix when `required` is passed in', () => {
73+
const { getByTitle } = render(makeSelectFieldFixture({ required: true }))
74+
expect(getByTitle('This field is required.')).toBeInTheDocument()
75+
})
76+
77+
it('Should render a `validationMessage` when passed in', () => {
78+
const { container, getByText } = render(makeSelectFieldFixture({ validationMessage: 'Please choose a value.' }))
79+
expect(getByText('Please choose a value.')).toBeInTheDocument()
80+
expect(container.querySelector('select')).toHaveAccessibleDescription('Please choose a value.')
81+
})
82+
83+
it('Should correctly compose an accessible description from multiple hints', () => {
84+
const { container } = render(makeSelectFieldFixture({ hint: 'Am hint.', validationMessage: 'Try again.' }))
85+
86+
expect(container.querySelector('select')).toHaveAccessibleDescription('Try again. Am hint.')
87+
})
1688
})

src/select/src/Select.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const Select = memo(
4343
forwardRef(function Select(props, ref) {
4444
const {
4545
appearance = 'default',
46+
'aria-describedby': ariaDescribedby,
4647
autoFocus,
4748
children,
4849
defaultValue,
@@ -98,6 +99,7 @@ const Select = memo(
9899
paddingRight={iconMargin * 2 + iconSize}
99100
{...boxProps}
100101
height="100%"
102+
aria-describedby={ariaDescribedby}
101103
>
102104
{children}
103105
</Box>

src/select/src/SelectField.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
33
import { splitBoxProps } from 'ui-box'
44
import { FormField } from '../../form-field'
55
import { useId } from '../../hooks'
6+
import { generateAriaDescribedBy } from '../../lib/generate-aria-describedby'
67
import Select from './Select'
78

89
const SelectField = memo(
@@ -57,6 +58,7 @@ const SelectField = memo(
5758
isInvalid={isInvalid}
5859
appearance={appearance}
5960
ref={ref}
61+
aria-describedby={generateAriaDescribedBy(id, { description, hint, validationMessage })}
6062
{...remainingProps}
6163
/>
6264
</FormField>

src/text-input/__tests__/TextInput.test.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import React, { useState } from 'react'
22
import { render } from '@testing-library/react'
33
import userEvent from '@testing-library/user-event'
4-
import { TextInput } from '../'
4+
import { TextInput, TextInputField } from '../'
55
import { mockRef } from '../../test/utils'
66

77
function makeTextInputFixture(props = {}) {
88
return <TextInput data-testid="input" {...props} />
99
}
1010

11+
function makeTextInputFieldFixture(props = {}) {
12+
return <TextInputField data-testid="input" label="Name" {...props} />
13+
}
14+
1115
describe('TextInput', () => {
1216
it('should forward ref to underlying <input />', () => {
1317
const ref = mockRef()
@@ -65,3 +69,42 @@ describe('TextInput', () => {
6569
expect(getByDisplayValue('')).toEqual(input)
6670
})
6771
})
72+
73+
describe('TextInputField', () => {
74+
it('Should render without crashing', () => {
75+
expect(() => render(makeTextInputFieldFixture())).not.toThrow()
76+
})
77+
78+
it('Should have expected accessible name when `label` prop', () => {
79+
const { getByLabelText, getByTestId } = render(makeTextInputFieldFixture())
80+
expect(getByLabelText('Name')).toBeInTheDocument()
81+
expect(getByTestId('input')).toHaveAccessibleName('Name')
82+
})
83+
84+
it('Should add hint text to accessible description when `hint` prop provided', () => {
85+
const { getByTestId, getByText } = render(makeTextInputFieldFixture({ hint: 'Enter a value in the input' }))
86+
expect(getByText('Enter a value in the input')).toBeInTheDocument()
87+
expect(getByTestId('input')).toHaveAccessibleDescription('Enter a value in the input')
88+
})
89+
90+
it('Should render an astrix when `required` is passed in', () => {
91+
const { getByTitle } = render(makeTextInputFieldFixture({ required: true }))
92+
expect(getByTitle('This field is required.')).toBeInTheDocument()
93+
})
94+
95+
it('Should render a `validationMessage` when passed in', () => {
96+
const { getByTestId, getByText } = render(makeTextInputFieldFixture({ validationMessage: 'Please enter a value.' }))
97+
expect(getByText('Please enter a value.')).toBeInTheDocument()
98+
expect(getByTestId('input')).toHaveAccessibleDescription('Please enter a value.')
99+
})
100+
101+
it('Should correctly compose an accessible description from multiple hints', () => {
102+
const { getByTestId, getByText } = render(
103+
makeTextInputFieldFixture({ description: 'A description.', hint: 'Am hint.', validationMessage: 'Try again.' })
104+
)
105+
expect(getByText('A description.')).toBeInTheDocument()
106+
expect(getByText('Am hint.')).toBeInTheDocument()
107+
expect(getByText('Try again.')).toBeInTheDocument()
108+
expect(getByTestId('input')).toHaveAccessibleDescription('A description. Try again. Am hint.')
109+
})
110+
})

src/text-input/src/TextInputField.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types'
33
import { splitBoxProps } from 'ui-box'
44
import { FormField } from '../../form-field'
55
import { useId } from '../../hooks'
6+
import { generateAriaDescribedBy } from '../../lib/generate-aria-describedby'
67
import { majorScale } from '../../scales'
78
import TextInput from './TextInput'
89

@@ -61,6 +62,7 @@ const TextInputField = memo(
6162
placeholder={placeholder}
6263
spellCheck={spellCheck}
6364
ref={ref}
65+
aria-describedby={generateAriaDescribedBy(id, { description, hint, validationMessage })}
6466
{...remainingProps}
6567
/>
6668
</FormField>

src/textarea/__tests__/TextareaField.test.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,14 @@ describe('TextareaField', () => {
144144
expect(onChange).toHaveBeenCalled()
145145
})
146146
})
147+
148+
it('Should correctly compose an accessible description from multiple hints', () => {
149+
const { getByTestId, getByText } = render(
150+
makeTextareaFieldFixture({ description: 'A description.', hint: 'Am hint.', validationMessage: 'Try again.' })
151+
)
152+
expect(getByText('A description.')).toBeInTheDocument()
153+
expect(getByText('Am hint.')).toBeInTheDocument()
154+
expect(getByText('Try again.')).toBeInTheDocument()
155+
expect(getByTestId('TextareaField')).toHaveAccessibleDescription('A description. Try again. Am hint.')
156+
})
147157
})

0 commit comments

Comments
 (0)