Skip to content

A JavaScript framework for developers who like HTML.

License

Notifications You must be signed in to change notification settings

oscarpalmer/magnus

Repository files navigation

Magnus

npm

A JavaScript framework for developers who like HTML.

It's basically a lightweight version of Stimulus, so you may want to use that lovely framework instead.

Quick start

To quickly get started, Magnus needs some nice HTML:

<div data-controller="talin">
  <input data-target="talin.input" type="text" />

  <button data-action="click->talin@greet">Greet</button>

  <pre data-target="talin.output"></pre>
</div>

… and some JavaScript:

import {Controller} from 'magnus';

export class Talin extends Controller {
  greet(): void {
    this.targets.get('output').textContent = this.targets.get('input').value;
  }
}

… and that's it. Entering any value in the input field and then clicking the button will display the same value below the button. Awesome!

(Thanks to Stimulus for the example, which looks mostly the same for Magnus!)

Installation

Magnus is available on NPM as @oscarpalmer/magnus and works well with JavaScript-bundlers, or you may use it completely standalone using the file in the dist-folder.

How Magnus works

Magnus isn't very opinionated, but does have some ideas on how HTML and JavaScript should be written.

Lifecycles

Magnus is built on top of mutation observers, which are objects that watch for changes in the DOM, allowing Magnus to connect specific element and attribute changes to event handlers within Magnus.

These event handlers are then able to:

  • create (and remove) controller instances that can react to interactivity in the DOM
  • create (and remove) targets (element groups) from controllers
  • create (and remove) actions (controller-specific event handlers) that handle interactivity for the controller
  • create (and remove) input- and output-targets, which are built-in targets (with actions) for handling reactivity for the controller

Controller lifecycle events

As controllers can be created and destroyed, they may also need to react to such events. When a controller is created, it will call the connect-method; when it's destroyed, it will call the disconnect-method.

Startup

To get things going with Magnus, all we need to do is import the application, like below:

import {magnus} from '@oscarpalmer/magnus'; // And it will automatically start and observe the document

// Stops observing the document element
application.stop();

// Begins to observe the document element again
application.start();

Controllers

Magnus controllers should be regular JavaScript classes, while also extending the base class available in Magnus.

import {Controller} from '@oscarpalmer/magnus';

export class Talin extends Controller {}

To allow Magnus to create instances of a controller, it must also be added to Magnus with a name, which is used when evaluating the data-controller-attribute for elements.

import {magnus} from '@oscarpalmer/magnus';
import {Talin} from 'talin';

// Add controller to Magnus with a name to watch for in observer
magnus.add('talin', Talin);

When a controller has been instantiated, it will call its connect-method.

When a controller is removed, either by having its element removed from the DOM or by modifying the element's data-controller-attribute, it will stop observing the element, as well as remove all actions and targets from itself, and finally call the disconnect-method.

The same goes for other attributed elements, as well: e.g., modifying an element's data-action-attribute (or removing the element completely) will then remove the element and its relevant information from the controllers it has been connected to.

Targets

Targets are elements in your controller that are useful to have quick and easy access to. Targets are defined by the attribute data-target.

The value for the attribute should be a string of space-separated names, allowing for an element to be part of multiple target groups (and multiple controllers!)

To map the target to your controller, you may use talin.output, where talin is the name of your controller and output is the name of the target group, and Magnus will attempt to find controller closest to your element.

If you wish to map the target to a specific controller, you may use talin#id.output, where talin and output remain the same as above, but id points to an element with the same ID.

Target example

Define your targets in HTML:

<span data-target="talin.output"></span>

And access them in JavaScript:

import {Controller} from 'magnus';

export class Talin extends Controller {
  // Custom method for showcasing built-in target-methods
  getTargets(): void {
    // Returns the first target (or undefined, if none exists)
    const targets = this.targets.get('output')

    // Returns an array of targets
    const targets = this.targets.getAll('output')

    // Returns true if at least one target exists
    const has = this.targets.has('output')

    // Finds and returns an array of elements within the controller (not just targets!),
    // and accepts whatever 'querySelectorAll' will take
    const found = this.targets.find('pre');
  }
}

The methods above (except has) also accepts an optional type for easier management of whatever is found and returned, e.g., this.targets.find<HTMLButtonElement>('button') for retrieving a list of properly typed button-elements.

Actions

Actions are events for elements within a controller and are defined by the attribute data-action.

The value for the attribute should be a space-separated string of actions, where each action should match any of the following:

  • controller@method
  • controller@method:options
  • controller#id@method
  • controller#id@method:options
  • event->controller@method
  • event->controller@method:options
  • event->controller#id@method
  • event->controller#id@method:options
  • external@event->controller@method
  • external@event->controller@method:options
  • external@event->controller#id@method
  • external@event->controller#id@method:options
  • external#identifier@event->controller@method
  • external#identifier@event->controller@method:options
  • external#identifier@event->controller#id@method
  • external#identifier@event->controller#id@method:options

Phew, that's a lot, but it helps Magnus do a lot of cool stuff automagically with your events.

Action parameters

Part Required Description
controller Name of controller
method Name of controller method
options Event options; a colon-separated string that may contain:
• a or active for allowing preventDefault
• c or capture for capturing events
• o or once for handling event once
id ID for element that has the controller
event Event name
(Whenever event is omitted, Magnus will try to interpret a default event type based on the element)
external External target, either window, document, a controller, or an element ID
identifier When included, identifier implies that external should be a controller and is used to identify a unique element using the controller

Action example

Define your actions in HTML:

<button data-action="click@greet:once">Greet</button>

And their methods in JavaScript:

import {Controller} from 'magnus';

export class Talin extends Controller {
  greet(): void {
    // Called on a click event once
  }
}

Dispatching actions

Dispatching actions example
import {Controller} from 'magnus';

export class Talin extends Controller {
  trigger(): void {
    this.actions.dispatch('event'); // Dispatches an event on the controller's element

    this.actions.dispatch('event', target);
    // Dispatches an event for the `target`:
    // - a string, to find the first existing target in the controller
    // - an `EventTarget`, i.e., the document, window, or an element

    this.actions.dispatch('event', options, target?)
    // Dispatches an event on the controller's element (or target)
    // `options` allow for bubbling, cancellation, composition,
    // and may hold details to pass along with the event
  }
}

Input & output

Inputs and outputs are built-in targets that allow for reactivity in a controller, by listening to change events on input-targets - using the attribute data-input - and outputting values into output-targets, using the attribute data-output.

To map such a target to your controller, you may use talin.message, where talin is the name of your controller and message is the key in the controller's data, and Magnus will attempt to find controller closest to your element.

If you wish to map the target to a specific controller, you may use talin#id.message, where talin and message remain the same as above, but id points to an element with the same ID.

Whenever the value for an input-target changes, the new value will be stored in the controller's data, update the contents of output-targets (using the same key), as well as update the values of input-targets (using the same key).

Important

Unlike regular targets, these special ones will only try to map the first proper attribute value, i.e., talin.first talin.second will only match talin.first to avoid unwanted effects in controller data stores.

Inputting & outputting JSON

If you have any kind of object you wish to edit or display, either the complete data for a controller or a key-based value in the controller's data, you may do so with the :json-suffix in the attribute value, e.g., talin.object:json, and Magnus will do its best to handle whatever JSON-y data you're working with.

Input & output example

<div>
  <label for="message">Message</label>
  <textarea id="message" data-input="talin.message"></textarea>
  <!-- The textarea now automatically responds to input events... -->
</div>
<pre data-output="talin.message"></pre>
<!-- ... and updates the formatted block! -->

Data

Magnus is also able to handle simple, mostly-flat data structures, as well as respond to changes when needed.

Data can be initialized for a controller using attributes on your controller element, e.g. data-talin-name, where name is the key for the value to store, and its value is the actual data value (of any type!). If the name contains dashes or underscores, it will be converted to its camel-cased variant in the controller, i.e., data-talin-my-propertymyProperty.

To access the data structure for retrieving and storing information, the controller has the property data which returns a Proxy-object, so be mindful of how you retrieve and store nested objects.

When storing values, Magnus will first: update the attribute as set in the HTML; and second: update input- and output-targets and set their contents and values respectively.

Data example

<div data-controller="talin" data-talin-my-cool-property="and a value"></div>
import {Controller} from 'magnus';

export class Talin extends Controller {
  // Custom method accessing your custom data property
  onAlert(): void {
    alert(this.data.myCoolProperty);
  }
}

Custom data models

Magnus also lets you set a custom type for your data model to allow for nicer management of your controller's data. This can be done by extending the base controller with your type, e.g. class Talin extends Controller<MyCustomDataModel> where MyCustomDataModel is your nicely structured interface.

The name

… it was Magnus who created the schematics and diagrams needed to construct the mortal plane.

— Brother Mikhael Karkuxor, Varieties of Faith in Tamriel 📚

License

MIT licensed, natch 😉

About

A JavaScript framework for developers who like HTML.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published