Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: adds constraint validation to all form-associated elements #3932

Merged
merged 8 commits into from Sep 22, 2020
Expand Up @@ -393,11 +393,12 @@ export abstract class FormAssociated<T extends HTMLInputElement | HTMLTextAreaEl
protected abstract proxy: T;
reportValidity(): boolean;
required: boolean;
protected requiredChanged(): void;
protected requiredChanged(prev: boolean, next: boolean): void;
protected setFormValue(value: File | string | FormData | null, state?: File | string | FormData | null): void;
// Warning: (ae-forgotten-export) The symbol "ValidityStateFlags" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "HTMLElement" needs to be exported by the entry point index.d.ts
setValidity(flags: ValidityStateFlags, message?: string, anchor?: HTMLElement_2): void;
protected validate(): void;
get validationMessage(): string;
get validity(): ValidityState;
value: string;
Expand Down
Expand Up @@ -279,4 +279,28 @@ describe("Checkbox", () => {
await disconnect();
});
});

describe("that is required", () => {
it("should be invalid when unchecked", async () => {
const { element, connect, disconnect } = await setup();
await connect();

element.required = true;
element.checked = false;

expect(element.validity.valueMissing).to.equal(true);

await disconnect();
});
it("should be valid when checked", async () => {
const { element, connect, disconnect } = await setup();
await connect();

element.required = true;
element.checked = true;

expect(element.validity.valueMissing).to.equal(false);
await disconnect();
});
});
});
Expand Up @@ -90,6 +90,8 @@ export class Checkbox extends FormAssociated<HTMLInputElement> {
if (this.constructed) {
this.$emit("change");
}

this.validate();
}

protected proxy: HTMLInputElement = document.createElement("input");
Expand Down
Expand Up @@ -213,6 +213,7 @@ export abstract class FormAssociated<
}

this.setFormValue(this.value);
this.validate();
}

/**
Expand Down Expand Up @@ -319,12 +320,13 @@ export abstract class FormAssociated<
* They must be sure to invoke `super.requiredChanged(previous, next)` to ensure
* proper functioning of `FormAssociated`
*/
protected requiredChanged(): void {
protected requiredChanged(prev: boolean, next: boolean): void {
if (this.proxy instanceof HTMLElement) {
this.proxy.required = this.required;
}

DOM.queueUpdate(() => this.classList.toggle("required", this.required));
this.validate();
}

/**
Expand Down Expand Up @@ -472,6 +474,16 @@ export abstract class FormAssociated<
this.shadowRoot?.removeChild(this.proxySlot as HTMLSlotElement);
}

/**
* Sets the validity of the custom element. By default this uses the proxy element to determine
* validity, but this can be extended or replaced in implementation.
*/
protected validate() {
if (this.proxy instanceof HTMLElement) {
this.setValidity(this.proxy.validity, this.proxy.validationMessage);
}
}

/**
* Associates the provided value (and optional state) with the parent form.
* @param value - The value to set
Expand Down
27 changes: 27 additions & 0 deletions packages/web-components/fast-foundation/src/radio/radio.spec.ts
Expand Up @@ -266,4 +266,31 @@ describe("Radio", () => {
await disconnect();
});
});

describe("that is required", () => {
it("should be invalid when not checked", async () => {
const { element, connect, disconnect } = await setup();
await connect();
element.name = "name";
element.required = true;
element.value = "test";
expect(element.validity.valueMissing).to.equal(true);

await disconnect();
});

it("should be valid when checked", async () => {
const { element, connect, disconnect } = await setup();
await connect();

element.name = "name";
element.value = "test";
element.required = true;
element.checked = true;

expect(element.validity.valueMissing).to.equal(false);

await disconnect();
});
});
});
4 changes: 4 additions & 0 deletions packages/web-components/fast-foundation/src/radio/radio.ts
Expand Up @@ -112,6 +112,8 @@ export class Radio extends FormAssociated<HTMLInputElement> implements RadioCont
this.$emit("change");
this.checkedAttribute = this.checked;
this.updateForm();

this.validate();
}

protected proxy: HTMLInputElement = document.createElement("input");
Expand All @@ -130,6 +132,7 @@ export class Radio extends FormAssociated<HTMLInputElement> implements RadioCont
super.connectedCallback();

this.proxy.setAttribute("type", "radio");
this.validate();

if (
this.parentElement?.getAttribute("role") !== "radiogroup" &&
Expand All @@ -139,6 +142,7 @@ export class Radio extends FormAssociated<HTMLInputElement> implements RadioCont
this.setAttribute("tabindex", "0");
}
}

this.updateForm();
}

Expand Down
Expand Up @@ -147,6 +147,8 @@ export class Slider extends FormAssociated<HTMLInputElement>
if (this.proxy instanceof HTMLElement) {
this.proxy.min = `${this.min}`;
}

this.validate;
}

/**
Expand All @@ -162,6 +164,7 @@ export class Slider extends FormAssociated<HTMLInputElement>
if (this.proxy instanceof HTMLElement) {
this.proxy.max = `${this.max}`;
}
this.validate();
}

/**
Expand All @@ -176,6 +179,8 @@ export class Slider extends FormAssociated<HTMLInputElement>
if (this.proxy instanceof HTMLElement) {
this.proxy.step = `${this.step}`;
}

this.validate();
}

/**
Expand Down
Expand Up @@ -231,4 +231,28 @@ describe("Switch", () => {
await disconnect();
});
});

describe("that is required", () => {
it("should be invalid when unchecked", async () => {
const { element, connect, disconnect } = await setup();
await connect();

element.required = true;
element.checked = false;

expect(element.validity.valueMissing).to.equal(true);

await disconnect();
});
it("should be valid when checked", async () => {
const { element, connect, disconnect } = await setup();
await connect();

element.required = true;
element.checked = true;

expect(element.validity.valueMissing).to.equal(false);
await disconnect();
});
});
});
Expand Up @@ -92,6 +92,8 @@ export class Switch extends FormAssociated<HTMLInputElement> {
this.$emit("change");

this.checked ? this.classList.add("checked") : this.classList.remove("checked");

this.validate();
}

protected proxy = document.createElement("input");
Expand Down
@@ -1,7 +1,8 @@
import { customElement } from "@microsoft/fast-element";
import { expect } from "chai";
import { TextField, TextFieldTemplate as template } from "./index";
import { fixture } from "../fixture";
import { customElement } from "@microsoft/fast-element";
import { TextField, TextFieldTemplate as template, TextFieldTemplate } from "./index";
import { TextFieldType } from "./text-field";

@customElement({
name: "fast-text-field",
Expand Down Expand Up @@ -493,4 +494,172 @@ describe("TextField", () => {
await disconnect();
});
});

describe("with constraint validation", () => {
Object.keys(TextFieldType)
.map(key => TextFieldType[key])
.forEach(type => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NICE!

describe(`of [type="${type}"]`, () => {
describe("that is [required]", () => {
it("should be invalid when it's value property is an empty string", async () => {
const { element, connect, disconnect } = await setup();
await connect();

element.type = type;
element.required = true;
element.value = "";

expect(element.validity.valueMissing).to.equal(true);

await disconnect();
});

it("should be valid when value property is a string that is non-empty", async () => {
const { element, connect, disconnect } = await setup();
await connect();

element.type = type;

element.required = true;
element.value = "some value";

expect(element.validity.valueMissing).to.equal(false);
await disconnect();
});
});
describe("that has a [minlength] attribute", () => {
it("should be valid if the value is an empty string", async () => {
const { element, connect, disconnect } = await setup();
await connect();
const value = "";
const el = document.createElement(
"fast-text-field"
) as TextField;
el.type = type;
el.value = value;
el.minlength = value.length + 1;

expect(el.validity.tooShort).to.equal(false);
});
it("should be valid if the value has a length less than the minlength", async () => {
const { element, connect, disconnect } = await setup();
await connect();
const value = "value";
const el = document.createElement(
"fast-text-field"
) as TextField;
el.type = type;
el.value = value;
el.minlength = value.length + 1;

expect(el.validity.tooShort).to.equal(false);
});
});

describe("that has a [maxlength] attribute", () => {
it("should be valid if the value is an empty string", async () => {
const { element, connect, disconnect } = await setup();
await connect();

const value = "";
const el = document.createElement(
"fast-text-field"
) as TextField;
el.type = type;
el.value = value;
el.maxlength = value.length;

expect(el.validity.tooLong).to.equal(false);
});
it("should be valid if the value has a exceeding the maxlength", async () => {
const { element, connect, disconnect } = await setup();
await connect();
const value = "value";
element.type = type;
element.value = value;
element.maxlength = value.length - 1;

expect(element.validity.tooLong).to.equal(false);
});
it("should be valid if the value has a length shorter than maxlength and the element is [required]", async () => {
const { element, connect, disconnect } = await setup();
await connect();
const value = "value";
element.type = type;
element.required = true;
element.value = value;
element.maxlength = value.length + 1;

expect(element.validity.tooLong).to.equal(false);
});
});

describe("that has a [pattern] attribute", () => {
it("should be valid if the value matches a pattern", async () => {
const { element, connect, disconnect } = await setup();
await connect();
const value = "value";
element.type = type;
element.required = true;
element.pattern = value;
element.value = value;

expect(element.validity.patternMismatch).to.equal(false);
});

it("should be invalid if the value does not match a pattern", async () => {
const { element, connect, disconnect } = await setup();
await connect();
const value = "value";
element.type = type;
element.required = true;
element.pattern = value;
element.value = "foo";

expect(element.validity.patternMismatch).to.equal(true);
});
});
});
});
describe('of [type="email"]', () => {
it("should be valid when value is an empty string", async () => {
const { element, connect, disconnect } = await setup();
await connect();
element.type = TextFieldType.email;
element.required = true;
element.value = "";

expect(element.validity.typeMismatch).to.equal(false);
});
it("should be a typeMismatch when value is not a valid email", async () => {
const { element, connect, disconnect } = await setup();
await connect();
element.type = TextFieldType.email;
element.required = true;
element.value = "foobar";

expect(element.validity.typeMismatch).to.equal(true);
});
});
describe('of [type="url"]', () => {
it("should be valid when value is an empty string", async () => {
const { element, connect, disconnect } = await setup();
await connect();
element.type = TextFieldType.url;
element.required = true;
element.value = "";

expect(element.validity.typeMismatch).to.equal(false);
});
it("should be a typeMismatch when value is not a valid URL", async () => {
const { element, connect, disconnect } = await setup();
await connect();
element.type = TextFieldType.url;
element.required = true;
element.value = "foobar";

expect(element.validity.typeMismatch).to.equal(true);
});
});
});
});