From fd392fe5658bf1b08c0bbcbdb74b4e3e47a1d360 Mon Sep 17 00:00:00 2001 From: Ben Elan Date: Tue, 16 Jan 2024 17:03:47 -0800 Subject: [PATCH] feat: use input-message to display validation messages for invalid fields after form submission (#8574) **Related Issue:** #8000 ## Summary - Replaces the native popover displayed on form elements with invalid values after form submission. - The `calcite-input-message` used to display the validation message is cleared once the component's Change event fires (or Input event for `calcite-input`, `calcite-input-number`, `calcite-input-text`, and `calcite-text-area`). - Non-calcite form elements will still display the native popover to prevent a breaking change. It is up to the developer replace the native popover with a `calcite-input-message` to prevent UI inconsistencies. - This change may cause layout shifting after form submission due to adding a `calcite-input-message` under calcite form elements with invalid values. --- .../components/input-number/input-number.tsx | 7 - .../src/components/input-text/input-text.tsx | 7 - .../src/components/input/common/tests.ts | 6 +- .../src/components/input/input.tsx | 7 - .../src/components/text-area/text-area.tsx | 4 - .../calcite-components/src/demos/form.html | 201 ++++++------ .../calcite-components/src/utils/form.e2e.ts | 301 ++++++++++++++++++ .../calcite-components/src/utils/form.tsx | 75 +++++ .../calcite-components/tsconfig-base.json | 2 +- 9 files changed, 487 insertions(+), 123 deletions(-) create mode 100644 packages/calcite-components/src/utils/form.e2e.ts diff --git a/packages/calcite-components/src/components/input-number/input-number.tsx b/packages/calcite-components/src/components/input-number/input-number.tsx index c058ad08984..c7114d094ff 100644 --- a/packages/calcite-components/src/components/input-number/input-number.tsx +++ b/packages/calcite-components/src/components/input-number/input-number.tsx @@ -804,13 +804,6 @@ export class InputNumber } }; - onFormReset(): void { - this.setNumberValue({ - origin: "reset", - value: this.defaultValue, - }); - } - syncHiddenFormInput(input: HTMLInputElement): void { input.type = "number"; input.min = this.min?.toString(10) ?? ""; diff --git a/packages/calcite-components/src/components/input-text/input-text.tsx b/packages/calcite-components/src/components/input-text/input-text.tsx index c9d8b28ff91..354ad16e0c2 100644 --- a/packages/calcite-components/src/components/input-text/input-text.tsx +++ b/packages/calcite-components/src/components/input-text/input-text.tsx @@ -500,13 +500,6 @@ export class InputText } }; - onFormReset(): void { - this.setValue({ - origin: "reset", - value: this.defaultValue, - }); - } - syncHiddenFormInput(input: HTMLInputElement): void { if (this.minLength != null) { input.minLength = this.minLength; diff --git a/packages/calcite-components/src/components/input/common/tests.ts b/packages/calcite-components/src/components/input/common/tests.ts index f1e15dc57a2..d0200d46f57 100644 --- a/packages/calcite-components/src/components/input/common/tests.ts +++ b/packages/calcite-components/src/components/input/common/tests.ts @@ -13,7 +13,7 @@ export function testPostValidationFocusing( await page.setContent(html`
- <${inputTag} type="text" required name="${inputName}"> + <${inputTag} required name="${inputName}">
diff --git a/packages/calcite-components/src/utils/form.e2e.ts b/packages/calcite-components/src/utils/form.e2e.ts new file mode 100644 index 00000000000..96fe0980d2b --- /dev/null +++ b/packages/calcite-components/src/utils/form.e2e.ts @@ -0,0 +1,301 @@ +import { E2EElement, newE2EPage } from "@stencil/core/testing"; +import { html } from "../../support/formatting"; +import { componentsWithInputEvent } from "./form"; + +async function assertValidationIdle(element: E2EElement) { + expect(await element.getProperty("status")).toBe("idle"); + expect(await element.getProperty("validationMessage")).toBe(""); + expect(await element.getProperty("validationIcon")).toBe(false); +} + +async function assertValidationInvalid(element: E2EElement, message: string) { + expect(await element.getProperty("status")).toBe("invalid"); + expect(await element.getProperty("validationMessage")).toBe(message); + expect(element.getAttribute("validation-icon")).toBe(""); +} + +describe("form", () => { + describe("constraint validation", () => { + describe("required property", () => { + const requiredValidationMessage = "Please fill out this field."; + + const getInputEventName = (component: string): string => + component + .split("-") + .map((part: string, index: number) => (index === 0 ? part : `${part[0].toUpperCase()}${part.slice(1)}`)) + .join("") + .concat("Input"); + + for (const component of ["calcite-input", "calcite-input-number", "calcite-input-text"]) { + it(`${component} - enter to submit`, async () => { + const page = await newE2EPage(); + await page.setContent(html` +
+ <${component} required name="${component}"> +
+ `); + + const element = await page.find(component); + + const clearValidationEventName = getInputEventName(component); + const inputEvent = await page.spyOnEvent(clearValidationEventName); + + await element.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + await assertValidationInvalid(element, requiredValidationMessage); + + await page.keyboard.press("1"); + await page.waitForChanges(); + + expect(inputEvent).toHaveReceivedEventTimes(1); + expect(await element.getProperty("value")).toBe("1"); + + await assertValidationIdle(element); + }); + } + + for (const component of componentsWithInputEvent) { + it(`${component}`, async () => { + const page = await newE2EPage(); + await page.setContent(html` +
+ <${component} required name="${component}"> + Submit +
+ `); + + const submitButton = await page.find("calcite-button"); + const element = await page.find(component); + + const clearValidationEventName = getInputEventName(component); + const inputEvent = await page.spyOnEvent(clearValidationEventName); + + await submitButton.click(); + await page.waitForChanges(); + + await assertValidationInvalid(element, requiredValidationMessage); + + await element.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("1"); + await page.waitForChanges(); + + expect(inputEvent).toHaveReceivedEventTimes(1); + expect(await element.getProperty("value")).toBe("1"); + + await assertValidationIdle(element); + }); + } + + it(`calcite-input-date-picker`, async () => { + const page = await newE2EPage(); + await page.setContent(html` +
+ + Submit +
+ `); + + const submitButton = await page.find("calcite-button"); + const element = await page.find("calcite-input-date-picker"); + const changeEvent = await page.spyOnEvent("calciteInputDatePickerChange"); + + await submitButton.click(); + await page.waitForChanges(); + + await assertValidationInvalid(element, requiredValidationMessage); + + await element.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.type("12/12/2012"); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + + await assertValidationIdle(element); + }); + + it(`calcite-input-time-picker`, async () => { + const page = await newE2EPage(); + await page.setContent(html` +
+ + Submit +
+ `); + + const submitButton = await page.find("calcite-button"); + const element = await page.find("calcite-input-time-picker"); + const changeEvent = await page.spyOnEvent("calciteInputTimePickerChange"); + + await submitButton.click(); + await page.waitForChanges(); + + await assertValidationInvalid(element, requiredValidationMessage); + + await element.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.type("12:00 PM"); + await page.keyboard.press("Tab"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await element.getProperty("value")).toBe("12:00"); + + await assertValidationIdle(element); + }); + + it(`calcite-select`, async () => { + const page = await newE2EPage(); + await page.setContent(html` +
+ + + uno + dos + tres + + Submit +
+ `); + + const submitButton = await page.find("calcite-button"); + const element = await page.find("calcite-select"); + const changeEvent = await page.spyOnEvent("calciteSelectChange"); + + await submitButton.click(); + await page.waitForChanges(); + + await assertValidationInvalid(element, requiredValidationMessage); + + await element.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await element.getProperty("value")).toBe("uno"); + + await assertValidationIdle(element); + }); + + it(`calcite-combobox`, async () => { + const page = await newE2EPage(); + await page.setContent(html` +
+ + + + Submit +
+ `); + + const submitButton = await page.find("calcite-button"); + const element = await page.find("calcite-combobox"); + const changeEvent = await page.spyOnEvent("calciteComboboxChange"); + + await submitButton.click(); + await page.waitForChanges(); + + await assertValidationInvalid(element, requiredValidationMessage); + + await element.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("Space"); + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await element.getProperty("value")).toBe("Pine"); + await assertValidationIdle(element); + }); + + it.skip(`calcite-radio-button-group`, async () => { + const page = await newE2EPage(); + await page.setContent(html` +
+ + + 1 + + + + 2 + + + + 3 + + + + Submit +
+ `); + + const submitButton = await page.find("calcite-button"); + const element = await page.find("calcite-radio-button-group"); + const changeEvent = await page.spyOnEvent("calciteRadioButtonGroupChange"); + + await submitButton.click(); + await page.waitForChanges(); + + await assertValidationInvalid(element, requiredValidationMessage); + + await element.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("Space"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await element.getProperty("value")).toBe("1"); + + await assertValidationIdle(element); + }); + + it.skip(`calcite-segmented-control`, async () => { + const page = await newE2EPage(); + await page.setContent(html` +
+ + 1 + 2 + 3 + + Submit +
+ `); + + const submitButton = await page.find("calcite-button"); + const element = await page.find("calcite-segmented-control"); + const changeEvent = await page.spyOnEvent("calciteSegmentedControlChange"); + + await submitButton.click(); + await page.waitForChanges(); + + await assertValidationInvalid(element, requiredValidationMessage); + + await element.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("Space"); + await page.waitForChanges(); + + expect(changeEvent).toHaveReceivedEventTimes(1); + expect(await element.getProperty("value")).toBe("1"); + + await assertValidationIdle(element); + }); + }); + }); +}); diff --git a/packages/calcite-components/src/utils/form.tsx b/packages/calcite-components/src/utils/form.tsx index ab241412434..1476bf9dd29 100644 --- a/packages/calcite-components/src/utils/form.tsx +++ b/packages/calcite-components/src/utils/form.tsx @@ -1,6 +1,17 @@ import { closestElementCrossShadowBoundary, queryElementRoots } from "./dom"; import { FunctionalComponent, h } from "@stencil/core"; +/** + * Any form with a `calciteInput` event needs to be included in this array. + * Exported for testing purposes. + */ +export const componentsWithInputEvent = [ + "calcite-input", + "calcite-input-number", + "calcite-input-text", + "calcite-text-area", +]; + /** * Exported for testing purposes. */ @@ -158,6 +169,55 @@ function hasRegisteredFormComponentParent( return hasRegisteredFormComponentParent; } +function clearFormValidation(component: HTMLCalciteInputElement | FormComponent): void { + "status" in component && (component.status = "idle"); + "validationIcon" in component && (component.validationIcon = false); + "validationMessage" in component && (component.validationMessage = ""); +} + +function setInvalidFormValidation( + component: HTMLCalciteInputElement | FormComponent, + message: string, +): void { + "status" in component && (component.status = "invalid"); + "validationIcon" in component && (component.validationIcon = true); + "validationMessage" in component && (component.validationMessage = message); +} + +function displayValidationMessage(event: Event) { + // target is the hidden input, which is slotted in the actual form component + const hiddenInput = event?.target as HTMLInputElement; + + // not necessarily a calcite-input, but we don't have an HTMLCalciteFormElement type + const formComponent = hiddenInput?.parentElement as HTMLCalciteInputElement; + + const componentTag = formComponent?.nodeName?.toLowerCase(); + const componentTagParts = componentTag?.split("-"); + + if (componentTagParts.length < 2 || componentTagParts[0] !== "calcite") { + return; + } + + // prevent the browser from showing the native validation popover + event?.preventDefault(); + + setInvalidFormValidation(formComponent, hiddenInput?.validationMessage || ""); + + const componentTagCamelCase = componentTagParts + .map((part: string, index: number) => + index === 0 ? part : `${part[0].toUpperCase()}${part.slice(1)}`, + ) + .join(""); + + const clearValidationEvent = `${componentTagCamelCase}${ + componentsWithInputEvent.includes(componentTag) ? "Input" : "Change" + }`; + + formComponent.addEventListener(clearValidationEvent, () => clearFormValidation(formComponent), { + once: true, + }); +} + /** * Helper to submit a form. * @@ -171,7 +231,21 @@ export function submitForm(component: FormOwner): boolean { return false; } + formEl.addEventListener("invalid", displayValidationMessage, true); formEl.requestSubmit(); + formEl.removeEventListener("invalid", displayValidationMessage, true); + + requestAnimationFrame(() => { + const invalidEls = formEl.querySelectorAll("[status=invalid]"); + + // focus the first invalid element that has a validation message + for (const el of invalidEls) { + if ((el as HTMLCalciteInputElement)?.validationMessage) { + (el as HTMLCalciteInputElement)?.setFocus(); + break; + } + } + }); return true; } @@ -225,6 +299,7 @@ export function findAssociatedForm(component: FormOwner): HTMLFormElement | null } function onFormReset(this: FormComponent): void { + clearFormValidation(this); if (isCheckable(this)) { this.checked = this.defaultChecked; return; diff --git a/packages/calcite-components/tsconfig-base.json b/packages/calcite-components/tsconfig-base.json index cc20fd3b604..943a8d4043b 100755 --- a/packages/calcite-components/tsconfig-base.json +++ b/packages/calcite-components/tsconfig-base.json @@ -4,7 +4,7 @@ "allowUnreachableCode": false, "declaration": false, "experimentalDecorators": true, - "lib": ["dom", "es2021"], + "lib": ["dom", "dom.iterable", "es2021"], "moduleResolution": "node", "module": "esnext", "target": "es2020",