Skip to content
Draft
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
27 changes: 27 additions & 0 deletions pages/form-field/character-count.page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<h1>Form field character count debouncing</h1>
<FormField
label="Name"
constraintText="Name must be 1 to 10 characters."
characterCountText={`Character count: ${value.length}/${maxCharacterCount}`}
errorText={value.length > maxCharacterCount && 'The name has too many characters.'}
>
<Input value={value} onChange={event => setValue(event.detail.value)} />
</FormField>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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": [],
Expand Down Expand Up @@ -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": [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions src/file-upload/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -167,9 +167,9 @@ function InternalFileUpload(
</FormFieldWarning>
)}
{constraintText && (
<ConstraintText id={constraintTextId} hasValidationText={!!errorText || !!warningText}>
<ConstraintTextArea id={constraintTextId} hasValidationText={!!errorText || !!warningText}>
{constraintText}
</ConstraintText>
</ConstraintTextArea>
)}
</div>
)}
Expand Down
89 changes: 78 additions & 11 deletions src/form-field/__tests__/form-field-rendering.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FormField {...props} />);
return createWrapper(renderResult.container).findFormField()!;
const { container, rerender } = render(<FormField {...props} />);
const wrapper = createWrapper(container).findFormField()!;
return { wrapper, rerender: (props: FormFieldProps) => rerender(<FormField {...props} />) };
}

function findDebouncedCharacterCount(wrapper: FormFieldWrapper): HTMLElement | undefined {
return wrapper.findByClassName(screenreaderOnlyStyles.root)?.getElement();
}

describe('FormField component', () => {
Expand All @@ -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();
});

Expand All @@ -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);
});

Expand All @@ -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('<div>this is a <strong>formatted</strong> value</div>');
Expand Down Expand Up @@ -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,
});

Expand All @@ -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 },
});
Expand All @@ -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 },
});
Expand All @@ -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,
});
Expand All @@ -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,
});
Expand Down Expand Up @@ -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');
});
});
});
});
7 changes: 7 additions & 0 deletions src/form-field/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading