From eb27eb02c7dce92760b1a2d589f3bdc25b859fb7 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 5 Mar 2026 00:43:17 +0100 Subject: [PATCH 1/3] fix: Add characterCountText to form field for improved screen reader support --- pages/form-field/character-count.page.tsx | 27 ++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 8 ++++++ src/form-field/interfaces.ts | 7 +++++ src/form-field/internal.tsx | 28 +++++++++++++++++-- src/form-field/util.ts | 7 +++-- 5 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 pages/form-field/character-count.page.tsx diff --git a/pages/form-field/character-count.page.tsx b/pages/form-field/character-count.page.tsx new file mode 100644 index 0000000000..ba78e9fc2d --- /dev/null +++ b/pages/form-field/character-count.page.tsx @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState } from 'react'; + +import FormField from '~components/form-field'; +import Input from '~components/input'; + +const maxCharacterCount = 20; + +export default function FormFieldCharacterCountPage() { + const [value, setValue] = useState(''); + + return ( + <> +

Form field character count debouncing

+ maxCharacterCount && 'The name has too many characters.'} + > + setValue(event.detail.value)} /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b19b3647c2..e7c38f9358 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -13597,6 +13597,14 @@ exports[`Components definition for form-field matches the snapshot: form-field 1 "optional": true, "type": "FormFieldProps.AnalyticsMetadata", }, + { + "description": "Character count constraint displayed adjacent to the constraintText. Use +this to provide an updated character count on each keypress that is debounced +for screen reader users.", + "name": "characterCountText", + "optional": true, + "type": "string", + }, { "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).", "description": "Adds the specified classes to the root element of the component.", diff --git a/src/form-field/interfaces.ts b/src/form-field/interfaces.ts index 3d1b91b6f7..7afefae861 100644 --- a/src/form-field/interfaces.ts +++ b/src/form-field/interfaces.ts @@ -68,6 +68,13 @@ export interface FormFieldProps extends BaseComponentProps { */ constraintText?: React.ReactNode; + /** + * Character count constraint displayed adjacent to the constraintText. Use + * this to provide an updated character count on each keypress that is debounced + * for screen reader users. + */ + characterCountText?: string; + /** * Text that displays as a validation error message. If this is set to a * non-empty string, it will render the form field as invalid. diff --git a/src/form-field/internal.tsx b/src/form-field/internal.tsx index a932c5a3a2..7d6f491884 100644 --- a/src/form-field/internal.tsx +++ b/src/form-field/internal.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import clsx from 'clsx'; import { useMergeRefs, useUniqueId, warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -19,6 +19,7 @@ import { getTextFromSelector, } from '../internal/analytics/selectors'; import { getBaseProps } from '../internal/base-component'; +import ScreenreaderOnly from '../internal/components/screenreader-only'; import { FormFieldContext, useFormFieldContext } from '../internal/context/form-field-context'; import { InfoLinkLabelContext } from '../internal/context/info-link-label-context'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; @@ -42,6 +43,8 @@ interface FormFieldWarningProps { warningIconAriaLabel?: string; } +const CHARACTER_COUNT_DEBOUNCE_MS = 1000; + export function FormFieldError({ id, children, errorIconAriaLabel }: FormFieldErrorProps) { const i18n = useInternalI18n('form-field'); const contentRef = useRef(null); @@ -114,6 +117,7 @@ export default function InternalFormField({ secondaryControl, description, constraintText, + characterCountText, errorText, warningText, __hideLabel, @@ -147,6 +151,7 @@ export default function InternalFormField({ label, description, constraintText, + characterCountText, errorText, showWarning ? warningText : undefined ); @@ -174,6 +179,16 @@ export default function InternalFormField({ [DATA_ATTR_FIELD_ERROR]: slotIds.error ? getFieldSlotSeletor(slotIds.error) : undefined, }; + const debounceTimeoutRef = useRef(); + const [debouncedCharacterCountText, setDebouncedCharacterCountText] = useState(characterCountText); + + useEffect(() => { + debounceTimeoutRef.current = setTimeout(() => { + setDebouncedCharacterCountText(characterCountText); + }, CHARACTER_COUNT_DEBOUNCE_MS); + return () => clearTimeout(debounceTimeoutRef.current); + }, [characterCountText]); + useEffect(() => { if (funnelInteractionId && errorText && funnelState.current !== 'complete') { const stepName = getTextFromSelector(stepNameSelector); @@ -275,8 +290,15 @@ export default function InternalFormField({ )} {constraintText && ( - - {constraintText} + + {constraintText} + {characterCountText && ( + <> + {' '} + {characterCountText} + {debouncedCharacterCountText} + + )} )} diff --git a/src/form-field/util.ts b/src/form-field/util.ts index 4a6c9c8518..3e6e5e2691 100644 --- a/src/form-field/util.ts +++ b/src/form-field/util.ts @@ -6,6 +6,7 @@ interface FormFieldIds { label?: string; description?: string; constraint?: string; + characterCount?: string; error?: string; warning?: string; } @@ -23,6 +24,7 @@ export function getSlotIds( label?: React.ReactNode, description?: React.ReactNode, constraintText?: React.ReactNode, + characterCount?: string, errorText?: React.ReactNode, warningText?: React.ReactNode ) { @@ -30,6 +32,7 @@ export function getSlotIds( label: makeSlotId(label, formFieldId, 'label'), description: makeSlotId(description, formFieldId, 'description'), constraint: makeSlotId(constraintText, formFieldId, 'constraint'), + characterCount: makeSlotId(characterCount, formFieldId, 'character-count'), error: makeSlotId(errorText, formFieldId, 'error'), warning: makeSlotId(warningText, formFieldId, 'warning'), }; @@ -37,8 +40,8 @@ export function getSlotIds( return ids; } -export function getAriaDescribedBy({ error, warning, description, constraint }: FormFieldIds) { - const describedByAttributes = [error, warning, description, constraint].filter(e => !!e); +export function getAriaDescribedBy({ error, warning, description, constraint, characterCount }: FormFieldIds) { + const describedByAttributes = [error, warning, description, constraint, characterCount].filter(e => !!e); const describedBy = describedByAttributes.length ? describedByAttributes.join(' ') : undefined; return describedBy; } From 8a3eeb8b96208d284e28882319809218a6f57d22 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 5 Mar 2026 01:31:06 +0100 Subject: [PATCH 2/3] Tests and test selectors. --- .../test-utils-selectors.test.tsx.snap | 2 + .../__tests__/form-field-rendering.test.tsx | 89 ++++++++++++++++--- src/form-field/internal.tsx | 15 +++- src/form-field/test-classes/styles.scss | 9 ++ src/test-utils/dom/form-field/index.ts | 9 +- 5 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 src/form-field/test-classes/styles.scss diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index df80540e4b..717021285c 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -313,7 +313,9 @@ exports[`test-utils selectors 1`] = ` "awsui_secondary-actions_1i0s3", ], "form-field": [ + "awsui_character-count_6mjrv", "awsui_constraint_14mhv", + "awsui_constraint_6mjrv", "awsui_control_14mhv", "awsui_description_14mhv", "awsui_error_14mhv", diff --git a/src/form-field/__tests__/form-field-rendering.test.tsx b/src/form-field/__tests__/form-field-rendering.test.tsx index e66f91c934..245b5e728a 100644 --- a/src/form-field/__tests__/form-field-rendering.test.tsx +++ b/src/form-field/__tests__/form-field-rendering.test.tsx @@ -6,11 +6,17 @@ import { render } from '@testing-library/react'; import FormField, { FormFieldProps } from '../../../lib/components/form-field'; import createWrapper, { FormFieldWrapper } from '../../../lib/components/test-utils/dom'; +import screenreaderOnlyStyles from '../../../lib/components/internal/components/screenreader-only/styles.css.js'; import liveRegionStyles from '../../../lib/components/live-region/test-classes/styles.css.js'; function renderFormField(props: FormFieldProps = {}) { - const renderResult = render(); - return createWrapper(renderResult.container).findFormField()!; + const { container, rerender } = render(); + const wrapper = createWrapper(container).findFormField()!; + return { wrapper, rerender: (props: FormFieldProps) => rerender() }; +} + +function findDebouncedCharacterCount(wrapper: FormFieldWrapper): HTMLElement | undefined { + return wrapper.findByClassName(screenreaderOnlyStyles.root)?.getElement(); } describe('FormField component', () => { @@ -27,14 +33,14 @@ describe('FormField component', () => { ].forEach(({ slot, finder }) => { describe(`${slot}`, () => { test(`displays empty ${slot} when not set`, () => { - const wrapper = renderFormField({}); + const { wrapper } = renderFormField({}); expect(finder(wrapper)).toBeNull(); }); test(`displays empty ${slot} when set to empty`, () => { const props: any = {}; props[slot] = ''; - const wrapper = renderFormField(props); + const { wrapper } = renderFormField(props); expect(finder(wrapper)).toBeNull(); }); @@ -43,7 +49,7 @@ describe('FormField component', () => { const props: any = {}; props[slot] = value; - const wrapper = renderFormField(props); + const { wrapper } = renderFormField(props); expect(finder(wrapper)?.getElement()).toHaveTextContent(value); }); @@ -55,7 +61,7 @@ describe('FormField component', () => { ); const props: any = {}; props[slot] = value; - const wrapper = renderFormField(props); + const { wrapper } = renderFormField(props); expect(finder(wrapper)?.getElement()).toHaveTextContent('this is a formatted value'); expect(finder(wrapper)?.getElement()).toContainHTML('
this is a formatted value
'); @@ -83,7 +89,7 @@ describe('FormField component', () => { test('label is rendered with semantic DOM element', () => { const testLabel = 'Label Unit Test'; - const wrapper = renderFormField({ + const { wrapper } = renderFormField({ label: testLabel, }); @@ -96,7 +102,7 @@ describe('FormField component', () => { test('errorIcon has an accessible text alternative', () => { const errorText = 'Yikes, that is just plan wrong'; const errorIconAriaLabel = 'Error'; - const wrapper = renderFormField({ + const { wrapper } = renderFormField({ errorText, i18nStrings: { errorIconAriaLabel }, }); @@ -110,7 +116,7 @@ describe('FormField component', () => { test('warningIcon has an accessible text alternative', () => { const warningText = 'You sure?'; const warningIconAriaLabel = 'Warning'; - const wrapper = renderFormField({ + const { wrapper } = renderFormField({ warningText, i18nStrings: { warningIconAriaLabel }, }); @@ -124,7 +130,7 @@ describe('FormField component', () => { test('constraintText region displays constraint content text when error-text is also set', () => { const constraintText = 'let this be a lesson to you'; const errorText = 'wrong, do it again'; - const wrapper = renderFormField({ + const { wrapper } = renderFormField({ constraintText, errorText, }); @@ -135,7 +141,7 @@ describe('FormField component', () => { test('constraintText region displays constraint content text when warning-text is also set', () => { const constraintText = 'think twice'; const warningText = 'warning you, check once again'; - const wrapper = renderFormField({ + const { wrapper } = renderFormField({ constraintText, warningText, }); @@ -164,4 +170,65 @@ describe('FormField component', () => { expect(createWrapper().findByClassName(liveRegionStyles.announcer)?.getElement()).toBeInTheDocument(); }); }); + + describe('characterCountText', () => { + test('does not render wrapper element when not set', () => { + const { wrapper } = renderFormField({}); + expect(wrapper.findCharacterCount()).toBeNull(); + }); + + test('does not render wrapper element when set to empty', () => { + const { wrapper } = renderFormField({ characterCountText: '' }); + expect(wrapper.findCharacterCount()).toBeNull(); + }); + + test('renders characterCountText when a string is passed', () => { + const { wrapper } = renderFormField({ characterCountText: 'this is a string' }); + expect(wrapper.findCharacterCount()!.getElement()).toHaveTextContent('this is a string'); + }); + + describe('debouncing', () => { + const DEBOUNCE_TIME_MS = 1000; + + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test('renders characterCountText directly on initial render', () => { + const { wrapper } = renderFormField({ characterCountText: 'this is a string' }); + expect(wrapper.findCharacterCount()!.getElement()).toHaveTextContent('this is a string'); + }); + + test("wrapper.findCharacterCount() doesn't return the debounced version of the slot", () => { + const { wrapper, rerender } = renderFormField({ characterCountText: 'this is a string' }); + expect(wrapper.findCharacterCount()!.getElement()).toHaveTextContent('this is a string'); + rerender({ characterCountText: 'another string' }); + expect(wrapper.findCharacterCount()!.getElement()).toHaveTextContent('another string'); + rerender({ characterCountText: '' }); + expect(wrapper.findCharacterCount()).toBeNull(); + }); + + test('delays updates until debounce duration', async () => { + const { wrapper, rerender } = renderFormField({ characterCountText: 'Character count: 5/10' }); + rerender({ characterCountText: 'Character count: 6/10' }); + expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 5/10'); + await jest.advanceTimersByTimeAsync(DEBOUNCE_TIME_MS); + expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 6/10'); + }); + + test('restarts timer if a new update happened during debounce duration', async () => { + const { wrapper, rerender } = renderFormField({ characterCountText: 'Character count: 5/10' }); + // Rerender and wait 500ms: the text should not update + rerender({ characterCountText: 'Character count: 6/10' }); + await jest.advanceTimersByTimeAsync(DEBOUNCE_TIME_MS / 2); + expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 5/10'); + // Rerender and wait 500ms: the text should not update + rerender({ characterCountText: 'Character count: 7/10' }); + await jest.advanceTimersByTimeAsync(DEBOUNCE_TIME_MS / 2); + expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 5/10'); + // Wait another 500ms (1s since last update): the text should update + await jest.advanceTimersByTimeAsync(DEBOUNCE_TIME_MS / 2); + expect(findDebouncedCharacterCount(wrapper)!).toHaveTextContent('Character count: 7/10'); + }); + }); + }); }); diff --git a/src/form-field/internal.tsx b/src/form-field/internal.tsx index 7d6f491884..a910583ca6 100644 --- a/src/form-field/internal.tsx +++ b/src/form-field/internal.tsx @@ -30,6 +30,7 @@ import { getAriaDescribedBy, getGridDefinition, getSlotIds } from './util'; import analyticsSelectors from './analytics-metadata/styles.css.js'; import styles from './styles.css.js'; +import testStyles from './test-classes/styles.css.js'; interface FormFieldErrorProps { id?: string; @@ -277,7 +278,7 @@ export default function InternalFormField({ - {(constraintText || errorText || warningText) && ( + {(constraintText || characterCountText || errorText || warningText) && (
{errorText && ( @@ -289,13 +290,19 @@ export default function InternalFormField({ {warningText} )} - {constraintText && ( + {(constraintText || characterCountText) && ( - {constraintText} + {constraintText && ( + + {constraintText} + + )} {characterCountText && ( <> {' '} - {characterCountText} + + {characterCountText} + {debouncedCharacterCountText} )} diff --git a/src/form-field/test-classes/styles.scss b/src/form-field/test-classes/styles.scss new file mode 100644 index 0000000000..cd2c60e61c --- /dev/null +++ b/src/form-field/test-classes/styles.scss @@ -0,0 +1,9 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.constraint, +.character-count { + /* used in test-utils */ +} diff --git a/src/test-utils/dom/form-field/index.ts b/src/test-utils/dom/form-field/index.ts index a79df7fb4f..0ded53d893 100644 --- a/src/test-utils/dom/form-field/index.ts +++ b/src/test-utils/dom/form-field/index.ts @@ -3,6 +3,7 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; import styles from '../../../form-field/styles.selectors.js'; +import testStyles from '../../../form-field/test-classes/styles.selectors.js'; export default class FormFieldWrapper extends ComponentWrapper { static rootSelector: string = styles.root; @@ -20,7 +21,13 @@ export default class FormFieldWrapper extends ComponentWrapper { } findConstraint(): ElementWrapper | null { - return this.find(`:scope > .${styles.hints} .${styles.constraint}`); + return this.find( + `:scope > .${styles.hints} .${styles.constraint}, :scope > .${styles.hints} .${testStyles.constraint}` + ); + } + + findCharacterCount(): ElementWrapper | null { + return this.find(`:scope > .${styles.hints} .${testStyles['character-count']}`); } findError(): ElementWrapper | null { From d0adbdd29fb15cbe6ab920b090be25c698dc9585 Mon Sep 17 00:00:00 2001 From: Avinash Dwarapu Date: Thu, 5 Mar 2026 01:35:43 +0100 Subject: [PATCH 3/3] Give up on making constraintText selector "backwards-compatible". --- .../__snapshots__/documenter.test.ts.snap | 21 +++++++++++++++++++ src/file-upload/internal.tsx | 6 +++--- src/form-field/internal.tsx | 8 +++---- src/test-utils/dom/form-field/index.ts | 4 +--- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index e7c38f9358..2f75f4eb69 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -33462,6 +33462,19 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). }, { "methods": [ + { + "name": "findCharacterCount", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "name": "findConstraint", "parameters": [], @@ -44499,6 +44512,14 @@ To find a specific row use the \`findRow(n)\` function as chaining \`findRows(). }, { "methods": [ + { + "name": "findCharacterCount", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "name": "findConstraint", "parameters": [], diff --git a/src/file-upload/internal.tsx b/src/file-upload/internal.tsx index 18d8cd18d5..4cc0509c1f 100644 --- a/src/file-upload/internal.tsx +++ b/src/file-upload/internal.tsx @@ -13,7 +13,7 @@ import InternalFileDropzone from '../file-dropzone/internal'; import { useFilesDragging } from '../file-dropzone/use-files-dragging'; import InternalFileInput from '../file-input/internal'; import InternalFileTokenGroup from '../file-token-group/internal'; -import { ConstraintText, FormFieldError, FormFieldWarning } from '../form-field/internal'; +import { ConstraintTextArea, FormFieldError, FormFieldWarning } from '../form-field/internal'; import { useInternalI18n } from '../i18n/context'; import { getBaseProps } from '../internal/base-component'; import { fireNonCancelableEvent } from '../internal/events'; @@ -167,9 +167,9 @@ function InternalFileUpload( )} {constraintText && ( - + {constraintText} - + )}
)} diff --git a/src/form-field/internal.tsx b/src/form-field/internal.tsx index a910583ca6..0d7092a031 100644 --- a/src/form-field/internal.tsx +++ b/src/form-field/internal.tsx @@ -92,7 +92,7 @@ export function FormFieldWarning({ id, children, warningIconAriaLabel }: FormFie ); } -export function ConstraintText({ +export function ConstraintTextArea({ id, hasValidationText, children, @@ -291,7 +291,7 @@ export default function InternalFormField({ )} {(constraintText || characterCountText) && ( - + {constraintText && ( {constraintText} @@ -299,14 +299,14 @@ export default function InternalFormField({ )} {characterCountText && ( <> - {' '} + {!!constraintText && ' '} {characterCountText} {debouncedCharacterCountText} )} - + )} )} diff --git a/src/test-utils/dom/form-field/index.ts b/src/test-utils/dom/form-field/index.ts index 0ded53d893..7a654d917a 100644 --- a/src/test-utils/dom/form-field/index.ts +++ b/src/test-utils/dom/form-field/index.ts @@ -21,9 +21,7 @@ export default class FormFieldWrapper extends ComponentWrapper { } findConstraint(): ElementWrapper | null { - return this.find( - `:scope > .${styles.hints} .${styles.constraint}, :scope > .${styles.hints} .${testStyles.constraint}` - ); + return this.find(`:scope > .${styles.hints} .${testStyles.constraint}`); } findCharacterCount(): ElementWrapper | null {