Skip to main content
Default Gray Amethyst

Form controls

Every Modes UI component makes use of a shadow DOM to encapsulate markup, styles, and behavior. One caveat of this approach is that native <form> elements do not recognize form controls located inside a shadow root.

Modes UI solves this problem by using the formdata event, which is available in all modern browsers. This means, when a form is submitted, Modes UI form controls will automatically append their values to the FormData object that’s used to submit the form. In most cases, things will “just work.” However, if you’re using a form serialization library, it might need to be adapted to recognize Modes UI form controls.

Form serialization

Serialization is just a fancy word for collecting form data. If you’re relying on standard form submissions, e.g. <form action="...">, you can probably skip this section. However, most modern apps use the Fetch API or a library such as axios to submit forms using JavaScript.

The FormData interface offers a standard way to serialize forms in the browser. You can create a FormData object from any <form> element like this.

const form = document.querySelector('form');
const data = new FormData(form);

// All form control data is available in a FormData object

However, some folks find FormData tricky to work with or they need to pass a JSON payload to their server. To accommodate this, Modes UI offers a serialization utility that gathers form data and returns a simple JavaScript object instead.

import { serialize } from '@metsooutotec/modes-web-components/dist/utilities/form.js';

const form = document.querySelector('form');
const data = serialize(form);

// All form control data is available in a plain object

This results in an object with name/value pairs that map to each form control. If more than one form control shares the same name, the values will be passed as an array, e.g. { name: ['value1', 'value2'] }.

Form control validation

Client-side validation can be enabled through the browser’s Constraint Validation API for Modes UI form controls. You can activate it using attributes such as required, pattern, minlength, and maxlength. Modes UI implements many of the same attributes as native form controls, but check each form control’s documentation for a list of all supported properties.

As the user interacts with a form control, its invalid attribute will reflect its validity based on its current value and the constraints that have been defined. When a form control is invalid, the containing form will not be submitted. Instead, the browser will show the user a relevant error message. If you don’t want to use client-side validation, you can suppress this behavior by adding novalidate to the surrounding <form> element.

All form controls support validation, but not all validation props are available for every component. Refer to a component’s documentation to see which validation props it supports.

Required fields

To make a field required, use the required prop. The form will not be submitted if a required form control is empty.


Birds Cats Dogs Other

Check me before submitting

Submit
<form class="input-validation-required">
  <mo-input name="name" label="Name" required></mo-input>
  <br />
  <mo-select label="Favorite Animal" clearable required>
    <mo-option value="birds">Birds</mo-option>
    <mo-option value="cats">Cats</mo-option>
    <mo-option value="dogs">Dogs</mo-option>
    <mo-option value="other">Other</mo-option>
  </mo-select>
  <br />
  <mo-textarea name="comment" label="Comment" required></mo-textarea>
  <br />
  <mo-checkbox required>Check me before submitting</mo-checkbox>
  <br /><br />
  <mo-button type="submit" variant="primary">Submit</mo-button>
</form>

<script type="module">
  const form = document.querySelector('.input-validation-required');

  // Wait for controls to be defined before attaching form listeners
  await Promise.all([
    customElements.whenDefined('mo-button'),
    customElements.whenDefined('mo-checkbox'),
    customElements.whenDefined('mo-input'),
    customElements.whenDefined('mo-option'),
    customElements.whenDefined('mo-select'),
    customElements.whenDefined('mo-textarea')
  ]).then(() => {
    form.addEventListener('submit', event => {
      event.preventDefault();
      alert('All fields are valid!');
    });
  });
</script>
import {
  MOButton,
  MOCheckbox,
  MOInput,
  MOMenuItem,
  MOSelect,
  MOTextarea
} from '@metsooutotec/modes-web-components/dist/react';

const App = () => {
  function handleSubmit(event) {
    event.preventDefault();
    alert('All fields are valid!');
  }

  return (
    <form onSubmit={handleSubmit}>
      <MOInput name="name" label="Name" required />
      <br />
      <MOSelect label="Favorite Animal" clearable required>
        <MOMenuItem value="birds">Birds</MOMenuItem>
        <MOMenuItem value="cats">Cats</MOMenuItem>
        <MOMenuItem value="dogs">Dogs</MOMenuItem>
        <MOMenuItem value="other">Other</MOMenuItem>
      </MOSelect>
      <br />
      <MOTextarea name="comment" label="Comment" required></MOTextarea>
      <br />
      <MOCheckbox required>Check me before submitting</MOCheckbox>
      <br />
      <br />
      <MOButton type="submit" variant="primary">
        Submit
      </MOButton>
    </form>
  );
};

Input patterns

To restrict a value to a specific pattern, use the pattern attribute. This example only allows the letters A-Z, so the form will not submit if a number or symbol is entered. This only works with <mo-input> elements.


Submit
<form class="input-validation-pattern">
  <mo-input name="letters" required label="Letters" pattern="[A-Za-z]+"></mo-input>
  <br />
  <mo-button type="submit" variant="primary">Submit</mo-button>
</form>

<script type="module">
  const form = document.querySelector('.input-validation-pattern');

  // Wait for controls to be defined before attaching form listeners
  await Promise.all([
    customElements.whenDefined('mo-button'),
    customElements.whenDefined('mo-input')
  ]).then(() => {
    form.addEventListener('submit', event => {
      event.preventDefault();
      alert('All fields are valid!');
    });
  });
</script>
import { MOButton, MOInput } from '@metsooutotec/modes-web-components/dist/react';

const App = () => {
  function handleSubmit(event) {
    event.preventDefault();
    alert('All fields are valid!');
  }

  return (
    <form onSubmit={handleSubmit}>
      <MOInput name="letters" required label="Letters" pattern="[A-Za-z]+" />
      <br />
      <MOButton type="submit" variant="primary">
        Submit
      </MOButton>
    </form>
  );
};

Input types

Some input types will automatically trigger constraints, such as email and url.



Submit
<form class="input-validation-type">
  <mo-input variant="email" label="Email" placeholder="you@example.com" required></mo-input>
  <br />
  <mo-input variant="url" label="URL" placeholder="https://example.com/" required></mo-input>
  <br />
  <mo-button type="submit" variant="primary">Submit</mo-button>
</form>

<script type="module">
  const form = document.querySelector('.input-validation-type');
  // Wait for controls to be defined before attaching form listeners
  await Promise.all([
    customElements.whenDefined('mo-button'),
    customElements.whenDefined('mo-input')
  ]).then(() => {
    form.addEventListener('submit', event => {
      event.preventDefault();
      alert('All fields are valid!');
    });
  });
</script>
import { MOButton, MOInput } from '@metsooutotec/modes-web-components/dist/react';

const App = () => {
  function handleSubmit(event) {
    event.preventDefault();
    alert('All fields are valid!');
  }

  return (
    <form onSubmit={handleSubmit}>
      <MOInput variant="email" label="Email" placeholder="you@example.com" required />
      <br />
      <MOInput variant="url" label="URL" placeholder="https://example.com/" required />
      <br />
      <MOButton type="submit" variant="primary">
        Submit
      </MOButton>
    </form>
  );
};

Custom error messages

To create a custom validation error, pass a non-empty string to the setCustomValidity() method. This will override any existing validation constraints. The form will not be submitted when a custom validity is set and the browser will show a validation error when the containing form is submitted. To make the input valid again, call setCustomValidity() again with an empty string.


Submit Reset
<form class="input-validation-custom">
  <mo-input label="Type modes-ui" required></mo-input>
  <br />
  <mo-button type="submit" variant="primary">Submit</mo-button>
  <mo-button type="reset" variant="default">Reset</mo-button>
</form>

<script type="module">
  const form = document.querySelector('.input-validation-custom');
  const input = form.querySelector('mo-input');

  // Wait for controls to be defined before attaching form listeners
  await Promise.all([customElements.whenDefined('mo-button'), customElements.whenDefined('mo-input')]).then(() => {
    form.addEventListener('submit', event => {
      event.preventDefault();
      alert('All fields are valid!');
    });

    input.addEventListener('mo-input', () => {
      if (input.value === 'modes-ui') {
        input.setCustomValidity('');
      } else {
        input.setCustomValidity("Hey, you're supposed to type 'modes-ui' before submitting this!");
      }
    });
  });
</script>
import { useRef, useState } from 'react';
import MOButton from '@metsooutotec/modes-web-components/dist/react/button';
import MOInput from '@metsooutotec/modes-web-components/dist/react/input';

const App = () => {
  const input = useRef(null);
  const [value, setValue] = useState('');

  function handleInput(event) {
    setValue(event.target.value);

    if (event.target.value === 'modes-ui') {
      input.current.setCustomValidity('');
    } else {
      input.current.setCustomValidity("Hey, you're supposed to type 'modes-ui' before submitting this!");
    }
  }

  function handleSubmit(event) {
    event.preventDefault();
    alert('All fields are valid!');
  }

  return (
    <form onSubmit={handleSubmit}>
      <MOInput ref={input} label="Type 'modes-ui'" required value={value} onMOInput={handleInput} />
      <br />
      <MOButton type="submit" variant="primary">
        Submit
      </MOButton>
    </form>
  );
};

Custom validation styles

Due to the many ways form controls are used, Modes doesn’t provide out of the box validation styles for form controls as part of its default theme. Instead, the following attributes will be applied to reflect a control’s validity as users interact with it. You can use them to create custom styles for any of the validation states you’re interested in.

  • data-required - the form control is required
  • data-optional - the form control is optional
  • data-invalid - the form control is currently invalid
  • data-valid - the form control is currently valid
  • data-user-invalid - the form control is currently invalid and the user has interacted with it
  • data-user-valid - the form control is currently valid and the user has interacted with it

These attributes map to the browser’s built-in pseudo classes for validation: :required, :optional, :invalid, :valid, and the proposed :user-invalid and :user-valid.

Styling invalid form controls

You can target validity using any of the aforementioned data attributes, but it’s usually preferable to target data-user-invalid and data-user-valid since they get applied only after a user interaction such as typing or submitting. This prevents empty form controls from appearing invalid immediately, which often results in a poor user experience.

This example demonstrates custom validation styles using data-user-invalid and data-user-valid. Try Typing in the fields to see how validity changes with user input.

Birds Cats Dogs Other Accept terms and conditions Submit Reset
<form class="validity-styles">
  <mo-input
    name="name"
    label="Name"
    help-text="What would you like people to call you?"
    autocomplete="off"
    required
  ></mo-input>

  <mo-select name="animal" label="Favorite Animal" help-text="Select the best option." clearable required>
    <mo-option value="birds">Birds</mo-option>
    <mo-option value="cats">Cats</mo-option>
    <mo-option value="dogs">Dogs</mo-option>
    <mo-option value="other">Other</mo-option>
  </mo-select>

  <mo-checkbox value="accept" required>Accept terms and conditions</mo-checkbox>

  <mo-button type="submit" variant="primary">Submit</mo-button>
  <mo-button type="reset" variant="default">Reset</mo-button>
</form>

<script type="module">
  const form = document.querySelector('.validity-styles');

  // Wait for controls to be defined before attaching form listeners
  await Promise.all([
    customElements.whenDefined('mo-button'),
    customElements.whenDefined('mo-checkbox'),
    customElements.whenDefined('mo-input'),
    customElements.whenDefined('mo-option'),
    customElements.whenDefined('mo-select')
  ]).then(() => {
    form.addEventListener('submit', event => {
      event.preventDefault();
      alert('All fields are valid!');
    });
  });
</script>

<style>
  .validity-styles mo-input,
  .validity-styles mo-select,
  .validity-styles mo-checkbox {
    display: block;
    margin-bottom: var(--mo-spacing-medium);
  }

  /* user invalid styles */
  .validity-styles mo-input[data-user-invalid]::part(base),
  .validity-styles mo-select[data-user-invalid]::part(combobox),
  .validity-styles mo-checkbox[data-user-invalid]::part(control) {
    border-color: var(--mo-color-status-alert);
  }

  .validity-styles [data-user-invalid]::part(form-control-label),
  .validity-styles [data-user-invalid]::part(form-control-help-text),
  .validity-styles mo-checkbox[data-user-invalid]::part(label) {
    color: var(--mo-color-status-alert);
  }

  .validity-styles mo-checkbox[data-user-invalid]::part(control) {
    outline: none;
  }

  .validity-styles mo-input:focus-within[data-user-invalid]::part(base),
  .validity-styles mo-select:focus-within[data-user-invalid]::part(combobox),
  .validity-styles mo-checkbox:focus-within[data-user-invalid]::part(control) {
    border-color: var(--mo-color-status-alert);
    box-shadow: 0 0 0 var(--mo-focus-ring-width) var(--mo-color-status-alert-container);
  }

  /* User valid styles */
  .validity-styles mo-input[data-user-valid]::part(base),
  .validity-styles mo-select[data-user-valid]::part(combobox),
  .validity-styles mo-checkbox[data-user-valid]::part(control) {
    border-color: var(--mo-color-status-success);
  }

  .validity-styles [data-user-valid]::part(form-control-label),
  .validity-styles [data-user-valid]::part(form-control-help-text),
  .validity-styles mo-checkbox[data-user-valid]::part(label) {
    color: var(--mo-color-status-success);
  }

  .validity-styles mo-checkbox[data-user-valid]::part(control) {
    background-color: var(--mo-color-status-success);
    outline: none;
  }

  .validity-styles mo-input:focus-within[data-user-valid]::part(base),
  .validity-styles mo-select:focus-within[data-user-valid]::part(combobox),
  .validity-styles mo-checkbox:focus-within[data-user-valid]::part(control) {
    border-color: var(--mo-color-status-success);
    box-shadow: 0 0 0 var(--mo-focus-ring-width) var(--mo-color-success-container);
  }
</style>

Inline form validation

By default, Modes form controls use the browser’s tooltip-style error messages. No mechanism is provided to show errors inline, as there are too many opinions on how that would work when combined with native form controls and other custom elements. You can, however, implement your own solution using the following technique.

To disable the browser’s error messages, you need to cancel the mo-invalid event. Then you can apply your own inline validation errors. This example demonstrates a primitive way to do this.

Submit Reset
<form class="inline-validation">
  <mo-input
    name="name"
    label="Name"
    help-text="What would you like people to call you?"
    autocomplete="off"
    required
  ></mo-input>

  <div id="name-error" aria-live="polite" hidden></div>

  <mo-button type="submit" variant="primary">Submit</mo-button>
  <mo-button type="reset" variant="secondary">Reset</mo-button>
</form>

<script type="module">
  const form = document.querySelector('.inline-validation');
  const nameError = document.querySelector('#name-error');

  // Wait for controls to be defined before attaching form listeners
  await Promise.all([customElements.whenDefined('mo-button'), customElements.whenDefined('mo-input')]).then(() => {
    // A form control is invalid
    form.addEventListener(
      'mo-invalid',
      event => {
        // Suppress the browser's constraint validation message
        event.preventDefault();

        nameError.textContent = `Error: ${event.target.validationMessage}`;
        nameError.hidden = false;

        event.target.focus();
      },
      { capture: true } // you must use capture since mo-invalid doesn't bubble!
    );

    // Handle form submit
    form.addEventListener('submit', event => {
      event.preventDefault();
      nameError.hidden = true;
      nameError.textContent = '';
      setTimeout(() => alert('All fields are valid'), 50);
    });

    // Handle form reset
    form.addEventListener('reset', event => {
      nameError.hidden = true;
      nameError.textContent = '';
    });
  });
</script>

<style>
  #name-error {
    font-size: var(--mo-input-help-text-font-size-medium);
    color: var(--mo-color-status-alert);
  }

  #name-error ~ mo-button {
    margin-top: var(--mo-spacing-medium);
  }

  .inline-validation mo-input {
    display: block;
  }

  /* user invalid styles */
  .inline-validation mo-input[data-user-invalid]::part(base) {
    border-color: var(--mo-color-status-alert);
  }

  .inline-validation [data-user-invalid]::part(form-control-label),
  .inline-validation [data-user-invalid]::part(form-control-help-text) {
    color: var(--mo-color-alert-700);
  }

  .inline-validation mo-input:focus-within[data-user-invalid]::part(base) {
    border-color: var(--mo-color-status-alert);
    box-shadow: 0 0 0 var(--mo-focus-ring-width) var(--mo-color-status-alert-container);
  }

  /* User valid styles */
  .inline-validation mo-input[data-user-valid]::part(base) {
    border-color: var(--mo-color-status-success);
  }

  .inline-validation [data-user-valid]::part(form-control-label),
  .inline-validation [data-user-valid]::part(form-control-help-text) {
    color: var(--mo-color-success-700);
  }

  .inline-validation mo-input:focus-within[data-user-valid]::part(base) {
    border-color: var(--mo-color-status-success);
    box-shadow: 0 0 0 var(--mo-focus-ring-width) var(--mo-color-success-300);
  }
</style>

Getting associated form controls

At this time, using HTMLFormElement.elements will not return Modes form controls because the browser is unaware of their status as custom element form controls. Modes provides an elements() function that does something very similar. However, instead of returning an HTMLFormControlsCollection, it returns an array of HTML and Modes form controls in the order they appear in the DOM.

import { getFormControls } from '@metsooutotec/modes-web-components/dist/utilities/form.js';

const form = document.querySelector('#my-form');
const formControls = getFormControls(form);

console.log(formControls); // e.g. [input, mo-input, ...]