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 (16747)(A11y) : Proper Conveyance of Word/Character Count by screen readers #16898

Merged
merged 13 commits into from
Jul 23, 2024
Merged
29 changes: 28 additions & 1 deletion packages/react/src/components/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,11 +416,33 @@ const TextArea = React.forwardRef((props: TextAreaProps, forwardRef) => {
textareaProps.maxLength = maxCount;
}
}

const announcerRef = useRef(null);
const [prevAnnouncement, setPrevAnnouncement] = useState('');
const ariaAnnouncement = useAnnouncer(
textCount,
maxCount,
counterMode === 'word' ? 'words' : undefined
);
useEffect(() => {
if (ariaAnnouncement && ariaAnnouncement !== prevAnnouncement) {
const announcer = announcerRef.current as HTMLSpanElement | null;
if (announcer) {
// Clear the content first
announcer.textContent = '';
// Set the new content after a small delay
setTimeout(
() => {
if (announcer) {
announcer.textContent = ariaAnnouncement;
setPrevAnnouncement(ariaAnnouncement);
}
},
counterMode === 'word' ? 2000 : 1000
);
Copy link
Member

Choose a reason for hiding this comment

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

I think it might be best to be sure the timeout is cleared in the useEffect cleanup just in case the component unmounts before the timeout expires. In theory this should be garbage collected but it's a small add for this extra assurance.

}
}
}, [ariaAnnouncement, prevAnnouncement, counterMode]);

const input = (
<textarea
Expand Down Expand Up @@ -463,7 +485,12 @@ const TextArea = React.forwardRef((props: TextAreaProps, forwardRef) => {
)}
{input}
{normalizedSlug}
<span className={`${prefix}--text-area__counter-alert`} role="alert">
<span
className={`${prefix}--text-area__counter-alert`}
role="alert"
aria-live="assertive"
aria-atomic="true"
ref={announcerRef}>
{ariaAnnouncement}
</span>
{isFluid && <hr className={`${prefix}--text-area__divider`} />}
Expand Down
33 changes: 31 additions & 2 deletions packages/react/src/components/TextInput/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
*/

import PropTypes from 'prop-types';
import React, { ReactNode, useContext, useState } from 'react';
import React, {
ReactNode,
useContext,
useState,
useEffect,
useRef,
} from 'react';
import classNames from 'classnames';
import { useNormalizedInputProps } from '../../internal/useNormalizedInputProps';
import PasswordInput from './PasswordInput';
Expand Down Expand Up @@ -310,7 +316,25 @@ const TextInput = React.forwardRef(function TextInput(
);

const { isFluid } = useContext(FormContext);
const announcerRef = useRef(null);
const [prevAnnouncement, setPrevAnnouncement] = useState('');
const ariaAnnouncement = useAnnouncer(textCount, maxCount);
useEffect(() => {
if (ariaAnnouncement && ariaAnnouncement !== prevAnnouncement) {
const announcer = announcerRef.current as HTMLSpanElement | null;
if (announcer) {
// Clear the content first
announcer.textContent = '';
// Set the new content after a small delay
setTimeout(() => {
Copy link
Member

Choose a reason for hiding this comment

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

Same thing here with clearing this timeout just in case

if (announcer) {
announcer.textContent = ariaAnnouncement;
setPrevAnnouncement(ariaAnnouncement);
}
}, 1000);
}
}
}, [ariaAnnouncement, prevAnnouncement]);
const Icon = normalizedProps.icon as any;

// Slug is always size `mini`
Expand Down Expand Up @@ -338,7 +362,12 @@ const TextInput = React.forwardRef(function TextInput(
{Icon && <Icon className={iconClasses} />}
{input}
{normalizedSlug}
<span className={`${prefix}--text-input__counter-alert`} role="alert">
<span
className={`${prefix}--text-input__counter-alert`}
role="alert"
aria-live="assertive"
aria-atomic="true"
ref={announcerRef}>
{ariaAnnouncement}
</span>
{isFluid && <hr className={`${prefix}--text-input__divider`} />}
Expand Down
28 changes: 26 additions & 2 deletions packages/react/src/internal/__tests__/useAnnouncer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe('useAnnouncer', () => {
}

render(<TestComponent />);
expect(value).toBe('1 characters left.');
expect(value).toBe('1 character left.');
});

it('should emit announcement for words', () => {
Expand All @@ -24,6 +24,30 @@ describe('useAnnouncer', () => {
}

render(<TestComponent />);
expect(value).toBe('1 words left.');
expect(value).toBe('1 word left.');
});

it('should emit announcement for maximum words reached', () => {
let value = null;

function TestComponent() {
value = useAnnouncer(10, 10, 'words');
return null;
}

render(<TestComponent />);
expect(value).toBe('Maximum words reached.');
});

it('should emit announcement for maximum characters reached', () => {
let value = null;

function TestComponent() {
value = useAnnouncer(10, 10, 'characters');
return null;
}

render(<TestComponent />);
expect(value).toBe('Maximum characters reached.');
});
});
15 changes: 11 additions & 4 deletions packages/react/src/internal/useAnnouncer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

export function useAnnouncer(textCount, maxCount, entityName = 'characters') {
const lastTen = maxCount - 10;
if (textCount >= lastTen) {
return `${maxCount - textCount} ${entityName} left.`;
const remaining = maxCount - textCount;

if (remaining <= 10 && remaining > 0) {
const entity = remaining === 1 ? entityName.slice(0, -1) : entityName;
return `${remaining} ${entity} left.`;
}

if (remaining <= 0) {
return `Maximum ${entityName} reached.`;
}

return null;
}
Loading