Forms

Add form-associated to a definition and the component becomes a form field through ElementInternals. It takes part in submission and FormData, form.reset(), <fieldset disabled>, validation, labels and back/forward restore, like <input>.

<custom-element name="star-input" form-associated>
  …
</custom-element>

<form>
  <star-input name="rating" required></star-input>
</form>

The name attribute works as on native fields, because the browser reads it from the element.

Values are synced for you

If a form-associated component declares a prop called value, REALM keeps the submitted form value equal to it. If it declares checked, the field submits its value (or "on") only while checked is true, like a checkbox.

On form.reset(), every prop is parsed again from its attribute, so the field returns to the value written in the HTML. When the browser restores form state (back/forward navigation or autofill), value or checked is restored.

Delegating to an inner control

Most fields wrap a native control. Mark it with ref="control" and REALM wires it up:

Previewindex.htmlquantity-input.htmlorder-form.html
- +
Order Reset
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Forms demo</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.css">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/dist/theme.css">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@realmorg/realm@alpha/dist/realm.js"></script>
</head>
<body>
  <import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>

  <import-element from="./quantity-input.html"></import-element>
  <import-element from="./order-form.html"></import-element>

  <order-form></order-form>
</body>
</html>
<custom-element name="quantity-input" form-associated delegates-focus>
  <script>
    const props = defineProps({
      value: prop.number().int().default(1),
      min: prop.number().default(1),
      max: prop.number().default(10),
      disabled: prop.boolean(),
    });

    function step(amount) {
      props.value = Math.min(props.max, Math.max(props.min, props.value + amount));
    }
  </script>

  <style>
    :host {
      display: inline-flex;
      align-items: center;
      gap: calc(var(--spacing) * 1);
    }
    input {
      width: calc(var(--spacing) * 14);
      height: calc(var(--spacing) * 8);
      border: 1px solid var(--input);
      border-radius: var(--radius-md);
      background: transparent;
      color: inherit;
      font: inherit;
      text-align: center;
    }
    input:invalid {
      border-color: var(--destructive);
    }
  </style>

  <ui-button size="icon-sm" variant="outline" aria-label="Less" _disabled="@disabled" onclick="step(-1)">-</ui-button>
  <input ref="control" type="number" _value="@value" _min="@min" _max="@max" _disabled="@disabled">
  <ui-button size="icon-sm" variant="outline" aria-label="More" _disabled="@disabled" onclick="step(1)">+</ui-button>
</custom-element>
<custom-element name="order-form">
  <script>
    const result = useState({ text: "Submit to see the form data." });

    function submit(event) {
      event.preventDefault();
      result.text = new URLSearchParams(new FormData(event.target)).toString();
    }

    function cleared() {
      result.text = "Reset.";
    }
  </script>

  <style>
    form {
      display: grid;
      gap: calc(var(--spacing) * 3);
      justify-items: start;
    }
    label {
      display: flex;
      gap: calc(var(--spacing) * 3);
      align-items: center;
    }
    .actions {
      display: flex;
      gap: calc(var(--spacing) * 2);
    }
    output {
      font-family: var(--font-mono);
      font-size: var(--text-sm);
    }
  </style>

  <form onsubmit="submit(event)" onreset="cleared()">
    <label>Coffee <quantity-input name="coffee" value="2"></quantity-input></label>
    <label>Tea <quantity-input name="tea" value="1" max="3"></quantity-input></label>
    <label>Cake (sold out) <quantity-input name="cake" disabled></quantity-input></label>
    <div class="actions">
      <ui-button type="submit">Order</ui-button>
      <ui-button type="reset" variant="outline">Reset</ui-button>
    </div>
    <output><slot name="#result.text"></slot></output>
  </form>
</custom-element>

In the demo:

Disabled fields

Inside a form-associated component, props.disabled is true when the disabled attribute is set or when a surrounding <fieldset disabled> disables the field. Bind it to inner controls (_disabled="@disabled") and both cases work.

Doing it by hand

Without a value or checked prop, or for anything special, use internals directly:

const props = defineProps({ tags: prop.array(prop.string()).default([]), required: prop.boolean() });

observe(() => {
  internals.setFormValue(props.tags.join(","));
  internals.setValidity(
    props.required && props.tags.length === 0 ? { valueMissing: true } : {},
    "Add at least one tag.",
  );
});

internals.form, internals.labels, internals.validity and internals.willValidate are the native properties.

Form lifecycle hooks

Hook Runs when
onFormReset(fn) The form is reset. Runs after REALM re-reads the props
onFormDisabled(fn) The field becomes disabled or enabled through a fieldset or its own attribute. fn(disabled)
onFormStateRestore(fn) The browser restores the field's state. fn(state, mode), where mode is "restore" or "autocomplete"

Submitting with fetch attributes

A <form> with fetch attributes sends its fields, including form-associated components, without a page load:

<form action="/api/orders" method="post" into="#orders.list" into-mode="append" onload="this.reset()">
  <quantity-input name="coffee" value="1"></quantity-input>
  <ui-button type="submit">Order</ui-button>
</form>

REALM UI fields

Every REALM UI input is form-associated: Input, Textarea, Select, Checkbox, Switch, Radio Group, Slider, Input OTP, Combobox, Calendar, Toggle, Toggle Group, and Button for submit and reset.

Edit this page on GitHub · View as Markdown