Skip to content

Latest commit

 

History

History
480 lines (353 loc) · 18.5 KB

File metadata and controls

480 lines (353 loc) · 18.5 KB

@open-wc/form-control

A standardized mixin for creating form-associated custom elements using a standardized validation function pattern.

Install

# npm
npm install @open-wc/form-control

# yarn
yarn add @open-wc/form-control

Usage

After importing, create a web component class that extends the mixin, and provide your desired base class as the input to FormControlMixin.

Public API

The FormControlMixin adds several methods to the element's prototype

setValue

setValue(value: FormData) => void

The setValue method takes an argument of FormValue which is equal to string | FormData | FileData | null. This the value passed into this method will be attached to the element's parent form using the element's name attribute.

A common use case for this would look something like the following

import { FormControlMixin } from '@open-wc/form-control';

class CustomFormControl extends FormControlMixin(HTMLElement) {
  private _value: string;

  set value(newValue: string) {
    this._value = newValue;
    this.setValue(newValue);
  }

  get value(): string {
    return this._value;
  }
}

The above example—using HTMLElement as the mixed class—will now respond to changes to the element's value property by attaching the element's value to its associated form.

Using LitElement the above example might look like:

import { LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { FormControlMixin } from '@open-wc/form-control';

export class CustomControlLit extends FormControlMixin(LitElement) {
  @property()
  value: string = '';

  updated(changedProperties: Map<string, unknown>): void {
    if (changedProperties.has('value')) {
      this.setValue(this.value);
    }
  }
}

shouldFormValueUpdate

shouldFormValueUpdate() => boolean

The shouldFormValueUpdate method is called internally before a call to ElementInternals.prototype.setFormValue. If the method returns true the value passed ot setValue will be added to the form; otherwise an empty value will be passed. This is useful for emulating behavior like that of a radio or checkbox.

import { FormControlMixin } from '@open-wc/form-control';

export class CustomControl extends FormControlMixin(HTMLElement) {
  _checked = false;
  _value = '';

  set checked(newChecked: boolean) {
    this._checked = newChecked;
    this.setValue(this.value);
  }

  get checked(): boolean {
    return this._checked;
  }

  set value(newValue: string) {
    this._value = newValue;
    this.setValue(newValue);
  }

  get value(): string {
    return this._value;
  }

  shouldFormValueUpdate(): boolean {
    return this.checked;
  }
}

For LitElement this example might look like

import { LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import { FormControlMixin } from '@open-wc/form-control';

export class CustomControlWithLit extends FormControlMixin(LitElement) {
  @property({ type: Boolean })
  checked = false;

  @property()
  value = '';

  shouldFormValueUpdate(): boolean {
    return this.checked;
  }

  updated(changedValues: Map<string, unknown>): void {
    if (changedProperties.has('checked') || changedProperties.has('value')) {
      this.setValue(this.value);
    }
  }
}

Both of the above examples will update the element's form value when either the checked or value properties are set.

resetFormControl

resetFormControl() => void

The resetFormControl lifecycle method is called when a control's form is reset either via a button or the HTMLFormElement.prototype.reset method. This is a place to clean up the form control's values and settings. For example, if creating a custom checkbox or radio button, you might use this method to restore the element's checked state.

import { FormControlMixin } from '@open-wc/form-control';

export class CustomControl extends FormControlMixin(HTMLElement) {
  /** Built on from the exampels in setValue and shouldFormValueUpdate */

  resetFormControl(): void {
    this.checked = this.hasAttribute('checked');
  }
}

validityCallback

validityCallback(key: keyof ValidityState) => string | void

The validityCallback is used to override the controls' validity message for a given Validator key. This has the highest level of priority when setting a validationMessage, so use this method wisely.

To use this method you must also call the the FormControlMixin's validation API. The following example will be the same for both HTMLElement and LitElement and assumes the built-in requiredValidator is used.

import { FormControlMixin, requiredValidator } from '@open-wc/form-control';

export class CustomControl extends FormControlMixin(HTMLElement) {
  static formControlValidators = [requiredValidator];

  validityCallback(key: keyof ValidityState): boolean {
    if (key === 'valueMissing') {
      return 'This is a custom error message for valueMissing errors';
    }
  }
}

validationTarget

The validationTarget is required when using the validation API should be an element inside the custom element's shadow root that is capable of receiving focus. Per the DOM spec (and accessibility best practices) the first validation target in source order will receive focus whenever a form is submitted or the element's requestValidity method is called.

In the event a control becomes invalid, this item will be focused on form submit for accessibility purposes. Failure to do so will cause an error to throw.

This can be a getter or a property:

import { FormControlMixin, requiredValidator } from '@open-wc/form-control';

const template = `<label for="input"><slot></slot></label>
<input id="input">`;

export class CustomControl extends FormControlMixin(HTMLElement) {
  static formControlValidators = [requiredValidator];

  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    root.append(template.contents.cloneNode(true));
  }

  get validationTarget(): HTMLInputElment {
    return this.shadowRoot.querySelector<HTMLInputElement>('input');
  }
}

or in Lit you will likely want to use the query decorator

import { html, LitElement, TemplateResult } from 'lit';
import { query } from 'lit/decorators.js';
import { FormControlMixin, requiredValidator } from '@open-wc/form-control';

export class CustomControl extends FormControlMixin(LitElement) {
  static formControlValidators = [requiredValidator];

  @query('input')
  validationTarget: HTMLInputElement;

  render(): TemplateResult {
    return html`<label for="input"><slot></slot></label>
    <input type="text" id="input">`;
  }
}

validationMessageCallback

validationMessageCallback(validitionMessage: string): void

The validationMessageCallback is an opinionated method that is called when the form control mixin believes a displayed validation message should be changed. This will will clear the message on the host focus and on value changes but will re-introduce the message whenever the element becomes blurred again.

import { FormControlMixin, requiredValidator } from '@open-wc/form-control';

export class CustomControl extends FormControlMixin(HTMLElement) {
  static formControlValidators = [requiredValidator];

  constructor() {
    super();
    const root = this.attachShadow({ mode: 'open' });
    const span = document.createElement('span');
    root.append(span);
  }

  validationMessageCallback(message: string): void {
    this.shadowRoot.querySelector('span').innerText = message;
  }
}

This is a partial example but would attach a message to a span element whenever the mixin assumes it should be attached. This might not meet all use cases. Other access to the validationMessage can be accessed directly on this.internals.validationMessage.

A more complete example in Lit might look something like

import { css, html, LitElement, TemplateResult } from 'lit';
import { property, query } from 'lit/decorators.js';
import { live } from 'lit/directives/live.js';
import { FormControlMixin, requiredValidator } from '@open-wc/form-control';

export class CustomControl extends FormControlMixin(LitElement) {
  static formControlValidator = [requiredValidator];
  static styles = css`
    /** Custom styles here potentially for a design system */
  `;

  @property({ type: Boolean, reflect: true })
  required = false;

  @property()
  value = '';

  @property()
  validationMessage = '';

  @query('input')
  validationTarget: HTMLInputElement;

  render(): TemplateResult {
    return html`
    <label for="input"><slot></slot></label>
    <input
      aria-describedby="helper-text"
      id="input"
      type="text"
      .required="${live(this.required)}"
      .value="${live(this.value)}"
      @input="${this._onChange}"
    >
    <span id="helper-text">${this.validationMessage}<span>`;
  }

  validationMessageCallback(message: string): void {
    this.validationMessage = message;
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (changedProperties.has('value')) {
      this.setValue(this.value);
    }
  }

  private _onChange(event: Event & { target: HTMLInputElement}): void {
    this.value = event.target.value;
  }
}

ElementInternals

This library makes use of ElementInternals features. As of the time of writing ElementInternals features are fully supported in Chrome, partially supported in Firefox and being strongly considered by Webkit.

In order to make these features work in all browsers you will need to include the element-internals-polyfill. Refer to the element-internals-polyfill documentation for installation and usage instructions.

Validation

The FormControlMixin includes an API for constraint validations and a set of common validators for validity states like required, minlength, maxlength and pattern.

import { LitElement, html } from 'lit';
import { customElement, query, property } from 'lit/decorators.js'
import { live } from 'lit/directives/live.js';

import { FormControlMixin, requiredValidator } from '@open-wc/form-control';

@customElement('demo-form-control')
class DemoFormControl extends FormControlMixin(LitElement) {
  static formControlValidators = [requiredValidator];

  @property({ type: Boolean, reflect: true })
  required = false;

  @property({ type: String })
  value = '';

  render() {
    return html`
      <label for="input"><slot></slot></label>
      <input
        id="input"
        .value="${live(this.value)}"
        @input="${this.#onInput}"
      >
    `;
  }

  updated(changedProperties: Map<string, unknown>): void {
    if (changedProperties.has('value')) {
      this.setValue(this.value);
    }
  }

  #onInput({ target }: { target: HTMLInputElement }): void {
    this.value = target.value;
  }
}

Including the requiredValidator adds a validation function attached to the valueMissing validity state to the component instance.

Note, this does require the element's prototype to actually have a required property defined.

Validators

This package contains a few standardized validators, though more could be added for various unconsidered use cases. So far, there are validators for:

  • required (valueMissing) : fails when the element's value is falsy while the element's required property equals true
  • minlength (rangeUnderflow) : fails if the length of the element's value is less than the defined minLength
  • maxlength (rangeOverflow) : fails if the length of the element's value is greater than the defined maxLength
  • programmatic (customError) : Allows setting a completely custom error state and message as a string.

If you have an idea for another standardized validator, please Submit an issue (preferred so that we can discuss) or Send a PR with your ideas.

Creating a custom validator

It is possible to create a custom validator object using the Validator interface:

export interface Validator {
  attribute?: string;
  key?: string;
  message: string | ((instance: any, value: any) => string);
  isValid(instance: HTMLElement, value: any): boolean;
}
Property Type Required Description
attribute string true If defined, adds the specified attribute to the element's observedAttributes and the validator will run when the provided attribute changed
key string - String name of one of the fields in the ValidityState object to override on validator change. If key is not set, it is assumed to be customError.
message string | ((instance: any, value: any) => string) true When set to a string, the message will equal the string passed in. If set to a function, the validation message will be the returned value from the callback. The message callback takes two arguments, the element instance and the control's form value (not the element's value property)
isValid (instance: any, value: any) => boolean true When isValid returns true, the validator is considered to be in a valid state. When the isValid callback returns false the validator is considered to be in an invalid state.

Example custom validator

So, a validator that would key off an error attribute to attach a programatic validation to an input might look like this:

export const programaticValidator: Validator = {
  attribute: 'error',
  message(instance: HTMLElement & { error: string }): string {
    return instance.error;
  },
  isValid(instance: HTMLElement & { error: string }): boolean {
    return !instance.error;
  }
};

Validators come in two varieties: synchronous and asynchronous. The most common pattern for validators are synchronous. This means that the isValid method directly returns the validity of the object in real time. Asynchronous validators, on the other hand return a Promise<boolean|void> indicating the validity state of the validator where a Promise<void> value has no effect on the validity status.

Because its possible for an asynchronous validator to run multiple times in rapid succession, the Validator.isValid method provides a third argument called an abort signal. This signal will be aborted during the next validatation run. This allows developers to respond to frequent validation requests and cancel any long-running validatons.

Let's look at an naive example of an async validator:

import { AsyncValidator, FormValue } from '@open-wc/form-control';

const sleepValidator: AsyncValidator = {
  message: 'Hello world',
  isValid(instance: AsyncValidatorDemo, value: FormValue, signal: AbortSignal): Promise<boolean|void> {
    if (signal.aborted) {
      return Promise<void>.resolve();
    }

    return new Promise((resolve) => {
      const id = setTimeout(() => {
        resolve(value === 'foo');
      }, 2000);

      signal.addEventListener('abort', () => {
        clearTimeout(id);
        console.log(`abort for value ${value}`);
        resolve();
      });
    });
  }
}

Here we can see the isValid method returns a Promise object that evaluates the value and returns sets the control to valid if and only if the value is exactly equal to the string 'foo'. This validator creates some asynchronous behavior by utilizing the setTimeout function which will wait at minimum two seconds before finally validating the control.

When the signal dispatches an abort event, the validator cancels its the timeout, logs some information to the developer about the validator not running for the current value and resolves. This will only happen when the validation cycle is kicked off again before all validators in the chain finish.

For more information on AbortController and AbortSignal, see this post from @samthor titled AbortController is your friend.

Validating a control as a group

It is possible to evaluate the validity of a set of controls as a group (similar to a radio button) where if one control in the group doesn't meet some criteria the validation fails. To enable this behavior, you need to set the components static property formControlValidationGroup to true. The following example emulates how the native required property interacts with input[type="radio"].

import { FormControlMixin } from '@open-wc/form-control';
import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('fc-radio')
class FcRadio extends FormControlMixin(LitElement) {
  /** Enable group validation behavior */
  static formControlValidationGroup = true;

  /** Custom validator logic */
  static formControlValidators = [
    {
      attribute: 'required',
      key: 'valueMissing',
      message: 'Please select an item', 
      isValid(instance, value) {
        const rootNode = instance.getRootNode();
        const selector = `${instance.localName}[name="${instance.getAttribute('name')}"]`;
        const group = Array.from(rootNode.querySelectorAll(selector));
        const isChecked = group.some(instance => instance.checked);
        const isRequired = group.some(instance => instance.required);
        
        if (isRequired && !isChecked) {
          return false;
        }
        
        return true;
      }
    }
  ];
}