glue-stix v1.3.0
glue-stix
A light weight, object oriented, web client framework. The DOM is directly produced and manipulated via a Model View Controller objects controller. Controllers extend GSController and can aggregate child controllers. The controller produces the DOM elements and DOM events on those elements are dispatched back to the controller. Reactivity is supported by two signaling mechanism, GSPubSub and GSState. It is unopionated and intended for use with vanilla JavaScript. This MVC/VC controller approach allows for the creation of reusable web components.
Install
npm install glue-stix
A simple example:
"use strict";
import { GSController } from 'glue-stix';
class Salutation extends GSController {
constructor(person) {
super();
this.model = person;
this.paint('body');
}
content() {
return `
<div>
<div>GLUE STIX</div>
<div>Welcome ${this.model.fname} ${this.model.lname}!</div>
</div>
`;
}
}
new Salutation({ fname: "John", lname: "Doe" });
In the example above, take note of the following:
The controller must include a content method which returns either a string or a DocumentFragment object.
The content must have a single root element.
The content is append to the DOM body element because we specified it in the paint.
The model is optional. The content's inner div could be written using
<div>Welcome John Doe!</div>
in which casenew Salutation()
would used to instantiate it and the constructor would have no parameters.
Another example using display mode.
"use strict";
import { GSController, GSDisplayMode } from 'glue-stix';
class Salutation extends GSController {
constructor(person) {
super({ displayMode: GSDisplayMode.REPLACE });
this.model = person;
this.paint('body');
}
content() {
return `
<body>
<div>GLUE STIX</div>
<div>Welcome ${this.model.fname} ${this.model.lname}!</div>
</body>
`;
}
}
new Salutation({ fname: "John", lname: "Doe" });
In this example, the root element from the content will replace the body element.
Example of a child controller, a state object and DOM event handling.
"use strict";
import { GSController, GSDisplayMode, GSState } from 'glue-stix';
const viewState = new GSState('EDIT');
export class Salutation extends GSController {
constructor(){
super({ eventSubscriptions: ['click', 'change'] });
this.person = new Person();
this.paint('body');
}
content() {
return `
<div>
<div>
GLUE STIX
<button type="button" data-click="toggleView">${viewState.get()}</button>
</div>
<div>
Hello,
<div data-controller="person"></div>
</div>
</div>
`;
}
toggleView(evt) {
let mode = viewState.get();
mode = mode == 'VIEW' ? 'EDIT' : 'VIEW';
evt.target.innerText = mode;
viewState.set(mode);
}
}
export class Person extends GSController {
constructor() {
super({ displayMode: GSDisplayMode.REPLACE });
this.model = { fname: "John", lname: "Doe"};
this.watch(viewState, this.repaint);
}
content() {
if(viewState.get() == 'EDIT') {
return this.view();
} else {
return this.edit();
}
}
view() {
return `
<span>${this.model.fname} ${this.model.lname}!</span>
`;
}
edit() {
return `
<div>
<div>First Name: <input type="text" data-change="fname" value="${this.model.fname}" /></div>
<div>Last Name: <input type="text" data-change="lname" value="${this.model.lname}" /></div>
</div>
`;
}
}
new Salutation();
Notes:
A GSState object is created with an initial value of 'EDIT'. The value is a string in this case but can be of any type. The state object has two primary methods,
get()
andset(value)
. Theget()
methods return the current value of the state. Theset(value)
method alters the current state to the passed in value and triggers a send to all subscribers (watch
).The main controller, 'Salutation', installs event handlers for 'click' and 'change'. This list can include any event type. Here we only need the two. The event listeners are install at the root of the controller's content. Child controllers do not need to specify event handlers since they enclosed with the parent controller's element. Events will bubble up to the parent controller but will be dispatch to the appropriate controller based on the event target.
The 'Salutation' controller instantiates a child controller of type 'Person' with a property name of 'person'. It's content contains a placeholder
div
element which reference the child controller by having an attribute
data-controllerand its value set to
person. This is where the content of the 'Person' controller is placed. In this case the placeholder
divelement is removed and replaced by the root element of the 'Person' controller because the 'Person' controller sets it 'displayMode' to 'REPLACE'. Note that the root element of Person controller in the view mode is a
spanelement. The placeholder
divis replace with a
span`.The 'Salutation' controller has a button element 'data-click' attribute with value of 'toggleView'. The controller also contains a 'toggleView' method which is passed the event as an argument. When the button is click the event is routed to the 'toggleView' method. Here the 'toggleView' method retrieves the current 'viewState', toggles the state value, renames the button caption and sets the 'viewState' to the new value.
The 'Person' controller constructor 'watches' the 'viewState' and repaints itself whenever it changes. It also provides for two versions of content depending on the current value of 'viewState'. Also unlike the 'Salutation' controller, the 'Person' controller does not have a 'paint' call with an argument of where to paint. This is because the parent controller, 'Salutation' handles this.
The 'edit()' method of the 'Person' controller contains two 'input' elements with 'data-change' attributes which are set to 'fname' and 'lname' respectively. Because the controller has a property name, 'model', value changes on those input elements are automatically routed into the models respective property. If the controller's model is named something other 'model' or the model object does not contain the property name specified in the attribute, then nothing happens. For example, if 'data-change' has a value of 'fisrtName' in lieu of 'fname', the change will not be captured. In this case, the change can be capture by having a 'firstName(evt) method in the controller' which would have to capture the new value from the event and update the model directly. In routing changes, methods are given priority over model properties.
Glue-Stix Objects
GSController
- The base controller for extending.GSControlList
- A typed array of 'GSController' objects.GSState
- A state signaling mechanism.GSPubSub
- A publish and subscribe message bus.GSDisplayMode
- An enumeration of display modes.GSDisplayPosition
- An enumeration of display positions used mostly with 'GSControllerList'GSInit()
- An optional initialization method.
GSController
constructor(config)
- The configuration argument is an optional object which can contain
eventSubscriptions
and/ordisplayMode
. eventSubscription
is an array of one or more event strings (e.g. 'click', 'change', 'drag', etc.) to be installed. If not specified, no event handlers are installed.displayMode
is aGSDisplayMode
value which determines the content relationship to the element passed to the paint method. If not specified GSDisplayMode.APPEND is used.
Example:
super({ eventSubscripts: ['click', 'change'], displayMode: GSDisplayMode.REPLACE});
$root
- The $root property is set/reset when the controller's
paint
orrepaint
is called. - It contains a reference to the controller's root DOM element.
delete()
The delete method tears down the controller and removes it.
- arguments: none.
- return value: none.
- It deletes all child controllers and control lists except a controller reference named
$parent
. The$parent
reference is set to null. - It unsubscribes from all GSState objects that it is watching.
- It unsubscribes from all GSPubSub objects this it has a subscription to.
- It remove the associated DOM elements.
Note that the child controllers go through the same tear down. Thus the whole controller tree is torn down recursively.
removeControllers()
The removeControllers method deletes child controllers and controller lists and sets their reference property to null
. This recursively removes the child controller tree. The controller from which this method is called remains.
- arguments: none.
- return value: none.
All child controllers are removed with the exception is child controllers whose property name begin with a $
are not deleted.
For example:
this.$person = new Person(this.model.person);
this.address = new Address(this.model.address);
this.phone = new Phone(this.model.phone);
this.email = new Email(this.model.email);
Calling removeControllers deletes the address, phone and email controllers but leave the $person controller in place.
The properties this.address
, this.phone
and this.email
are set to null. this.$person
is unchanged.
subscribe(publisher, pattern, handler)
The subscribe method subscribes the controller to a publication. The arguments are:
- publisher: The GSPubSub instance to subscribe to.
- pattern: A regex or string of topics we want to handle.
- handler: the callback handler.
- return value: none.
Example:
this.subscribe(connPub, /^.*$/, this.topicHandler);
watch(state, handler)
The watch method subscribes the controller to a GSState. The arguments are:
- state: The GSState instance to subscribe to.
- handler: The callback handler.
- return value: The current State.
Example:
this.watch(viewState, this.repaint);
Note. The watch method returns the current value of the state.
paint(at, replace)
The paint method inserts the controller's content into the DOM. The controller's content method is used to retrieve the content. The arguments are:
- at: The element or selector string of where the content will place.
- replace: (optional) Either true or false. This is used internally by the GSController.
Example:
this.paint('#my-content');
or
this.paint(document.getElementById('my-content'));
repaint()
The repaint method removes the cureent DOM elements and re-establish them by calling the content() method.
- arguments: none.
- return value: none.
Note: All child controllers are repainted as a result.
GSControlList
The GSControlList is a subclass of Array which can only contain GSController objects. An example of creating one:
constructor() {
super();
this.addresses = new GSControlList();
this.model.addresses.forEach(a => this.addresses.push(new Address(a)));
}
content() {
return `
...
<div data-controller="addresses"></div>
...
`;
}
- The rendering of controllers in the control list is the order they are in the array.
- The controllers in the list should have their displayMode set or defaulted to GSDisplayMode.APPEND.
constructor()
Creates a new GSControlList object.
- arguments: none.
delete()
Calls delete on each of controllers in the list.
clear()
Override of the Array clear method which deletes each of the controllers in the list.
paint(at)
Paints each controller in the list at the element defined by the at
argument.
- at: The element or selector string of where the content will place.
remove(controller)
Removes a controller from the list. The delete method of the controller is called before removing it. -controller: An instance of the controller to remove.
push(controller)
Adds a controller to the bottom of list. -controller: An instance of the controller to add.
unshift(controller)
Adds a controller to the top of the list. -controller: An instance of the controller to add.
add(controller, position, refIndex)
Adds a controller to the at a specified order in the list.
-controller: An instance of the controller to removed.
-position: A GSDisplayPosition
value which can be TOP
, BOTTOM
, ABOVE
or BELOW
.
-refIndex: The index used to reference ABOVE
and BELOW
insertion. Otherwise it is ignored and can be omitted.
Note: If position and refIndex are omitted, the controller is added at the bottom (same as using add or push).
moveUp(index)
Moves the controller at the specified index up one place in the list.
moveDown(index)
Moves the controller at the specified index down one place in the list.
GSPubSub
constructor()
Creates a new GSPubSub object.
- arguments: none.
publish(topic, data)
Publishes the topic and data.
- topic: A string containing the topic value;
- data: The data can be any type.
Example:
const myPubSub = new PubSub();
myPubSub.publish('HELLO', { fname: "joe", lname: "Smith"});
myPubSub.publish('HOWDY', { fname: "Sue", lname: "Jones"});
In some controller:
constructor() {
super();
...
this.subscribe(myPubSub, /HELLO|HOWDY/, this.sayHi);
}
sayHi(topic, data) {
if(topic == 'HELLO') {
alert(`GREETINGS ${data.fname} ${data.lname}!`);
} else {
alert(`${topic} ${data.fname} ${data.lname}!`);
}
}
GSState
constructor(value)
Creates a new GSState object.
- value: The init1al state. Can be any type.
set(value)
Sets the state value.
- value: The new state. Can be any type.
get()
-return value: The current state
GSDisplayMode
Enumeration of display modes for the insertion of controller content.
- APPEND: The content is appended to specified element.
- REPLACE: The content replaces the specified element.
- PREPEND: The content is first child in specified element.
GSDisplayPosition
Enumeraton of the position of a controller in a ControlList array.
- TOP: Add at the top.
- BEFORE: Add before specified index.
- AFTER: Add after specified index.
- BOTTOM: Add at the bottom.
GSInit() OPTIONAL
This function creates a MutationObserver
which scans the removed DOM elements from the body. It will remove the bindings between the controller object and it DOM content if present. This is done when controller's delete method is called making this function unneccessary. However, you may want to use this if you experience memory leakage. This function only needs to be called once.
Further Considerations
$parent
When dealing with childen controllers, there may be a need for a child controller directly call a method in the parent. For example:
In the parent controller:
constructor() {
super();
this.addresses = new GSControlList();
this.model.addresses.forEach(a => this.addresses.push(new Address(this, a)));
}
content() {
return `
...
<div data-controller="addresses"></div>
...
`;
}:
removeAddress(ctrl) {
this.addresses.remove(ctrl);
}
In the Address controller:
constructor(parent, model) {
super();
this.$parent = parent;
this.model = model;
}
content() {
return `
...
<div>
<button type="button" data-click="remove">Remove Me</button>
</div>
...
`;
}:
remove() {
this.$parent(removeAddress(this));
}
It is important
that the parent reference property be named $parent
! Otherwise the parent will not be protected from deletion by the child. If the parent property is named something other that $parent
, a maximum iteration error will occur. This results from the child deleting the parent which in turn the parent deletes the child. A endless loop occurs. Holding a reference to the parent is fine. Just name it $parent
.
Marshaling changes to Model Properties
The data-change
attribute can have its value set to either a method or a model property name. The following are elements and types that can be used:
- input
- textarea
- select select-one
If the captured value is not what you expect (e.g. an input type submit), use a method instead.
repaint
When the repaint method is called on a controller, the associated DOM element ($root
) is removed first. If the controller has aggregated children controllers, their content is removed as well because their content is nested within the parent's content. The controller is the repainted at the same DOM location and the $root
is updated. However, this means all of the child controllers will repainted as well. This is normal and to be expected. However, consider the following example
In the parent controller.
constructor(model) {
super();
this.model = model;
this.person = new Person(this, model.person);
this.address = new Address(this, model.address);
}
content() {
return `
...
<div data-controller="person"></div>
<div data-controller="address"></div>
...
`;
}:
In the Person controller.
constructor(parent, model) {
super();
this.$parent = parent
this.model = model;
this.person = new Person(this, model.person);
this.address = new Address(this, model.address);
}
content() {
return `
...
<button type="button" data-click="doSomething()">Do Something</button>
...
`;
}:
doSomething(evt) {
// The event is processed and we detemine we need to be repainted.
// We could do this.
$parent.repaint(); // DON'T DO THIS!
// Or we could do this.
this.repaint(); // DO THIS!
}
- Both will work but calling repaint on the parent will cause the parent, person and address controllers to be repainted.
- Keeping repaints local improves the performance.
Cleaning formatted HTML.
In the examples above, I formatted the markup within the string literal template. This does improve the readability. However, minification will not remove the whitespace between tags. There are two NPM packages which can help this. html-template-cleaner
and vite-plugin-html-template-cleaner
.
4 months ago