1.3.0 • Published 4 months ago

glue-stix v1.3.0

Weekly downloads
-
License
ISC
Repository
-
Last release
4 months ago

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 case new 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() and set(value). The get() methods return the current value of the state. The set(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 divelement which reference the child controller by having an attributedata-controllerand its value set toperson. This is where the content of the 'Person' controller is placed. In this case the placeholderdivelement 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 aspanelement. The placeholderdivis replace with aspan`.

  • 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/or displayMode.
  • 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 a GSDisplayMode 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 or repaint 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.
  1. It deletes all child controllers and control lists except a controller reference named $parent. The $parent reference is set to null.
  2. It unsubscribes from all GSState objects that it is watching.
  3. It unsubscribes from all GSPubSub objects this it has a subscription to.
  4. 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.