Skip to content

Commit

Permalink
feat(textarea): textarea form association (#300)
Browse files Browse the repository at this point in the history
* Add the form association logic to foundation

* Update and start writing the field's tests

* Form association tests

* Allow to add other types of hidden elements

* Start implementing the tests

* Text area tests

* Improve textfield tests

* Remove "only" from textarea test

* Fixed deps

* Remove another "only"

* Linting fixes

* removing unneded code - to resolve lint issues

* fixing some implementation (partial fix) and tests

* Test cleanup

* Add protection to the API

Co-authored-by: Yuri Guller <gullerya@gmail.com>
  • Loading branch information
YonatanKra and gullerya committed Sep 8, 2020
1 parent 82496e9 commit 19ebd4e
Show file tree
Hide file tree
Showing 11 changed files with 476 additions and 137 deletions.
20 changes: 20 additions & 0 deletions __snapshots__/vwc-textfield.md
@@ -0,0 +1,20 @@
# `vwc-textfield`

#### `should have internal contents`

```html
<label class="mdc-text-field mdc-text-field--filled mdc-text-field--no-label">
<span class="mdc-text-field__ripple">
</span>
<input
aria-labelledby="label"
class="mdc-text-field__input"
placeholder=""
type="text"
>
<span class="mdc-line-ripple">
</span>
</label>

```

55 changes: 55 additions & 0 deletions common/foundation/src/form-association.ts
@@ -0,0 +1,55 @@
const types = ['checkbox', 'textarea', 'input'];
export type HiddenInputType = typeof types;

function getFormByIdOrClosest(element: HTMLElement): HTMLFormElement | null {
const formId = element.getAttribute('form');
const formElement = formId ? document.getElementById(formId) : element.closest('form');
return formElement instanceof HTMLFormElement ? formElement : null;
}

function addHiddenInput(hostingForm: HTMLElement, { name, value }: { name: string, value: string }, hiddenType: HiddenInputType[number]) {
const hiddenInput = document.createElement(hiddenType) as HTMLInputElement;
hiddenInput.style.display = 'none';
hiddenInput.setAttribute('name', name);
hiddenInput.defaultValue = value;
hostingForm.appendChild(hiddenInput);

return hiddenInput;
}

function setValueAndValidity(inputField: HTMLInputElement | undefined, value: string, validationMessage = '') {
if (!inputField) {
return;
}
inputField.value = value;
inputField.setCustomValidity(validationMessage);
}

export function addInputToForm(inputElement: any, hiddenType: HiddenInputType[number] = 'input'): void {
const hostingForm = getFormByIdOrClosest(inputElement);

if (!hostingForm || !inputElement) {
return;
}

inputElement.hiddenInput = addHiddenInput(hostingForm, inputElement, hiddenType);
setValueAndValidity(inputElement.hiddenInput, inputElement.value, inputElement.formElement.validationMessage);

hostingForm.addEventListener('reset', () => {
inputElement.value = inputElement.formElement.value = inputElement.hiddenInput?.defaultValue ?? '';
setValueAndValidity(inputElement.hiddenInput, inputElement.value, inputElement.formElement.validationMessage);
});

inputElement.hiddenInput.addEventListener('invalid', (event: Event) => {
event.stopPropagation();
event.preventDefault();
});

inputElement.addEventListener('change', () => {
setValueAndValidity(inputElement.hiddenInput, inputElement.value, inputElement.formElement.validationMessage);
});

inputElement.addEventListener('input', () => {
setValueAndValidity(inputElement.hiddenInput, inputElement.value, inputElement.formElement.validationMessage);
});
}
148 changes: 148 additions & 0 deletions common/foundation/test/form-association.test.js
@@ -0,0 +1,148 @@
import { addInputToForm } from '../form-association';
import { textToDomToParent, randomAlpha } from '../../../test/test-helpers';

describe(`Form Association Foundation`, function () {
let addedElements = [];

afterEach(function () {
addedElements.forEach(elm => elm.remove());
});

describe(`addInputToForm`, function () {
let originalSetCustomValidity;
beforeEach(function() {
originalSetCustomValidity = HTMLElement.prototype.setCustomValidity;
HTMLElement.prototype.setCustomValidity = function () {
return 5;
};
});

afterEach(() => {
HTMLElement.prototype.setCustomValidity = originalSetCustomValidity;
});

it(`should attach a hidden input to form with given name`, function () {
const fieldName = 'fieldName';
addedElements = textToDomToParent(`<form><div><input></input></div></form>`);
const formElement = addedElements[0];
const inputElementWrapper = formElement.children[0];
inputElementWrapper.name = fieldName;
inputElementWrapper.formElement = inputElementWrapper.querySelector('input');

const numberOfNamedInputsBefore = formElement.querySelectorAll(`input[name="${fieldName}]"`).length;

addInputToForm(inputElementWrapper);
expect(numberOfNamedInputsBefore).to.equal(0);
expect(formElement.querySelectorAll(`input[name="${fieldName}"]`).length).to.equal(1);
});

it(`should attach a hidden input to form with given id`, function () {
const otherFormId = randomAlpha();
addedElements = textToDomToParent(`
<form><div><input></input></div></form>
<form id="${otherFormId}"></form>
`);
const formElement = addedElements[0];
const otherForm = addedElements[1];

const inputElementWrapper = formElement.children[0];
inputElementWrapper.setAttribute('form', otherFormId);
inputElementWrapper.formElement = inputElementWrapper.querySelector('input');

addInputToForm(inputElementWrapper);

expect(formElement.querySelectorAll('input').length).to.equal(1);
expect(otherForm.querySelectorAll('input').length).to.equal(1);
});

it(`should attach to no form if given form id is not found`, function () {
const otherFormId = randomAlpha();
const fieldName = 'fieldName';
addedElements = textToDomToParent(`<form><div><input></input></div></form><form id="${otherFormId}"></form>`);
const formElement = addedElements[0];
const inputElementWrapper = formElement.children[0];
inputElementWrapper.setAttribute('form', 'someOtherFormId');
inputElementWrapper.name = fieldName;

inputElementWrapper.formElement = inputElementWrapper.querySelector('input');

addInputToForm(inputElementWrapper);

expect(document.querySelectorAll(`input[name="${fieldName}"]`).length).to.equal(0);
});

it(`should reset value of the internal input, the wrapper and the hidden input on form reset`, function () {
const otherFormId = randomAlpha();
const defaultValue = 'defaultValue';
const fieldName = 'fieldName';

addedElements = textToDomToParent(`<form><div><input></input></div></form><form id="${otherFormId}"></form>`);
const formElement = addedElements[0];
const otherForm = addedElements[1];

const inputElementWrapper = formElement.children[0];
inputElementWrapper.setAttribute('form', otherFormId);
inputElementWrapper.value = defaultValue;
inputElementWrapper.name = fieldName;
inputElementWrapper.formElement = inputElementWrapper.querySelector('input');

addInputToForm(inputElementWrapper);

const hiddenInput = document.querySelector(`input[name="${fieldName}"]`);

otherForm.reset();

expect(hiddenInput.value).to.equal(defaultValue);
expect(inputElementWrapper.formElement.value).to.equal(defaultValue)
expect(inputElementWrapper.value).to.equal(defaultValue)
});

it(`should set the validity and value of the hidden input according to the internal input`, function () {
const otherFormId = randomAlpha();
const validValue = 'defaultValue';
const invalidValue = 'defaultValue';
const fieldName = 'fieldName';

addedElements = textToDomToParent(`<form><div required><input required></input></div></form><form id="${otherFormId}"></form>`);
const formElement = addedElements[0];

const inputElementWrapper = formElement.children[0];
inputElementWrapper.form = otherFormId;
inputElementWrapper.formElement = inputElementWrapper.querySelector('input');
inputElementWrapper.name = fieldName;

addInputToForm(inputElementWrapper);

const hiddenInput = document.querySelector(`input[name="${fieldName}"]`);

const values = [validValue, invalidValue];
const events = ['input', 'change'];

events.forEach(eventName => {
const inputEvent = new Event(eventName);
values.forEach(inputValue => {
inputElementWrapper.value = inputElementWrapper.formElement.value = inputValue;
inputElementWrapper.dispatchEvent(inputEvent);
expect(hiddenInput.value, `${eventName} was unable to match values`).to.equal(inputElementWrapper.formElement.value);
expect(hiddenInput.validationMessage, `${eventName} was unable to match validation messages`).to.equal(inputElementWrapper.formElement.validationMessage);
});
});
});

it(`should add custom hidden element`, function () {
const hiddenElementType = 'DIGGERING';
const fieldName = 'inputName';

addedElements = textToDomToParent(`<form><div><input></input></div></form>`);
const formElement = addedElements[0];

const inputElementWrapper = formElement.children[0];
inputElementWrapper.formElement = inputElementWrapper.querySelector('input');
inputElementWrapper.name = fieldName;

addInputToForm(inputElementWrapper, hiddenElementType);

expect(formElement.querySelector(`[name="${fieldName}"]`).tagName).to.equal(hiddenElementType);
});
});
});
2 changes: 1 addition & 1 deletion components/select/package.json
Expand Up @@ -26,14 +26,14 @@
},
"dependencies": {
"@material/mwc-select": "^0.18.0",
"@vonage/vvd-foundation": "^0.5.0",
"@vonage/vvd-style-coupling": "^0.5.0",
"@vonage/vwc-icon": "^0.5.0",
"@vonage/vwc-notched-outline": "^0.5.0",
"lit-element": "^2.4.0",
"tslib": "^2.0.1"
},
"devDependencies": {
"@vonage/vvd-foundation": "^0.5.0",
"@vonage/vvd-typography": "^0.5.0"
}
}
59 changes: 2 additions & 57 deletions components/select/src/vwc-select.ts
Expand Up @@ -5,6 +5,7 @@ import { Select as MWCSelect } from '@material/mwc-select';
import { style as styleCoupling } from '@vonage/vvd-style-coupling/vvd-style-coupling.css.js';
import { style as vwcSelectStyle } from './vwc-select.css';
import { style as mwcSelectStyle } from '@material/mwc-select/mwc-select-css.js';
import { addInputToForm } from '@vonage/vvd-foundation/form-association';

declare global {
interface HTMLElementTagNameMap {
Expand All @@ -16,32 +17,6 @@ declare global {
// @ts-ignore
MWCSelect.styles = [styleCoupling, mwcSelectStyle, vwcSelectStyle];

function getFormByIdOrClosest(element: VWCSelect): HTMLFormElement | null {
const formId = element.form;
const formElement = formId ? document.getElementById(formId) : element.closest('form');
return formElement instanceof HTMLFormElement ? formElement : null;
}

function addHiddenInput(hostingForm: HTMLElement, { name, value }: { name: string | undefined, value: string }) {
const hiddenInput = document.createElement('input');
hiddenInput.style.display = 'none';
if (name !== undefined) {
hiddenInput.setAttribute('name', name);
}
hiddenInput.defaultValue = value;
hostingForm.appendChild(hiddenInput);

return hiddenInput;
}

function setValueAndValidity(inputField: HTMLInputElement | undefined, value: string, validationMessage = '') {
if (!inputField) {
return;
}
inputField.value = value;
inputField.setCustomValidity(validationMessage);
}

/**
* This component is an extension of [<mwc-select>](https://github.com/material-components/material-components-web-components/tree/master/packages/select)
*/
Expand All @@ -60,7 +35,7 @@ export class VWCSelect extends MWCSelect {
await super.firstUpdated();
this.shadowRoot?.querySelector('.mdc-notched-outline')?.shadowRoot?.querySelector('.mdc-notched-outline')?.classList.add('vvd-notch');
this.replaceIcon();
this.addSelectToForm();
addInputToForm(this);
}

private replaceIcon(): void {
Expand All @@ -70,34 +45,4 @@ export class VWCSelect extends MWCSelect {
chevronIcon.setAttribute('type', 'down');
this.shadowRoot?.querySelector(`.${ddIconClass}`)?.replaceWith(chevronIcon);
}

protected addSelectToForm(): void {
const hostingForm = getFormByIdOrClosest(this);

if (!hostingForm) {
return;
}

this.hiddenInput = addHiddenInput(hostingForm, this);
setValueAndValidity(this.hiddenInput, this.value, this.formElement.validationMessage);

hostingForm.addEventListener('reset', () => {
this.value = this.formElement.value = this.hiddenInput?.defaultValue ?? '';
setValueAndValidity(this.hiddenInput, this.value, this.formElement.validationMessage);
});

this.hiddenInput.addEventListener('invalid', (event) => {
event.stopPropagation();
event.preventDefault();
});

this.addEventListener('change', () => {
setValueAndValidity(this.hiddenInput, this.value, this.formElement.validationMessage);
});

this.addEventListener('input', () => {
setValueAndValidity(this.hiddenInput, this.value, this.formElement.validationMessage);
});

}
}
1 change: 1 addition & 0 deletions components/textarea/package.json
Expand Up @@ -25,6 +25,7 @@
"dependencies": {
"@material/mwc-textarea": "^0.18.0",
"@material/mwc-textfield": "^0.18.0",
"@vonage/vvd-foundation": "^0.5.0",
"@vonage/vvd-style-coupling": "^0.5.0",
"@vonage/vwc-notched-outline": "^0.5.0",
"lit-element": "^2.4.0",
Expand Down
7 changes: 6 additions & 1 deletion components/textarea/src/vwc-textarea.ts
@@ -1,9 +1,10 @@
import { customElement } from 'lit-element';
import { customElement, property } from 'lit-element';
import '@vonage/vwc-notched-outline';
import { TextArea as MWCTextArea } from '@material/mwc-textarea';
import { style as styleCoupling } from '@vonage/vvd-style-coupling/vvd-style-coupling.css.js';
import { style as vwcTextareaStyle } from './vwc-textarea.css';
import { style as mwcTextareaStyle } from '@material/mwc-textarea/mwc-textarea-css.js';
import { addInputToForm } from '@vonage/vvd-foundation/form-association';

export { TextFieldType } from '@material/mwc-textfield';

Expand All @@ -22,8 +23,12 @@ MWCTextArea.styles = [styleCoupling, mwcTextareaStyle, vwcTextareaStyle];
*/
@customElement('vwc-textarea')
export class VWCTextArea extends MWCTextArea {
@property({ type: String, reflect: true })
form: string | undefined;

async firstUpdated(): Promise<void> {
await super.firstUpdated();
this.shadowRoot?.querySelector('.mdc-notched-outline')?.shadowRoot?.querySelector('.mdc-notched-outline')?.classList.add('vvd-notch');
addInputToForm(this, 'textarea');
}
}

0 comments on commit 19ebd4e

Please sign in to comment.