Skip to content

Commit

Permalink
feat(text-field): announce error messages
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 474291991
  • Loading branch information
asyncLiz authored and copybara-github committed Sep 14, 2022
1 parent 6ea69ec commit 973a982
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 8 deletions.
55 changes: 47 additions & 8 deletions textfield/lib/text-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ export abstract class TextField extends LitElement {
*/
@state() protected dirty = false;
@state() protected focused = false;
/**
* When set to true, the error text's `role="alert"` will be removed, then
* re-added after an animation frame. This will re-announce an error message
* to screen readers.
*/
@state() protected refreshErrorAlert = false;
/**
* Returns true when the text field's `value` property has been changed from
* it's initial value.
Expand Down Expand Up @@ -391,21 +397,30 @@ export abstract class TextField extends LitElement {
*
* If invalid, this method will dispatch the `invalid` event.
*
* This method will update `error` to the current validity state and
* `errorText` to the current `validationMessage`, unless the invalid event is
* canceled.
* This method will display or clear an error text message equal to the text
* field's `validationMessage`, unless the invalid event is canceled.
*
* Use `setCustomValidity()` to customize the `validationMessage`.
*
* This method can also be used to re-announce error messages to screen
* readers.
*
* https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/reportValidity
*
* @return true if the text field is valid, or false if not.
*/
reportValidity() {
const {valid, canceled} = this.checkValidityAndDispatch();
if (!canceled) {
const prevMessage = this.getErrorText();
this.nativeError = !valid;
this.nativeErrorText = this.validationMessage;

const needsRefresh =
this.shouldErrorAnnounce() && prevMessage === this.getErrorText();
if (needsRefresh) {
this.refreshErrorAlert = true;
}
}

return valid;
Expand Down Expand Up @@ -677,19 +692,35 @@ export abstract class TextField extends LitElement {
* @slotName supporting-text
*/
protected renderSupportingText(): TemplateResult {
const shouldAlert = this.shouldErrorAnnounce();
const text = this.getSupportingText();
return text ?
html`<span id=${this.supportingTextId} slot="supporting-text">${
text}</span>` :
html``;
const template = html`<span id=${this.supportingTextId}
slot="supporting-text"
role=${ifDefined(shouldAlert ? 'alert' : undefined)}>${text}</span>`;

return text ? template : html``;
}

/** @soyTemplate */
protected getSupportingText(): string {
const errorText = this.error ? this.errorText : this.nativeErrorText;
const errorText = this.getErrorText();
return this.getError() && errorText ? errorText : this.supportingText;
}

/** @soyTemplate */
protected getErrorText(): string {
return this.error ? this.errorText : this.nativeErrorText;
}

/** @soyTemplate */
protected shouldErrorAnnounce(): boolean {
// Announce if there is an error and error text visible.
// If refreshErrorAlert is true, do not announce. This will remove the
// role="alert" attribute. Another render cycle will happen after an
// animation frame to re-add the role.
return this.getError() && !!this.getErrorText() && !this.refreshErrorAlert;
}

/**
* @soyTemplate
* @slotName supporting-text-end
Expand Down Expand Up @@ -744,6 +775,14 @@ export abstract class TextField extends LitElement {
// before checking its value.
this.value = value;
}

if (this.refreshErrorAlert) {
// The past render cycle removed the role="alert" from the error message.
// Re-add it after an animation frame to re-announce the error.
requestAnimationFrame(() => {
this.refreshErrorAlert = false;
});
}
}

/** @bubbleWizEvent */
Expand Down
56 changes: 56 additions & 0 deletions textfield/lib/text-field_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ class TestTextField extends TextField {
override getSupportingText() {
return super.getSupportingText();
}

override shouldErrorAnnounce() {
return super.shouldErrorAnnounce();
}
}

describe('TextField', () => {
Expand Down Expand Up @@ -603,5 +607,57 @@ describe('TextField', () => {
});
});

describe('error announcement', () => {
it('should announce errors when both error and errorText are set',
async () => {
const {testElement} = await setupTest();
testElement.error = true;
testElement.errorText = 'Error message';

expect(testElement.shouldErrorAnnounce())
.withContext('testElement.shouldErrorAnnounce()')
.toBeTrue();
});

it('should announce native errors', async () => {
const {testElement} = await setupTest();
testElement.required = true;
testElement.reportValidity();

expect(testElement.shouldErrorAnnounce())
.withContext('testElement.shouldErrorAnnounce()')
.toBeTrue();
});

it('should not announce supporting text', async () => {
const {testElement} = await setupTest();
testElement.error = true;
testElement.supportingText = 'Not an error';

expect(testElement.shouldErrorAnnounce())
.withContext('testElement.shouldErrorAnnounce()')
.toBeFalse();
});

it('should re-announce when reportValidity() is called', async () => {
const {testElement} = await setupTest();
testElement.error = true;
testElement.errorText = 'Error message';

testElement.reportValidity();
await env.waitForStability();
// After lit update, but before re-render refresh
expect(testElement.shouldErrorAnnounce())
.withContext('shouldErrorAnnounce() before refresh')
.toBeFalse();

// After the second lit update render refresh
await env.waitForStability();
expect(testElement.shouldErrorAnnounce())
.withContext('shouldErrorAnnounce() after refresh')
.toBeTrue();
});
});

// TODO(b/235238545): Add shared FormController tests.
});

0 comments on commit 973a982

Please sign in to comment.