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..2f75f4eb69 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.",
@@ -33454,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": [],
@@ -44491,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/__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/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/__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/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..0d7092a031 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';
@@ -29,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;
@@ -42,6 +44,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);
@@ -88,7 +92,7 @@ export function FormFieldWarning({ id, children, warningIconAriaLabel }: FormFie
);
}
-export function ConstraintText({
+export function ConstraintTextArea({
id,
hasValidationText,
children,
@@ -114,6 +118,7 @@ export default function InternalFormField({
secondaryControl,
description,
constraintText,
+ characterCountText,
errorText,
warningText,
__hideLabel,
@@ -147,6 +152,7 @@ export default function InternalFormField({
label,
description,
constraintText,
+ characterCountText,
errorText,
showWarning ? warningText : undefined
);
@@ -174,6 +180,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);
@@ -262,7 +278,7 @@ export default function InternalFormField({
- {(constraintText || errorText || warningText) && (
+ {(constraintText || characterCountText || errorText || warningText) && (
{errorText && (
@@ -274,10 +290,23 @@ export default function InternalFormField({
{warningText}
)}
- {constraintText && (
-
- {constraintText}
-
+ {(constraintText || characterCountText) && (
+
+ {constraintText && (
+
+ {constraintText}
+
+ )}
+ {characterCountText && (
+ <>
+ {!!constraintText && ' '}
+
+ {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/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;
}
diff --git a/src/test-utils/dom/form-field/index.ts b/src/test-utils/dom/form-field/index.ts
index a79df7fb4f..7a654d917a 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,11 @@ export default class FormFieldWrapper extends ComponentWrapper {
}
findConstraint(): ElementWrapper | null {
- return this.find(`:scope > .${styles.hints} .${styles.constraint}`);
+ return this.find(`:scope > .${styles.hints} .${testStyles.constraint}`);
+ }
+
+ findCharacterCount(): ElementWrapper | null {
+ return this.find(`:scope > .${styles.hints} .${testStyles['character-count']}`);
}
findError(): ElementWrapper | null {