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.
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!)
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.
Magnus isn't very opinionated, but does have some ideas on how HTML and JavaScript should be written.
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
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.
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();
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 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.
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 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.
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 |
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
}
}
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
}
}
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 matchtalin.first
to avoid unwanted effects in controller data stores.
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.
<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! -->
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-property
→ myProperty
.
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.
<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);
}
}
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.
… it was Magnus who created the schematics and diagrams needed to construct the mortal plane.
— Brother Mikhael Karkuxor, Varieties of Faith in Tamriel 📚
MIT licensed, natch 😉