CustomControl

A form-oriented base class for Monster controls with value handling, validation and control semantics.

Import
the javascript logo
import { CustomControl } from "@schukai/monster/source/dom/customcontrol.mjs";
Source
the git logo
Package
the npm logo
Since
1.14.0

Introduction

CustomControl builds on CustomElement and adds the contract required for real form controls: value handling, form association, validation state and integration with ElementInternals.

Core Responsibilities

SurfaceWhat it solves
valueDefines how the control exposes and accepts its current state.
setFormValue()Publishes the value or a FormData payload to the owning form.
setValidity()Marks the control invalid and attaches a validation message to a concrete anchor element.
formResetCallback()Restores the control when the host form is reset.
formDisabledCallback()Keeps the UI in sync when a parent form or fieldset disables the control.

What a Derived Control Must Implement

  • Tag: override getTag() and register once.
  • Visual shell: provide a template and optional stylesheet just like any other custom element.
  • Value API: implement the value getter and setter.
  • Form publishing: call setFormValue() whenever the submitted payload changes.
  • Validation: use setValidity() rather than ad hoc error flags.

Minimal Setup


import { CustomControl } from "@schukai/monster/source/dom/customcontrol.mjs";
import { registerCustomElement } from "@schukai/monster/source/dom/customelement.mjs";

class MyControl extends CustomControl {
  static getTag() {
    return "my-control";
  }

  get value() {
    return "";
  }

  set value(next) {
    this.setFormValue(next ?? "");
  }
}

registerCustomElement(MyControl);

Build A Minimal Form Associated Text Control

import { CustomControl } from "@schukai/monster/source/dom/customcontrol.mjs";
import { registerCustomElement } from "@schukai/monster/source/dom/customelement.mjs";

const tagName = "monster-text-control";

const styleSheet = new CSSStyleSheet();
styleSheet.replaceSync(`
    :host {
        display: block;
    }

    label {
        display: grid;
        gap: var(--monster-space-1);
    }

    input {
        padding: var(--monster-space-2);
        border: 1px solid var(--monster-color-border-primary-2);
        border-radius: var(--monster-border-radius);
    }
`);

if (!customElements.get(tagName)) {
    class MonsterTextControl extends CustomControl {
        static getTag() {
            return tagName;
        }

        get defaults() {
            return {
                ...super.defaults,
                templates: {
                    main: `
                        <label>
                            <span>Message</span>
                            <input type="text" />
                        </label>
                    `,
                },
            };
        }

        static getCSSStyleSheet() {
            return styleSheet;
        }

        connectedCallback() {
            super.connectedCallback();

            const input = this.shadowRoot.querySelector("input");
            if (!this.wired) {
                input.addEventListener("input", () => {
                    this.value = input.value;
                    this.dispatchEvent(new Event("change", { bubbles: true }));
                });
                this.wired = true;
            }

            this.value = this.getAttribute("value") ?? "";
        }

        get value() {
            return this.shadowRoot?.querySelector("input")?.value ?? "";
        }

        set value(next) {
            const value = String(next ?? "");
            const input = this.shadowRoot?.querySelector("input");
            if (input && input.value !== value) {
                input.value = value;
            }
            this.setFormValue(value);
        }
    }

    registerCustomElement(MonsterTextControl);
}

const control = document.getElementById("custom-control-text");
const output = document.getElementById("custom-control-text-output");

const sync = () => {
    output.textContent = control.value;
};

control.addEventListener("change", sync);
sync();
Open in playground

Add Validation And ReportValidity Behaviour

import { CustomControl } from "@schukai/monster/source/dom/customcontrol.mjs";
import { registerCustomElement } from "@schukai/monster/source/dom/customelement.mjs";

const tagName = "monster-validation-control";

if (!customElements.get(tagName)) {
    class MonsterValidationControl extends CustomControl {
        static getTag() {
            return tagName;
        }

        get defaults() {
            return {
                ...super.defaults,
                templates: {
                    main: `
                        <label>
                            <span>Slug</span>
                            <input type="text" placeholder="min. 4 characters" />
                        </label>
                    `,
                },
            };
        }

        connectedCallback() {
            super.connectedCallback();

            const input = this.shadowRoot.querySelector("input");
            if (!this.wired) {
                input.addEventListener("input", () => {
                    this.value = input.value;
                    this.dispatchEvent(new Event("change", { bubbles: true }));
                });
                this.wired = true;
            }

            this.value = "";
        }

        get value() {
            return this.shadowRoot?.querySelector("input")?.value ?? "";
        }

        set value(next) {
            const value = String(next ?? "");
            const input = this.shadowRoot?.querySelector("input");
            if (input && input.value !== value) {
                input.value = value;
            }

            this.setFormValue(value);

            if (value.length > 0 && value.length < 4) {
                this.setValidity({ tooShort: true }, "Use at least 4 characters.", input);
            } else {
                this.setValidity({});
            }
        }
    }

    registerCustomElement(MonsterValidationControl);
}

const control = document.getElementById("custom-control-validation");
const message = document.getElementById("custom-control-validation-message");

const sync = () => {
    message.textContent = control.validationMessage || "valid";
};

control.addEventListener("change", sync);
document.getElementById("custom-control-validation-report").addEventListener("click", () => {
    control.reportValidity();
    sync();
});

sync();
Open in playground

Submit Structured FormData From A Custom Control

import { CustomControl } from "@schukai/monster/source/dom/customcontrol.mjs";
import { registerCustomElement } from "@schukai/monster/source/dom/customelement.mjs";

const tagName = "monster-profile-control";

if (!customElements.get(tagName)) {
    class MonsterProfileControl extends CustomControl {
        static getTag() {
            return tagName;
        }

        get defaults() {
            return {
                ...super.defaults,
                templates: {
                    main: `
                        <div class="monster-flow-space-2">
                            <label>
                                <span>First name</span>
                                <input data-field="firstName" type="text" />
                            </label>
                            <label>
                                <span>Last name</span>
                                <input data-field="lastName" type="text" />
                            </label>
                        </div>
                    `,
                },
            };
        }

        connectedCallback() {
            super.connectedCallback();

            if (!this.wired) {
                this.shadowRoot.querySelectorAll("input").forEach((input) => {
                    input.addEventListener("input", () => {
                        this.publish();
                        this.dispatchEvent(new Event("change", { bubbles: true }));
                    });
                });
                this.wired = true;
            }

            this.publish();
        }

        get value() {
            return {
                firstName: this.shadowRoot.querySelector('[data-field="firstName"]').value,
                lastName: this.shadowRoot.querySelector('[data-field="lastName"]').value,
            };
        }

        set value(next) {
            const value = next ?? {};
            this.shadowRoot.querySelector('[data-field="firstName"]').value = value.firstName ?? "";
            this.shadowRoot.querySelector('[data-field="lastName"]').value = value.lastName ?? "";
            this.publish();
        }

        publish() {
            const data = new FormData();
            const value = this.value;
            data.append(`${this.name}-firstName`, value.firstName ?? "");
            data.append(`${this.name}-lastName`, value.lastName ?? "");
            this.setFormValue(data);
        }
    }

    registerCustomElement(MonsterProfileControl);
}

const form = document.getElementById("custom-control-profile-form");
const output = document.getElementById("custom-control-profile-output");

form.addEventListener("submit", (event) => {
    event.preventDefault();
    const data = new FormData(form);
    output.textContent = JSON.stringify(Object.fromEntries(data.entries()), null, 2);
});
Open in playground

Restore The Control State On Form Reset

import { CustomControl } from "@schukai/monster/source/dom/customcontrol.mjs";
import { registerCustomElement } from "@schukai/monster/source/dom/customelement.mjs";

const tagName = "monster-reset-control";

if (!customElements.get(tagName)) {
    class MonsterResetControl extends CustomControl {
        static getTag() {
            return tagName;
        }

        get defaults() {
            return {
                ...super.defaults,
                templates: {
                    main: `<input type="text" />`,
                },
            };
        }

        connectedCallback() {
            super.connectedCallback();

            const input = this.shadowRoot.querySelector("input");
            if (!this.wired) {
                input.addEventListener("input", () => {
                    this.value = input.value;
                    this.dispatchEvent(new Event("change", { bubbles: true }));
                });
                this.wired = true;
            }

            this.initialValue = this.getAttribute("value") ?? "";
            this.value = this.initialValue;
        }

        get value() {
            return this.shadowRoot?.querySelector("input")?.value ?? "";
        }

        set value(next) {
            const value = String(next ?? "");
            const input = this.shadowRoot?.querySelector("input");
            if (input && input.value !== value) {
                input.value = value;
            }
            this.setFormValue(value);
        }

        formResetCallback() {
            this.value = this.initialValue ?? "";
        }
    }

    registerCustomElement(MonsterResetControl);
}

const control = document.getElementById("custom-control-reset");
const output = document.getElementById("custom-control-reset-output");

const sync = () => {
    output.textContent = control.value;
};

document.getElementById("custom-control-reset-mutate").addEventListener("click", () => {
    control.value = "Changed before reset";
    control.dispatchEvent(new Event("change", { bubbles: true }));
    sync();
});

control.addEventListener("change", sync);
document.getElementById("custom-control-reset-form").addEventListener("reset", () => {
    setTimeout(sync, 0);
});

sync();
Open in playground

Sync Disabled State From Form Containers Into The Control

import { CustomControl } from "@schukai/monster/source/dom/customcontrol.mjs";
import { registerCustomElement } from "@schukai/monster/source/dom/customelement.mjs";

const tagName = "monster-disabled-control";

if (!customElements.get(tagName)) {
    class MonsterDisabledControl extends CustomControl {
        static getTag() {
            return tagName;
        }

        get defaults() {
            return {
                ...super.defaults,
                templates: {
                    main: `<input type="text" value="Ready to publish" />`,
                },
            };
        }

        connectedCallback() {
            super.connectedCallback();
            this.value = this.shadowRoot.querySelector("input").value;
        }

        get value() {
            return this.shadowRoot?.querySelector("input")?.value ?? "";
        }

        set value(next) {
            const value = String(next ?? "");
            const input = this.shadowRoot?.querySelector("input");
            if (input && input.value !== value) {
                input.value = value;
            }
            this.setFormValue(value);
        }

        formDisabledCallback(disabled) {
            super.formDisabledCallback(disabled);
            const input = this.shadowRoot?.querySelector("input");
            if (input) {
                input.disabled = disabled;
            }
        }
    }

    registerCustomElement(MonsterDisabledControl);
}

const fieldset = document.getElementById("custom-control-disabled-fieldset");
const output = document.getElementById("custom-control-disabled-output");

const sync = () => {
    output.textContent = fieldset.disabled ? "disabled" : "enabled";
};

document.getElementById("custom-control-disabled-toggle").addEventListener("click", () => {
    fieldset.disabled = !fieldset.disabled;
    sync();
});

sync();
Open in playground

Exported

CustomControl

Derived from

CustomElement

Options

The Options listed in this section are defined directly within the class. This class is derived from several parent classes. Therefore, it inherits Options from these parent classes. If you cannot find a specific Options in this list, we recommend consulting the documentation of the CustomElement.

Option
Type
Default
Description
-/-

Properties

The Properties listed in this section are defined directly within the class. This class is derived from several parent classes. Therefore, it inherits Properties from these parent classes. If you cannot find a specific Properties in this list, we recommend consulting the documentation of the CustomElement.

Methods

The methods listed in this section are defined directly within the class. This class is derived from several parent classes. Therefore, it inherits methods from these parent classes. If you cannot find a specific method in this list, we recommend consulting the documentation of the CustomElement.

Constructor

constructor()
Throws
  • {Error} the ElementInternals is not supported and a polyfill is necessary
The constructor method of CustomControl, which is called when creating a new instance. It checks whether the element supports attachInternals() and initializes an internal form-associated element if supported. Additionally, it initializes a MutationObserver to watch for attribute changes. See the links below for more information: {@link https://html.spec.whatwg.org/multipage/custom-elements.html#dom-customelementregistry-define|CustomElementRegistry.define()} {@link https://html.spec.whatwg.org/multipage/custom-elements.html#dom-customelementregistry-get|CustomElementRegistry.get()} and {@link https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals|ElementInternals}

State query methods

value()
Throws
  • {Error} the value getter must be overwritten by the derived class
Must be overridden by a derived class and return the value of the control. This is a method of [internal API](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals), which is a part of the web standard for custom elements.
value(value)
Parameters
  • value {*}: The value to set.
Throws
  • {Error} the value setter must be overwritten by the derived class
Must be overridden by a derived class and set the value of the control. This is a method of [internal API](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals), which is a part of the web standard for custom elements.
willValidate()
Returns
  • {boolean}
Throws
  • {Error} the ElementInternals is not supported and a polyfill is necessary
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)

Structural methods

setFormValue(value,state)
Parameters
  • value {file|string|formdata}: value
  • state {file|string|formdata}: state
Returns
  • {undefined}
Throws
  • {DOMException} NotSupportedError
  • {Error} the ElementInternals is not supported and a polyfill is necessary
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals) ``// Use the control's name as the base name for submitted data const n = this.getAttribute('name'); const entries = new FormData(); entries.append(n + '-first-name', this.firstName_); entries.append(n + '-last-name', this.lastName_); this.setFormValue(entries);``
setValidity(flags,message,anchor)
Parameters
  • flags {object}: flags
  • message {string|undefined}: message
  • anchor {htmlelement}: anchor
Returns
  • {undefined}
Throws
  • {DOMException} NotSupportedError
  • {Error} the ElementInternals is not supported and a polyfill is necessary

Static methods

[instanceSymbol]()2.1.0
Returns
  • {symbol}
This method is called by the instanceof operator.
observedAttributes()1.15.0
Returns
  • {string[]}
This method determines which attributes are to be monitored by attributeChangedCallback().

Lifecycle methods

Lifecycle methods are called by the environment and are usually not intended to be called directly.

connectedCallback()

Other methods

checkValidity()
Returns
  • {boolean}
Throws
  • {DOMException} NotSupportedError
  • {Error} the ElementInternals is not supported and a polyfill is necessary
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
form()
Returns
  • {HTMLFontElement|null}
Throws
  • {Error} the ElementInternals is not supported and a polyfill is necessary
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
formAssociatedCallback(form)
Parameters
  • form {htmlformelement}: - The form element to associate with the control
Sets the form attribute of the custom control to the id of the passed form element. If no form element is passed, removes the form attribute.
formDisabledCallback(disabled)
Parameters
  • disabled {boolean}: - Whether or not the control should be disabled
Sets or removes the disabled attribute of the custom control based on the passed value.
formResetCallback()
formStateRestoreCallback(state,mode)
Parameters
  • state {string}: state
  • mode {string}: mode
labels()
Returns
  • {NodeList}
Throws
  • {Error} the ElementInternals is not supported and a polyfill is necessary
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
name()
Returns
  • {string|null}
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
reportValidity()
Returns
  • {boolean}
Throws
  • {Error} the ElementInternals is not supported and a polyfill is necessary
  • {DOMException} NotSupportedError
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
states()
Returns
  • {boolean}
Throws
  • {Error} the ElementInternals is not supported and a polyfill is necessary
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
type()
Returns
  • {string}
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
validationMessage()
Returns
  • {string}
Throws
  • {Error} the ElementInternals is not supported and a polyfill is necessary
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)
validity()
Returns
  • {ValidityState}
Throws
  • {Error} the ElementInternals is not supported and a polyfill is necessary
This is a method of [internal api](https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals)

Events

This component does not fire any public events. It may fire events that are inherited from its parent classes.

The current width of the area is too small to display the content correctly.