Skip to content

Commit

Permalink
feat(form elements): form association cleanup (#321)
Browse files Browse the repository at this point in the history
Handled possible memory leak with form reset event listener as well as a major refactor to the code
  • Loading branch information
YonatanKra authored Sep 22, 2020
1 parent a40c74b commit a4f89d3
Show file tree
Hide file tree
Showing 11 changed files with 360 additions and 174 deletions.
96 changes: 2 additions & 94 deletions common/foundation/src/form-association.ts
Original file line number Diff line number Diff line change
@@ -1,97 +1,5 @@
import { getFormByIdOrClosest } from './form-association/common';
export * from './form-association/associate-with-form';

const types = ['checkbox', 'textarea', 'input'];
export type HiddenInputType = typeof types;

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
);
});
}

export function requestSubmit(form: HTMLFormElement) {
if (form.requestSubmit) {
form.requestSubmit();
return;
}
const fakeButton = document.createElement('button');
fakeButton.style.display = 'none';
form.appendChild(fakeButton);
fakeButton.click();
fakeButton.remove();
}
export * from './form-association/request-submit';

export * from './form-association/submit-on-enter-key';
151 changes: 151 additions & 0 deletions common/foundation/src/form-association/associate-with-form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { getFormByIdOrClosest, InputElement } from './common';

const types = ['checkbox', 'textarea', 'input'];
export type HiddenInputType = typeof types;

class FormAssociationDisconnectionComponent extends HTMLElement {
disconnectedCallback() {
this.dispatchEvent(new Event('disconnected'));
}
}

window.customElements.define(
'form-association-disconnection',
FormAssociationDisconnectionComponent
);

function setHiddenInputInitialValuesAndStyle(
hiddenInput: HTMLInputElement,
{ name, value: initialValue }: InputElement
) {
hiddenInput.style.display = 'none';
name ? hiddenInput.setAttribute('name', name) : '';
hiddenInput.defaultValue = initialValue;
}

function appendHiddenInputToHostingForm(
hostingForm: HTMLFormElement,
hiddenType: HiddenInputType[number]
) {
const hiddenInput = document.createElement(hiddenType) as HTMLInputElement;
hostingForm.appendChild(hiddenInput);
return hiddenInput;
}

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

function resetFormFactory<T extends InputElement>(
inputElement: T,
internalFormElement: HTMLInputElement,
hiddenInput: HTMLInputElement
) {
return () => {
inputElement.value = internalFormElement.value =
hiddenInput?.defaultValue ?? '';
setInternalValueAndValidityInHiddenInput(
hiddenInput,
inputElement.value,
internalFormElement.validationMessage
);
};
}

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

function syncValueAndValidityOnChanges<T extends InputElement>(
inputElement: T,
internalFormElement: HTMLInputElement,
hiddenInput: HTMLInputElement
) {
const eventNames = ['input', 'change'];
eventNames.forEach((eventName) => {
inputElement.addEventListener(eventName, () => {
setInternalValueAndValidityInHiddenInput(
hiddenInput,
inputElement.value,
internalFormElement.validationMessage
);
});
});
}

function appendDisconnectionCleanupElement<T extends InputElement>(
inputElement: T,
disconnectionCallback: () => void
) {
const removeListenerElement = document.createElement(
'form-association-disconnection'
) as FormAssociationDisconnectionComponent;
removeListenerElement.addEventListener('disconnected', () => {
disconnectionCallback();
});
inputElement.appendChild(removeListenerElement);
}

function associateFormCleanupFactory(
hiddenInput: HTMLInputElement,
hostingForm: HTMLFormElement,
resetFormHandler: () => void
) {
return () => {
hiddenInput.remove();
hostingForm.removeEventListener('reset', resetFormHandler);
};
}

export function associateWithForm<T extends InputElement>(
inputElement: T,
internalFormElement: HTMLInputElement
): void {
const hostingForm = getFormByIdOrClosest(inputElement);

if (!hostingForm) {
return;
}

const hiddenInput = appendHiddenInputToHostingForm(
hostingForm,
internalFormElement.nodeName
);
setHiddenInputInitialValuesAndStyle(hiddenInput, inputElement);
suspendInvalidEvent(hiddenInput);

const resetFormHandler = resetFormFactory(
inputElement,
internalFormElement,
hiddenInput
);

const disconnectionCallback = associateFormCleanupFactory(
hiddenInput,
hostingForm,
resetFormHandler
);

setInternalValueAndValidityInHiddenInput(
hiddenInput,
inputElement.value,
internalFormElement.validationMessage
);

hostingForm.addEventListener('reset', resetFormHandler);

appendDisconnectionCleanupElement(inputElement, disconnectionCallback);

syncValueAndValidityOnChanges(inputElement, internalFormElement, hiddenInput);
}
9 changes: 7 additions & 2 deletions common/foundation/src/form-association/common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
export function getFormByIdOrClosest(
element: HTMLElement
export interface InputElement extends HTMLElement {
name: string | undefined;
value: string;
}

export function getFormByIdOrClosest<T extends InputElement>(
element: T
): HTMLFormElement | null {
const formId = element.getAttribute('form');
const formElement = formId
Expand Down
11 changes: 11 additions & 0 deletions common/foundation/src/form-association/request-submit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function requestSubmit(form: HTMLFormElement): void {
if (form.requestSubmit) {
form.requestSubmit();
return;
}
const fakeButton = document.createElement('button');
fakeButton.style.display = 'none';
form.appendChild(fakeButton);
fakeButton.click();
fakeButton.remove();
}
Loading

0 comments on commit a4f89d3

Please sign in to comment.