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

fix: Improve character count component's SR experience #2550

Merged
merged 8 commits into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
71 changes: 55 additions & 16 deletions src/components/forms/CharacterCount/CharacterCount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,21 @@ describe('CharacterCount component', () => {
/>
)
const message = getByTestId('characterCountMessage')
expect(message).toHaveClass('usa-character-count__message')
expect(message).toHaveAttribute('id', 'character-count-id-info')
expect(message).toHaveClass('usa-hint usa-character-count__status')
expect(message).toHaveAttribute('aria-hidden', 'true')
})

it('includes the screen reader message', () => {
const { getByTestId } = render(
<CharacterCount
id="character-count-id"
name="character-count"
maxLength={10}
/>
)
const message = getByTestId('characterCountSRMessage')
expect(message).toHaveClass('usa-character-count__sr-status usa-sr-only')
expect(message).toHaveAttribute('aria-live', 'polite')
})
})

Expand Down Expand Up @@ -202,8 +215,22 @@ describe('CharacterCount component', () => {
/>
)
const message = getByTestId('characterCountMessage')
expect(message).toHaveClass('usa-character-count__message')
expect(message).toHaveAttribute('id', 'character-count-info')
expect(message).toHaveClass('usa-character-count__status')
expect(message).toHaveAttribute('aria-hidden', 'true')
})

it('includes the screen reader message hint', () => {
const { getByTestId } = render(
<CharacterCount
id="character-count"
name="character-count"
maxLength={10}
isTextArea
/>
)
const message = getByTestId('characterCountSRMessage')
expect(message).toHaveClass('usa-character-count__sr-status usa-sr-only')
expect(message).toHaveAttribute('aria-live', 'polite')
})
})

Expand All @@ -219,8 +246,8 @@ describe('CharacterCount component', () => {
expect(getByText('20 characters allowed')).toBeInTheDocument()
})

it('updates message text with characters left onChange', () => {
const { getByRole, getByText } = render(
it('updates message text with characters left onChange', async () => {
const { getByRole, getAllByText } = render(
<CharacterCount
id="character-count-id"
name="characterCount"
Expand All @@ -232,17 +259,23 @@ describe('CharacterCount component', () => {
target: { value: 'a' },
})

expect(getByText('4 characters left')).toBeInTheDocument()
await new Promise((res) => setTimeout(res, 1000))

expect(getAllByText('4 characters left')).toHaveLength(2)
expect(getAllByText('4 characters left')[0]).toBeInTheDocument()

fireEvent.change(input, {
target: { value: 'abcd' },
})

expect(getByText('1 character left')).toBeInTheDocument()
await new Promise((res) => setTimeout(res, 1000))

expect(getAllByText('1 character left')).toHaveLength(2)
expect(getAllByText('1 character left')[0]).toBeInTheDocument()
})

it('updates message text with characters over the limit when expected ', () => {
const { getByRole, getByText } = render(
it('updates message text with characters over the limit when expected ', async () => {
const { getByRole, getAllByText } = render(
<CharacterCount
id="character-count-id"
name="characterCount"
Expand All @@ -255,16 +288,22 @@ describe('CharacterCount component', () => {
target: { value: 'abcdef' },
})

expect(getByText('1 character over limit')).toBeInTheDocument()
expect(getByText('1 character over limit')).toHaveClass(
'usa-character-count__message--invalid'
await new Promise((res) => setTimeout(res, 1000))

expect(getAllByText('1 character over limit')).toHaveLength(2)
expect(getAllByText('1 character over limit')[0]).toBeInTheDocument()
expect(getAllByText('1 character over limit')[0]).toHaveClass(
'usa-character-count__status--invalid'
)

fireEvent.change(input, {
target: { value: 'abcdefg' },
})

expect(getByText('2 characters over limit')).toBeInTheDocument()
await new Promise((res) => setTimeout(res, 1000))

expect(getAllByText('2 characters over limit')).toHaveLength(2)
expect(getAllByText('2 characters over limit')[0]).toBeInTheDocument()
})

it('updates input validity', () => {
Expand Down Expand Up @@ -306,15 +345,15 @@ describe('CharacterCount component', () => {
target: { value: 'abcdef' },
})
expect(getByTestId('characterCountMessage')).toHaveClass(
'usa-character-count__message--invalid'
'usa-character-count__status--invalid'
)

fireEvent.change(input, {
target: { value: 'abcde' },
})

expect(getByTestId('characterCountMessage')).not.toHaveClass(
'usa-character-count__message--invalid'
'usa-character-count__status--invalid'
)
})
})
Expand Down
38 changes: 26 additions & 12 deletions src/components/forms/CharacterCount/CharacterCount.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import classnames from 'classnames'

import { TextInput, TextInputProps } from '../TextInput/TextInput'
Expand Down Expand Up @@ -71,17 +71,23 @@ export const CharacterCount = ({
const [length, setLength] = useState(initialCount)
const [message, setMessage] = useState(getMessage(initialCount, maxLength))
const [isValid, setIsValid] = useState(initialCount < maxLength)
const srMessageRef = useRef<HTMLDivElement>(null)

const classes = classnames('usa-character-count__field', className)
const messageClasses = classnames(
'usa-hint',
'usa-character-count__message',
{ 'usa-character-count__message--invalid': !isValid }
)
const messageClasses = classnames('usa-hint', 'usa-character-count__status', {
'usa-character-count__status--invalid': !isValid,
})

useEffect(() => {
setMessage(getMessage(length, maxLength))
const message = getMessage(length, maxLength)
setMessage(message)
setIsValid(length <= maxLength)
// Updates the character count status for screen readers after a 1000ms delay
const timer = setTimeout(() => {
// Setting the text directly for VoiceOver compatibility.
if (srMessageRef.current) srMessageRef.current.textContent = message
}, 1000)
return () => clearTimeout(timer)
}, [length])
Comment on lines +88 to +90
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Does a useState/setState not work for Voiceover?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not! Very weird that it didn't. It did work with every other SR I tested.


const handleBlur = (
Expand Down Expand Up @@ -156,13 +162,21 @@ export const CharacterCount = ({
return (
<>
{InputComponent}
<span
data-testid="characterCountMessage"
id={`${id}-info`}
<span className="usa-sr-only" id={`${id}-info`}>
You can enter up to {maxLength} characters
</span>
<div
className={messageClasses}
aria-live="polite">
aria-hidden="true"
data-testid="characterCountMessage">
{message}
</span>
</div>
<div
ref={srMessageRef}
className="usa-character-count__sr-status usa-sr-only"
aria-live="polite"
data-testid="characterCountSRMessage"
/>
</>
)
}
Expand Down
Loading