Skip to content

Latest commit

 

History

History
299 lines (215 loc) · 16 KB

guides.md

File metadata and controls

299 lines (215 loc) · 16 KB

Form Observer Guides

Here you'll find helpful tips on how to use the FormObserver effectively in various situations. We hope that you find these guides useful! Here are the currently discussed topics:

Supporting Custom Event Types

The FormObserver allows you to listen for the standardized event types and for any custom event types that you create. If you're using TypeScript, then you'll need to register your custom event types with the global DocumentEventMap so that the FormObserver can recognize them.

// Somewhere in a `.ts` file that uses your custom event...
const observer = new FormObserver("mycustomevent", (event) => console.log(typeof event.detail));

// You should only need to do this once for each of your custom events
declare global {
  interface DocumentEventMap {
    mycustomevent: CustomEvent<string>;
  }
}

This approach is similar to what Lit does to support custom HTMLElement tags in TypeScript.

Usage with JavaScript Frameworks

Just like the MutationObserver and the IntersectionObserver, the FormObserver can be setup easily in any JS framework without a framework-specific wrapper/helper. You can setup the FormObserver in the same way that you would setup "any other observer". Here are some examples:

<form bind:this={form}>
  <!-- Other Form Controls -->
</form>

<script>
  import { onMount } from "svelte";
  import { FormObserver } from "@form-observer/core";

  let form;
  onMount(() => {
    const observer = new FormObserver("focusout", (e) => console.log(`Field ${e.target.name} was \`blur\`red`));
    observer.observe(form);

    return () => observer.disconnect();
  });
</script>

React (with TypeScript)

Using Functional Components

import { useEffect, useRef } from "react";
import { FormObserver } from "@form-observer/core";

export default function MyComponent() {
  const form = useRef<HTMLFormElement>(null);
  useEffect(() => {
    const observer = new FormObserver("focusout", (e) => console.log(`Field ${e.target.name} was \`blur\`red`));
    observer.observe(form.current as HTMLFormElement);

    return () => observer.disconnect();
  });

  return <form ref={form}>{/* Other Form Controls */}</form>;
}

Using Class Components

import { Component, createRef } from "react";
import { FormObserver } from "@form-observer/core";

export default class MyComponent extends Component {
  #form = createRef<HTMLFormElement>();
  #observer = new FormObserver("focusout", (e) => console.log(`Field ${e.target.name} was \`blur\`red`));

  componentDidMount() {
    this.#observer.observe(this.#form.current as HTMLFormElement);
  }

  componentWillUnmount() {
    this.#observer.disconnect();
  }

  render() {
    return <form ref={this.#form}>{/* Other Form Controls */}</form>;
  }
}

Using <script setup> Shorthand

<template>
  <form ref="form">
    <!-- Other Form Controls -->
  </form>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { FormObserver } from "@form-observer/core";

const form = ref(null);
const observer = new FormObserver("focusout", (e) => console.log(`Field ${e.target.name} was \`blur\`red`));
onMounted(() => observer.observe(form.value));
onUnmounted(() => observer.disconnect());
</script>

Using Regular <script> Tag

<template>
  <form ref="form">
    <!-- Other Form Controls -->
  </form>
</template>

<script>
import { defineComponent, ref, onMounted, onUnmounted } from "vue";
import { FormObserver } from "@form-observer/core";

export default defineComponent({
  setup() {
    const form = ref(null);
    const observer = new FormObserver("focusout", (e) => console.log(`Field ${e.target.name} was \`blur\`red`));
    onMounted(() => observer.observe(form.value));
    onUnmounted(() => observer.disconnect());

    return { form };
  },
});
</script>
import { onMount, onCleanup } from "solid-js";
import { FormObserver } from "@form-observer/core";

export default function MyComponent() {
  let form;
  const observer = new FormObserver("focusout", (e) => console.log(`Field ${e.target.name} was \`blur\`red`));
  onMount(() => observer.observe(form));
  onCleanup(() => observer.disconnect());

  return <form ref={form}>{/* Other Form Controls */}</form>;
}

Where's My JavaScript Framework?

As you know, JS frameworks are always being created at an incredibly rapid pace; so we can't provide an example for every framework that's out there. However, the process for getting the FormObserver working in your preferred framework is pretty straightforward:

  1. Obtain a reference to the HTMLFormElement that you want to observe.
  2. Call FormObserver.observe(form) when the reference to your form element becomes available (typically during the component's "mounting phase").
  3. When your component unmounts, call FormObserver.disconnect() (or FormObserver.unobserve(form)) to cleanup the listeners that are no longer being used.

This is the approach being taken in the examples above. These steps and the code examples above should give you everything you need to get started.

Usage with Web Components

Because the FormObserver builds on top of native JS features instead of relying on a JS framework (like React), it is completely compatible with native Web Components. However, there are some things to keep in mind when attempting to use them.

Your Web Component Must Be a Valid Form Control

The FormObserver (and all of its subclasses) will only observe elements that are actually recognized as form controls. If you're using regular HTML elements, this basically includes any elements that are supported by the HTMLFormElement.elements property by default. If you're using Custom Elements, this also includes any elements that are specifically identified as form controls.

To identify a Custom Element as a form control, you will need to give its class a static formAssociated property with a value of true. You must also call HTMLElement.attachInternals() in your element's constructor to allow it to participate in forms. Note that the form property on the internals provided by this method must be exposed for the FormObserver to acknowledge your component. (This property is used to make sure that events from fields in irrelevant forms do not trigger the observer's functions.) We have an example of a simple setup below.

class CustomField extends HTMLElement {
  static formAssociated = true;
  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
    // Any other setup ...
  }

  get form() {
    return this.#internals.form;
  }
}

customElements.define("custom-field", CustomField);

This is the code that would be required to allow your Custom Element to participate in HTML forms in general. So the FormObserver isn't requiring any additional work on your part.

Note: You are free to make the ElementInternals public, but it is highly recommended to keep this property private. (It is completely safe to expose the properties of the ElementInternals interface. Only the reference to the ElementInternals object itself needs to be kept private.)

For more information on how to create complex form controls with Web Components (such as how to create form controls that submit their values to the server with the help of ElementInternals.setFormValue()), see More Capable Form Controls by Arthur Evans.

Be Mindful of the Shadow Boundary

The Shadow DOM is a very useful tool for encapsulating the details of a Web Component. However, this tool is not very practical when it comes to HTML forms. Remember, the purpose of the Shadow DOM is to prevent anything on the outside from accessing a Web Component's internal elements; and the "internal elements" include any fields in the Shadow DOM. This means that a form in the Light DOM cannot see fields in the Shadow DOM. Similarly, a form in the Shadow DOM cannot see fields in the Light DOM even if the fields are slotted. Consider the following code:

<form id="light-form">
  <input name="input-light-dom" />
  <shadow-input></shadow-input>
  <button type="submit">Submit Me!</button>
</form>

<input name="blocked" form="shadow-form" />
<shadow-form>
  <textarea name="slotted" form="shadow-form"></textarea>
</shadow-form>
class ShadowInput extends HTMLElement {
  #shadow;

  constructor() {
    super();
    this.#shadow = this.attachShadow({ mode: "open" });

    const input = document.createElement("input");
    input.setAttribute("name", "input-shadow-dom");
    input.setAttribute("form", "light-form");
    this.#shadow.appendChild(input);
  }
}

class ShadowForm extends HTMLElement {
  #shadow;

  constructor() {
    super();
    this.#shadow = this.attachShadow({ mode: "open" });

    this.#shadow.innerHTML = `
      <form id="shadow-form">
        <input name="internal">
        <slot></slot>
      </form>
    `;
  }
}

customElements.define("shadow-input", ShadowInput);
customElements.define("shadow-form", ShadowForm);

(You can test this code out on the MDN Playground if you like.)

In this example, the input-shadow-dom field is completely invisible to the light-form form element. This field is invisible to the form despite the fact that the field's form attribute points to the light-form element. The light-form form element cannot see the input-shadow-dom field because the Shadow DOM prevents the form in the Light DOM from accessing the field in the Shadow DOM. Thus, the form in the Light DOM can only see the input-light-dom field and the submit button.

Similarly, both the blocked input and the slotted textarea are completely invisible to the shadow-form form element even though both of the fields have form attributes that point to the form element. Because the input and even the slotted textarea are defined in the Light DOM, the form element in the Shadow DOM refuses to welcome those elements entirely. Thus, the form in the Shadow DOM can only see the internal input.

In the above example, the input-shadow-dom field, the blocked field, and the slotted field don't partake in any HTML forms at all. This means that these fields don't partake in form submission, nor any of the other form-related features that fields can usually take advantage of. Theoretically, someone could try to bypass these restrictions, but all such efforts would complicate things unnecessarily. Consequently, in order to keep your code clean, functional, and reliable, you should either put your entire form in the Light DOM or in the Shadow DOM. You should never try to mix your form's fields between the Light DOM and the Shadow DOM, nor should you try to mix your form's fields between separate Shadow DOM instances.

How does this relate to the FormObserver? Well, naturally a FormObserver can only observe fields that are visible to the watched form. Because a field in the Shadow DOM would be invisible to a form in the Light DOM, it would also be invisible to a FormObserver which observes the form in the Light DOM. Again, for everything to function correctly, you should either put the entire form -- including its fields -- in the Light DOM, or put the entire form in the Shadow DOM. Then and only then will both the native JS form features and the FormObserver work as desired.

This is not a limitation of the FormObserver, nor is it a limitation of the Shadow DOM. It is an intentional design decision to make sure that the Shadow DOM truly is not disrupted by anything from the outside. In other words, the FormObserver is simply complying with what the current web standards require. The Shadow DOM does not have to be used in every situation where a Custom Element is used. In fact, it is recommended to avoid the Shadow DOM when it comes to Custom Elements that function as form controls.

Extending the FormObserver with Specialized Logic

There are times when you may want to run specific, sophisticated logic for a form that you're observing. For instance, you might want to store data in localStorage and/or validate a form's fields whenever a user interacts with your form. Our library already provides localStorage and form validation solutions for you. However, if you have another complex problem that you want to solve, or if you'd like to take an approach different from the one that we've chosen for our built-in solutions, then extending the FormObserver can help you to accomplish your goal.

class MyCustomObserver extends FormObserver {
  constructor(types) {
    const mySpecificListener = (event) => {
      /* Sophisticated Listener Logic */
    };

    super(types, mySpecificListener);

    /* Setup private fields belonging to `MyCustomObserver` */
  }

  /* Define any helpful methods */
}

If you're interested in creating your own extension of the FormObserver but don't know where to start, we recommend looking at the implementation of the FormStorageObserver for an intermediate-level example of extending the base FormObserver's functionality. It's only ~269 lines of code -- with about 33% of the code being types/JSDocs.

Of course, using the extends clause isn't the only way to create reusable logic related to the FormObserver. For instance, you can also encapsulate whatever reusable logic you want within a regular function that closes over an instance of the FormObserver.

Should I Create a Framework Wrapper for My Enhanced Observer?

The FormValidityObserver has framework-specific wrappers for the sake of convenience -- though using the wrappers is not required. However, the FormObserver and the FormStorageObserver don't provide any framework-specific wrappers at all. So how do you know when you should create a framework wrapper for your extension of the FormObserver?

Here's our general recommendation: If your extension of the FormObserver exposes no other instance methods besides observe(), unobserve(), and disconnect() (as is the case for the FormObserver and the FormStorageObserver), you should probably just use your utility directly. Don't bother with framework wrappers like actions (Svelte), custom hooks (React), or the like in that situation. If your extension of the FormObserver does expose unique instance methods (as is the case for the FormValidityObserver), then you can start to consider whether or not a framework wrapper could be helpful. But even then, you still may not need a wrapper at all.