Skip to content

Commit 9c18a34

Browse files
authored
fix: Add characterCountText to form field for improved screen reader support (#4332)
1 parent 41acf98 commit 9c18a34

File tree

10 files changed

+202
-24
lines changed

10 files changed

+202
-24
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useState } from 'react';
5+
6+
import FormField from '~components/form-field';
7+
import Input from '~components/input';
8+
9+
const maxCharacterCount = 20;
10+
11+
export default function FormFieldCharacterCountPage() {
12+
const [value, setValue] = useState('');
13+
14+
return (
15+
<>
16+
<h1>Form field character count debouncing</h1>
17+
<FormField
18+
label="Name"
19+
constraintText="Name must be 1 to 10 characters."
20+
characterCountText={`Character count: ${value.length}/${maxCharacterCount}`}
21+
errorText={value.length > maxCharacterCount && 'The name has too many characters.'}
22+
>
23+
<Input value={value} onChange={event => setValue(event.detail.value)} />
24+
</FormField>
25+
</>
26+
);
27+
}

src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13626,6 +13626,14 @@ exports[`Components definition for form-field matches the snapshot: form-field 1
1362613626
"optional": true,
1362713627
"type": "FormFieldProps.AnalyticsMetadata",
1362813628
},
13629+
{
13630+
"description": "Character count constraint displayed adjacent to the constraintText. Use
13631+
this to provide an updated character count on each keypress that is debounced
13632+
for screen reader users.",
13633+
"name": "characterCountText",
13634+
"optional": true,
13635+
"type": "string",
13636+
},
1362913637
{
1363013638
"deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).",
1363113639
"description": "Adds the specified classes to the root element of the component.",
@@ -33483,6 +33491,19 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows().
3348333491
},
3348433492
{
3348533493
"methods": [
33494+
{
33495+
"name": "findCharacterCount",
33496+
"parameters": [],
33497+
"returnType": {
33498+
"isNullable": true,
33499+
"name": "ElementWrapper",
33500+
"typeArguments": [
33501+
{
33502+
"name": "HTMLElement",
33503+
},
33504+
],
33505+
},
33506+
},
3348633507
{
3348733508
"name": "findConstraint",
3348833509
"parameters": [],
@@ -44520,6 +44541,14 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows().
4452044541
},
4452144542
{
4452244543
"methods": [
44544+
{
44545+
"name": "findCharacterCount",
44546+
"parameters": [],
44547+
"returnType": {
44548+
"isNullable": false,
44549+
"name": "ElementWrapper",
44550+
},
44551+
},
4452344552
{
4452444553
"name": "findConstraint",
4452544554
"parameters": [],

src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,9 @@ exports[`test-utils selectors 1`] = `
313313
"awsui_secondary-actions_1i0s3",
314314
],
315315
"form-field": [
316+
"awsui_character-count_6mjrv",
316317
"awsui_constraint_14mhv",
318+
"awsui_constraint_6mjrv",
317319
"awsui_control_14mhv",
318320
"awsui_description_14mhv",
319321
"awsui_error_14mhv",

src/file-upload/internal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import InternalFileDropzone from '../file-dropzone/internal';
1313
import { useFilesDragging } from '../file-dropzone/use-files-dragging';
1414
import InternalFileInput from '../file-input/internal';
1515
import InternalFileTokenGroup from '../file-token-group/internal';
16-
import { ConstraintText, FormFieldError, FormFieldWarning } from '../form-field/internal';
16+
import { ConstraintTextRegion, FormFieldError, FormFieldWarning } from '../form-field/internal';
1717
import { useInternalI18n } from '../i18n/context';
1818
import { getBaseProps } from '../internal/base-component';
1919
import { fireNonCancelableEvent } from '../internal/events';
@@ -167,9 +167,9 @@ function InternalFileUpload(
167167
</FormFieldWarning>
168168
)}
169169
{constraintText && (
170-
<ConstraintText id={constraintTextId} hasValidationText={!!errorText || !!warningText}>
170+
<ConstraintTextRegion id={constraintTextId} hasValidationText={!!errorText || !!warningText}>
171171
{constraintText}
172-
</ConstraintText>
172+
</ConstraintTextRegion>
173173
)}
174174
</div>
175175
)}

src/form-field/__tests__/form-field-rendering.test.tsx

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,17 @@ import { render } from '@testing-library/react';
66
import FormField, { FormFieldProps } from '../../../lib/components/form-field';
77
import createWrapper, { FormFieldWrapper } from '../../../lib/components/test-utils/dom';
88

9+
import screenreaderOnlyStyles from '../../../lib/components/internal/components/screenreader-only/styles.css.js';
910
import liveRegionStyles from '../../../lib/components/live-region/test-classes/styles.css.js';
1011

1112
function renderFormField(props: FormFieldProps = {}) {
12-
const renderResult = render(<FormField {...props} />);
13-
return createWrapper(renderResult.container).findFormField()!;
13+
const { container, rerender } = render(<FormField {...props} />);
14+
const wrapper = createWrapper(container).findFormField()!;
15+
return { wrapper, rerender: (props: FormFieldProps) => rerender(<FormField {...props} />) };
16+
}
17+
18+
function findDebouncedCharacterCount(wrapper: FormFieldWrapper): HTMLElement | undefined {
19+
return wrapper.findByClassName(screenreaderOnlyStyles.root)?.getElement();
1420
}
1521

1622
describe('FormField component', () => {
@@ -27,14 +33,14 @@ describe('FormField component', () => {
2733
].forEach(({ slot, finder }) => {
2834
describe(`${slot}`, () => {
2935
test(`displays empty ${slot} when not set`, () => {
30-
const wrapper = renderFormField({});
36+
const { wrapper } = renderFormField({});
3137
expect(finder(wrapper)).toBeNull();
3238
});
3339

3440
test(`displays empty ${slot} when set to empty`, () => {
3541
const props: any = {};
3642
props[slot] = '';
37-
const wrapper = renderFormField(props);
43+
const { wrapper } = renderFormField(props);
3844
expect(finder(wrapper)).toBeNull();
3945
});
4046

@@ -43,7 +49,7 @@ describe('FormField component', () => {
4349
const props: any = {};
4450
props[slot] = value;
4551

46-
const wrapper = renderFormField(props);
52+
const { wrapper } = renderFormField(props);
4753
expect(finder(wrapper)?.getElement()).toHaveTextContent(value);
4854
});
4955

@@ -55,7 +61,7 @@ describe('FormField component', () => {
5561
);
5662
const props: any = {};
5763
props[slot] = value;
58-
const wrapper = renderFormField(props);
64+
const { wrapper } = renderFormField(props);
5965

6066
expect(finder(wrapper)?.getElement()).toHaveTextContent('this is a formatted value');
6167
expect(finder(wrapper)?.getElement()).toContainHTML('<div>this is a <strong>formatted</strong> value</div>');
@@ -83,7 +89,7 @@ describe('FormField component', () => {
8389
test('label is rendered with semantic DOM element', () => {
8490
const testLabel = 'Label Unit Test';
8591

86-
const wrapper = renderFormField({
92+
const { wrapper } = renderFormField({
8793
label: testLabel,
8894
});
8995

@@ -96,7 +102,7 @@ describe('FormField component', () => {
96102
test('errorIcon has an accessible text alternative', () => {
97103
const errorText = 'Yikes, that is just plan wrong';
98104
const errorIconAriaLabel = 'Error';
99-
const wrapper = renderFormField({
105+
const { wrapper } = renderFormField({
100106
errorText,
101107
i18nStrings: { errorIconAriaLabel },
102108
});
@@ -110,7 +116,7 @@ describe('FormField component', () => {
110116
test('warningIcon has an accessible text alternative', () => {
111117
const warningText = 'You sure?';
112118
const warningIconAriaLabel = 'Warning';
113-
const wrapper = renderFormField({
119+
const { wrapper } = renderFormField({
114120
warningText,
115121
i18nStrings: { warningIconAriaLabel },
116122
});
@@ -124,7 +130,7 @@ describe('FormField component', () => {
124130
test('constraintText region displays constraint content text when error-text is also set', () => {
125131
const constraintText = 'let this be a lesson to you';
126132
const errorText = 'wrong, do it again';
127-
const wrapper = renderFormField({
133+
const { wrapper } = renderFormField({
128134
constraintText,
129135
errorText,
130136
});
@@ -135,7 +141,7 @@ describe('FormField component', () => {
135141
test('constraintText region displays constraint content text when warning-text is also set', () => {
136142
const constraintText = 'think twice';
137143
const warningText = 'warning you, check once again';
138-
const wrapper = renderFormField({
144+
const { wrapper } = renderFormField({
139145
constraintText,
140146
warningText,
141147
});
@@ -164,4 +170,65 @@ describe('FormField component', () => {
164170
expect(createWrapper().findByClassName(liveRegionStyles.announcer)?.getElement()).toBeInTheDocument();
165171
});
166172
});
173+
174+
describe('characterCountText', () => {
175+
test('does not render wrapper element when not set', () => {
176+
const { wrapper } = renderFormField({});
177+
expect(wrapper.findCharacterCount()).toBeNull();
178+
});
179+
180+
test('does not render wrapper element when set to empty', () => {
181+
const { wrapper } = renderFormField({ characterCountText: '' });
182+
expect(wrapper.findCharacterCount()).toBeNull();
183+
});
184+
185+
test('renders characterCountText when a string is passed', () => {
186+
const { wrapper } = renderFormField({ characterCountText: 'this is a string' });
187+
expect(wrapper.findCharacterCount()!.getElement()).toHaveTextContent('this is a string');
188+
});
189+
190+
describe('debouncing', () => {
191+
const DEBOUNCE_TIME_MS = 1000;
192+
193+
beforeEach(() => jest.useFakeTimers());
194+
afterEach(() => jest.useRealTimers());
195+
196+
test('renders characterCountText directly on initial render', () => {
197+
const { wrapper } = renderFormField({ characterCountText: 'this is a string' });
198+
expect(wrapper.findCharacterCount()!.getElement()).toHaveTextContent('this is a string');
199+
});
200+
201+
test("wrapper.findCharacterCount() doesn't return the debounced version of the slot", () => {
202+
const { wrapper, rerender } = renderFormField({ characterCountText: 'this is a string' });
203+
expect(wrapper.findCharacterCount()!.getElement()).toHaveTextContent('this is a string');
204+
rerender({ characterCountText: 'another string' });
205+
expect(wrapper.findCharacterCount()!.getElement()).toHaveTextContent('another string');
206+
rerender({ characterCountText: '' });
207+
expect(wrapper.findCharacterCount()).toBeNull();
208+
});
209+
210+
test('delays updates until debounce duration', async () => {
211+
const { wrapper, rerender } = renderFormField({ characterCountText: 'Character count: 5/10' });
212+
rerender({ characterCountText: 'Character count: 6/10' });
213+
expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 5/10');
214+
await jest.advanceTimersByTimeAsync(DEBOUNCE_TIME_MS);
215+
expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 6/10');
216+
});
217+
218+
test('restarts timer if a new update happened during debounce duration', async () => {
219+
const { wrapper, rerender } = renderFormField({ characterCountText: 'Character count: 5/10' });
220+
// Rerender and wait 500ms: the text should not update
221+
rerender({ characterCountText: 'Character count: 6/10' });
222+
await jest.advanceTimersByTimeAsync(DEBOUNCE_TIME_MS / 2);
223+
expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 5/10');
224+
// Rerender and wait 500ms: the text should not update
225+
rerender({ characterCountText: 'Character count: 7/10' });
226+
await jest.advanceTimersByTimeAsync(DEBOUNCE_TIME_MS / 2);
227+
expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 5/10');
228+
// Wait another 500ms (1s since last update): the text should update
229+
await jest.advanceTimersByTimeAsync(DEBOUNCE_TIME_MS / 2);
230+
expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 7/10');
231+
});
232+
});
233+
});
167234
});

src/form-field/interfaces.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ export interface FormFieldProps extends BaseComponentProps {
6868
*/
6969
constraintText?: React.ReactNode;
7070

71+
/**
72+
* Character count constraint displayed adjacent to the constraintText. Use
73+
* this to provide an updated character count on each keypress that is debounced
74+
* for screen reader users.
75+
*/
76+
characterCountText?: string;
77+
7178
/**
7279
* Text that displays as a validation error message. If this is set to a
7380
* non-empty string, it will render the form field as invalid.

0 commit comments

Comments
 (0)