Hot Controls
Build observable HTML controls tethered to JavaScript.
October CMS includes a simple implementation of MutationObserver (opens new window), where you can define HTML controls that detect when they are added or removed from the page. Now it's possible to initialize or uninitialize controls that are added or removed via AJAX or turbo router updates.
# Registering an Observable Control
This function can be called multiple times and it will take the last seen definition.
In its basic form, the oc.registerControl
JavaScript function is used to define a unique control name (first argument) and class definition (second argument) that extends the oc.ControlBase
base class.
oc.registerControl('hello', class extends oc.ControlBase {
// ...
});
The control name is used to link to a DOM element representing the control, using the data-control
attribute. For example, a control registered with the name hello monitors the page for any element with the data-control="hello"
attribute attached to it.
<div data-control="hello"></div>
The connect
and disconnect
methods within the class definition are triggered whenever the control is added or removed from the page. This can occur at any time, as the observer continuously monitors for DOM changes.
class extends oc.ControlBase {
connect() {
// Element has appeared in DOM
}
disconnect() {
// Element was removed from DOM
}
}
# Initializing a Control
The init
method allows you to load the default configuration for the control and configure its child elements.
class extends oc.ControlBase {
init() {
// Establish the control before running logic
}
}
The init
method is called once per control and connect
is called every time the control is added or removed from the DOM, for example, when moving the element to a new location.
# Configuration
All data-
attributes on the control element make up its available configuration.
<div data-control="hello" data-favorite-color="red"></div>
Configuration values can be accessed via the this.config
property. The data attributes are converted from to camelCase, without the data-
prefix, for example, the data-favorite-color
attribute is accessed as this.config.favoriteColor
.
class extends oc.ControlBase {
init() {
this.favoriteColor = this.config.favoriteColor || 'green';
}
connect() {
console.log(`Favorite color? ${this.favoriteColor}!`);
}
}
# Child Elements
Any selector, whether CSS or data attributes, can be used to select child elements within the parent control class.
<div data-control="hello">
<input class="name" disabled />
</div>
The parent control element is available via this.element
. Any child element can be selected with querySelector
for a single element, or querySelectorAll
for multiple elements.
class extends oc.ControlBase {
init() {
this.$name = this.element.querySelector('input.name');
}
connect() {
this.$name.value = 'Jeff';
this.$name.disabled = false;
}
}
# Referencing Other Controls
The oc.fetchControl
function is used to return a control instance from an existing control element, this accepts a selector string, or an element directly. The resulting instance support method calls or accessing properties found on the control class definition.
const searchControl = oc.fetchControl(element);
You may also pass a selector string, along with the control name as the second argument (optional). This is useful when multiple controls are bound to the same element and you want to clarify the exact identifier.
const searchControl = oc.fetchControl('[data-control=search]', 'search');
The oc.importControl
function can be used to return a control class that has been registered, which can be useful for calling static methods on the class. The function accepts the control identifier as a string.
const searchControlClass = oc.importControl('search');
The oc.observeControl
function is used to immediately resolve a control instance and attach it to the element. This is useful when an element does not have the data-control
attribute and you want to attach it without waiting for the observer events.
const searchControl = oc.observeControl(element, 'search');
# Working with Events
Observable controls can bind events either locally or globally. Local events are automatically unbound, while global events need to be manually unbound using the disconnect
method.
# Local Events
You can bind a local event handler using the listen
function, and these handlers will automatically unbind. To bind a listener to the control element itself, pass the event name and the event handler function to the listen
function.
class extends oc.ControlBase {
connect() {
this.listen('dblclick', this.onDoubleClick);
}
onDoubleClick() {
console.log('You double clicked my control!');
}
}
To bind a local event handler to a child element, pass the event name, CSS selector, and event handler function. The event.delegateTarget
will always contain the element that matched the CSS selector.
class extends oc.ControlBase {
connect() {
this.listen('click', '.toolbar-find-button', this.onClickFindButton);
}
onClickFindButton(event) {
console.log('You clicked the find button inside the control: ' + event.delegateTarget.innerText);
}
}
You may also bind to a DOM object, pass the event name, HTML element, and the event handler function.
class extends oc.ControlBase {
init() {
this.$name = this.element.querySelector('input.name');
}
connect() {
this.listen('click', this.$name, this.onClickNameInput);
}
onClickNameInput() {
console.log('You clicked the name input inside the control!');
}
}
# Global Events
Global events can be attached and removed using the addEventListener
and removeEventListener
native JavaScript functions. The event handler (second argument) refers to the class method of the same control instance. The proxy
method is called to bind the current context to the function call.
class extends oc.ControlBase {
connect() {
addEventListener('keydown', this.proxy(this.onKeyDown));
}
disconnect() {
removeEventListener('keydown', this.proxy(this.onKeyDown));
}
onKeyDown(event) => {
if (event.key === 'Escape') {
// Escape button was pressed
}
}
}
To prevent memory leaks, it is important to unbind global events so they are captured by garbage collection.
# Dispatching Events
Controls can dispatch events by passing an event name to the dispatch
function. The event is triggered on the DOM element and the event name is prefixed with the control name. In the following example, if the control is registered with a name hello, the event will be named hello:ready.
oc.registerControl('hello', class extends oc.ControlBase {
connect() {
this.dispatch('ready');
}
});
Now you can listen when the control is connected and grab the object using oc.fetchControl
on the event target.
addEventListener('hello:ready', function(ev) {
const helloControl = oc.fetchControl(ev.target);
});
The second argument contains options where you may pass detail
to the event, the following detail data is accessible via ev.detail.foo in the listener.
this.dispatch('ready', { detail: {
foo: 'bar'
}});
You may also specify a different target
where the default is the attached element.
this.dispatch('ready', { target: window });
Setting the prefix
to false will make the event name global, the following triggers an event name of hello-ready instead of hello:hello-ready.
this.dispatch('hello-ready', { prefix: false });
# Usage Examples
# Vanilla JS Example
The following example demonstrates a basic HTML form that includes a name input and a greeting button. The control class initializes the input and output elements, and then listens for the click event on the Greet button. When the Greet button is clicked, the output element displays a greeting that includes the entered name.
<div data-control="hello-world">
<input type="text" class="name" />
<button class="greet">
Greet
</button>
<span class="output">
</span>
</div>
<script>
oc.registerControl('hello-world', class extends oc.ControlBase {
init() {
this.$name = this.element.querySelector('input.name');
this.$output = this.element.querySelector('span.output');
}
connect() {
this.listen('click', 'button.greet', this.onGreet);
}
onGreet() {
this.$output.textContent = `Hello, ${this.$name.value}!`;
}
});
</script>
# Google Maps Example
The following example shows a simple implementation of a third-party JavaScript library, such as Google Maps API. The library Map
is initialized on the control div
element when it is seen on the page. When the control is removed from the page, it prevents memory leaks by calling destroy
on the map instance and setting the property to null
.
<div data-control="google-map"></div>
<script>
oc.registerControl('google-map', class extends oc.ControlBase {
connect() {
this.map = new Map(this.element, {
center: { lat: -34.397, lng: 150.644 },
zoom: 8
});
}
disconnect() {
this.map.destroy();
this.map = null;
}
});
</script>
# Vue.js Example
The next example shows how you can bring your own technology to build dynamic user interfaces, in this case using Vue.js (opens new window) as a technology. The Vue instance, or ViewModel (vm) is created and disposed as needed.
<div data-control="my-vue-control">
<div data-vue-template>
<button @click="greet">Greet</button>
</div>
</div>
<script>
oc.registerControl('my-vue-control', class extends oc.ControlBase {
connect() {
this.vm = new Vue({
el: this.element.querySelector('[data-vue-template]'),
data: {
name: 'October CMS'
},
methods: {
greet: this.greet
}
});
}
disconnect() {
this.vm.$destroy();
}
greet(event) {
alert('Hello ' + this.name + '!')
}
});
</script>
You can also use hot controls to initialize Vue components using the Vue.component
method, making them available to your controls. The following becomes available as <my-vue-component></my-vue-component>
within Vue, however, it is important that these templates are registered before they are used by other controls.
<div data-control="my-vue-component">
<button @click="greet">Greet</button>
</div>
<script>
oc.registerControl('my-vue-component', class extends oc.ControlBase {
init() {
Vue.component('my-vue-component', {
template: this.element,
methods: {
greet: this.greet
}
});
}
connect() {
this.element.style.display = 'none';
}
greet(event) {
alert('Hello!');
}
});
</script>