Skip to content

Commit

Permalink
Merge pull request #344 from centrica-engineering/feature/form
Browse files Browse the repository at this point in the history
  • Loading branch information
jholt1 authored Jun 14, 2022
2 parents 176df8f + 6ea4d27 commit 5b1fe65
Show file tree
Hide file tree
Showing 12 changed files with 870 additions and 11 deletions.
12 changes: 12 additions & 0 deletions packages/muon/components/cta/src/cta-component.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,16 @@ export class Cta extends ScopedElementsMixin(MuonElement) {
${this._wrapperElement(internal)}
`;
}

get submitTemplate() {
this.setAttribute('type', 'submit');

return this.standardTemplate;
}

get resetTemplate() {
this.setAttribute('type', 'reset');

return this.standardTemplate;
}
}
1 change: 1 addition & 0 deletions packages/muon/components/form/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Form } from './src/form-component.js';
186 changes: 186 additions & 0 deletions packages/muon/components/form/src/form-component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { html, MuonElement } from '@muonic/muon';
import scrollTo from '@muon/utils/scroll';

/**
* A form.
*
* @element form
*/

export class Form extends MuonElement {

constructor() {
super();
this._submit = this._submit.bind(this);
this._reset = this._reset.bind(this);
}

connectedCallback() {
super.connectedCallback();

queueMicrotask(() => {
this.__checkForFormEl();
if (this._nativeForm) {
this.__registerEvents();
// hack to stop browser validation pop up
this._nativeForm.setAttribute('novalidate', true);
// hack to force implicit submission (https://github.com/WICG/webcomponents/issues/187)
const input = document.createElement('input');
input.type = 'submit';
input.hidden = true;
this._nativeForm.appendChild(input);
}
});
}

disconnectedCallback() {
super.disconnectedCallback();
this.__teardownEvents();
}

__registerEvents() {
this._nativeForm?.addEventListener('submit', this._submit);
this._submitButton?.addEventListener('click', this._submit);
this._nativeForm?.addEventListener('reset', this._reset);
}

__teardownEvents() {
this._nativeForm?.removeEventListener('submit', this._submit);
this._submitButton?.removeEventListener('click', this._submit);
this._nativeForm?.removeEventListener('reset', this._reset);
}

__checkForFormEl() {
if (!this._nativeForm) {
throw new Error(
'No form node found. Did you put a <form> element inside?'
);
}
}

_reset() {
this.__checkForFormEl();

if (
!this._resetButton.disabled ||
!this._resetButton.loading
) {
this._nativeForm.reset();
}
}

_submit(event) {
event.preventDefault();
event.stopPropagation();

this.__checkForFormEl();

if (
!this._submitButton ||
this._submitButton.disabled ||
this._submitButton.loading
) {
return undefined; // should this be false?
}

const validity = this.validate();

if (validity.isValid) {
this.dispatchEvent(new Event('submit', { cancelable: true }));
} else {
const invalidElements = validity.validationStates.filter((state) => {
return !state.isValid;
});

scrollTo({ element: invalidElements[0].formElement });
}

return validity.isValid;
}

get _nativeForm() {
return this.querySelector('form');
}

get _submitButton() {
return this.querySelector('button:not([hidden])[type="submit"]') ||
this.querySelector('input:not([hidden])[type="submit"]') ||
this.querySelector('*:not([hidden])[type="submit"]');
}

get _resetButton() {
return this.querySelector('button:not([hidden])[type="reset"]') ||
this.querySelector('input:not([hidden])[type="reset"]') ||
this.querySelector('*:not([hidden])[type="reset"]');
}

_findInputElement(element) {
if (element.parentElement._inputElement) {
return element.parentElement;
}
// Due to any layout container elements - @TODO - need better logic
if (element.parentElement.parentElement._inputElement) {
return element.parentElement.parentElement;
}

return element;
}

validate() {
let isValid = true;
// @TODO: Check how this works with form associated
const validationStates = Array.from(this._nativeForm.elements).reduce((acc, element) => {
element = this._findInputElement(element);
const { name } = element;
const hasBeenSet = acc.filter((el) => el.name === name).length > 0;

// For checkboxes and radio button - don't set multiple times (needs checking for native inputs)
// Ignore buttons (including hidden reset)
if (
hasBeenSet ||
element === this._submitButton ||
element === this._resetButton ||
element.type === 'submit'
) {
return acc;
}

if (element.reportValidity) {
element.reportValidity();
}

const { validity } = element;

if (validity) {
const { value } = element;
const { valid, validationMessage } = validity;

isValid = Boolean(isValid & validity.valid);

acc.push({
name,
value,
isValid: valid,
error: validationMessage,
validity: validity,
formElement: element
});
}

return acc;
}, []);

return {
isValid,
validationStates
};
}

get standardTemplate() {
return html`
<div class="form">
<slot></slot>
</div>
`;
}
}
35 changes: 35 additions & 0 deletions packages/muon/components/form/story.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Form } from '@muonic/muon/components/form';
import setup from '@muonic/muon/storybook/stories';

const details = setup('form', Form);

export default details.defaultValues;

const innerDetail = () => `
<form>
<muon-inputter helper="Useful information to help populate this field." validation='["isRequired"]' name="username">
<label slot="label">Name</label>
<input type="text" placeholder="e.g. Placeholder" name="username"/>
</muon-inputter>
<muon-inputter value="" helper="How can we help you?" validation="[&quot;isRequired&quot;,&quot;isEmail&quot;]" autocomplete="email">
<label slot="label">Email</label>
<input type="email" placeholder="e.g. [email protected]" autocomplete="email" name="useremail">
<div slot="tip-details">By providing clarification on why this information is necessary.</div>
</muon-inputter>
<label for="user-id">User ID<label>
<input type="text" id="user-id" name="user-id" required/>
<muon-inputter heading="What options do you like?" helper="How can we help you?" validation='["isRequired"]' value="b">
<input type="checkbox" name="checkboxes" value="a" id="check-01">
<label for="check-01">Option A</label>
<input type="checkbox" name="checkboxes" value="b" id="check-02">
<label for="check-02">Option B</label>
<div slot="tip-details">By providing clarification on why this information is necessary.</div>
</muon-inputter>
<input type="reset" />
<muon-cta type="submit">Submit</muon-cta>
<form>`;

export const Standard = (args) => details.template(args, innerDetail);
36 changes: 36 additions & 0 deletions packages/muon/mixins/form-associate-mixin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { dedupeMixin } from '@muonic/muon';

/**
* A mixin to associate the component to the enclosing native form.
*
* @mixin FormElementMixin
*/

export const FormAssociateMixin = dedupeMixin((superClass) =>
class FormAssociateMixinClass extends superClass {

static get properties() {
return {
_internals: {
type: Object,
state: true
}
};
}

static get formAssociated() {
return true;
}

constructor() {
super();
this._internals = this.attachInternals();
}

updated(changedProperties) {
if (changedProperties.has('value')) {
this._internals.setFormValue(this.value);
}
}
}
);
33 changes: 29 additions & 4 deletions packages/muon/mixins/form-element-mixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ export const FormElementMixin = dedupeMixin((superClass) =>
static get properties() {
return {
name: {
type: String
type: String,
reflect: true
},

value: {
Expand All @@ -28,6 +29,11 @@ export const FormElementMixin = dedupeMixin((superClass) =>
type: String
},

_inputElement: {
type: Boolean,
state: true
},

_id: {
type: String,
state: true
Expand Down Expand Up @@ -55,6 +61,7 @@ export const FormElementMixin = dedupeMixin((superClass) =>
this.value = '';
this.labelID = '';
this.heading = '';
this._inputElement = true;
this._id = `${this._randomId}-input`;
}

Expand Down Expand Up @@ -90,6 +97,9 @@ export const FormElementMixin = dedupeMixin((superClass) =>

firstUpdated() {
super.firstUpdated();
if (!this.name) {
this.name = this._slottedInputs?.[0]?.name ?? '';
}
if (!this._isMultiple) {
if (this.labelID?.length > 0) {
this._slottedInputs.forEach((slot) => {
Expand All @@ -101,7 +111,7 @@ export const FormElementMixin = dedupeMixin((superClass) =>
this._slottedLabel?.setAttribute('for', this._id);
}
}
this.__syncValue();
this.__syncValue(true);

this._boundChangeEvent = (changeEvent) => {
this._onChange(changeEvent);
Expand All @@ -114,20 +124,28 @@ export const FormElementMixin = dedupeMixin((superClass) =>
this._boundInputEvent = (inputEvent) => {
this._onInput(inputEvent);
};

this._slottedInputs.forEach((input) => {
input.addEventListener('change', this._boundChangeEvent);
input.addEventListener('blur', this._boundBlurEvent);
input.addEventListener('input', this._boundInputEvent);
});
}

focus() {
this.updateComplete.then(() => {
this._slottedInputs[0].focus();
});
}

/**
* A method to sync the value property of the component with value of slotted input elements.
*
* @returns { void }
* @param {boolean} firstSync - If first time syncing values.
* @returns {void}
* @private
*/
__syncValue() {
__syncValue(firstSync) {
if (this._isMultiple) { //Check when component has slotted multi-input
if (!this.value && this.__checkedInput) {
// If component has null value and slotted input has checked value(s),
Expand All @@ -141,6 +159,9 @@ export const FormElementMixin = dedupeMixin((superClass) =>
return values.includes(input.value) && !input.checked;
}).forEach((input) => {
input.checked = true;
if (firstSync) {
input.defaultChecked = true;
}
});
}
} else { //When component has single-input slot
Expand All @@ -153,6 +174,10 @@ export const FormElementMixin = dedupeMixin((superClass) =>
// If component has not null value and slotted input has null value,
// assign the value of the component to value of the slotted input.
this._slottedInputs[0].value = this.value;

if (firstSync) {
this._slottedInputs[0].defaultValue = this.value;
}
}
}
}
Expand Down
Loading

0 comments on commit 5b1fe65

Please sign in to comment.