CustomControl
A form-oriented base class for Monster controls with value handling, validation and control semantics.
import { CustomControl } from "@schukai/monster/source/dom/customcontrol.mjs";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
| Surface | What it solves |
|---|---|
value | Defines 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
valuegetter 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();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();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);
});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();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();Exported
CustomControlDerived from
CustomElementOptions
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.
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(){Error}the ElementInternals is not supported and a polyfill is necessary
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(){Error}the value getter must be overwritten by the derived class
value(value)value{*}: The value to set.
{Error}the value setter must be overwritten by the derived class
willValidate()- {boolean}
{Error}the ElementInternals is not supported and a polyfill is necessary
Structural methods
setFormValue(value,state)value{file|string|formdata}: valuestate{file|string|formdata}: state
- {undefined}
{DOMException}NotSupportedError{Error}the ElementInternals is not supported and a polyfill is necessary
// 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)flags{object}: flagsmessage{string|undefined}: messageanchor{htmlelement}: anchor
- {undefined}
{DOMException}NotSupportedError{Error}the ElementInternals is not supported and a polyfill is necessary
Static methods
[instanceSymbol]()2.1.0- {symbol}
instanceof operator.observedAttributes()1.15.0- {string[]}
attributeChangedCallback().Lifecycle methods
Lifecycle methods are called by the environment and are usually not intended to be called directly.
connectedCallback()Other methods
checkValidity()- {boolean}
{DOMException}NotSupportedError{Error}the ElementInternals is not supported and a polyfill is necessary
form()- {HTMLFontElement|null}
{Error}the ElementInternals is not supported and a polyfill is necessary
formAssociatedCallback(form)form{htmlformelement}: - The form element to associate with the control
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)disabled{boolean}: - Whether or not the control should be disabled
disabled attribute of the custom control based on the passed value.formResetCallback()formStateRestoreCallback(state,mode)state{string}: statemode{string}: mode
labels()- {NodeList}
{Error}the ElementInternals is not supported and a polyfill is necessary
name()- {string|null}
reportValidity()- {boolean}
{Error}the ElementInternals is not supported and a polyfill is necessary{DOMException}NotSupportedError
states()- {boolean}
{Error}the ElementInternals is not supported and a polyfill is necessary
type()- {string}
validationMessage()- {string}
{Error}the ElementInternals is not supported and a polyfill is necessary
validity()- {ValidityState}
{Error}the ElementInternals is not supported and a polyfill is necessary
Events
This component does not fire any public events. It may fire events that are inherited from its parent classes.