alpinejs-app
v1.4.3
Published
Simple SPA plugin for Alpinejs
Readme
alpinejs-app
A Simplest Single Page Application (SPA) library for Alpine.js.
The Vision
To maintain a clear separation between HTML and JavaScript logic.
This separation keeps presentation distinct from logic as much as possible and have as few abstractions as possible.
Develop layout with HTML/CSS and the logic with Alpine.js for two-way data bindings and reactivity.
To have some kind of central registry of HTML templates and Javascript classes and use it to register components.
The registry is just 2 properties in the global
appobject,app.templatesandapp.components.Server-side rendering of templates (similar to
htmx) is supported out of the box, same directives render bundled or remote HTML templates the same way without any external dependencies.How the registry is delivered to the browser depends on bundling or application, for example:
Native support:
- esbuild: bundle everything into a single file by converting HTML files to strings: see
scripts/build-app.jsfor a simpleesbuildplugin - server-side: maintain HTML files on the server to load individually on demand using the same directives, see
examples/dashboard.html.
More ideas:
- keep HTML templates in JSON files to load separately via fetch on demand or via importmap
- include your .js files in the HTML for small apps and let the browser to handle caching
- esbuild: bundle everything into a single file by converting HTML files to strings: see
Features
Components: These are the primary building blocks of the UI. They can be either standalone HTML templates or HTML backed by JavaScript classes, capable of nesting other components.
Main Component: Displays the current main page within the
#app-mainHTML element. It manages the navigation and saves the view to the browser history usingapp.savePath.History Navigation: Supports browser back/forward navigation by rendering the appropriate component on window
popstateevents, achieved usingapp.restorePath.Direct Deep-Linking: For direct access, server-side routes must redirect to the main app HTML page, with the base path set as '/app/' by default.
Live demo is available at demo. External rendering is not working due pages only serving static contnt, local demo shows server-side rendering.
Installation
npm
npm install alpinejs-appcdn
These must be placed before your bundle.
<script src="https://unpkg.com/[email protected]/dist/app.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js" defer></script>git
git clone https://github.com/vseryakov/alpinejs-app.gitGetting Started
Here is how to build very simple Hello World app.
mkdir app && cd app
npm install alpinejs-app
sh ./node_modules/alpinejs-app/scripts/demo.shThe files shown below will be created in the app folder, this is to save you from copy/pasting. The classes and styles are stripped for brivity, the actual demo may contain some styles to make it look nicer.
The package.json is also created, now let's build our silly app and see how it works.
npm install
npm run watchPoint your browser to http://localhost:8090/ to see it in action.
index.html
<head>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js" defer></script>
<script src="bundle.js"></script>
</head>
<body>
<div id="app-main"></div>
</body>
<template id="index">
<h5>This is the index component</h5>
<button x-render="'hello/hi?reason=World'">Say Hello</button>
</template>
<template id="hello">
<h5>This is the <span x-text=$name></span> component</h5>
<p>
First param from render url: <span x-text="params.param1"></span>
</p>
<p>
Named param reason: <span x-text="params.reason"></span>
</p>
<div x-template.show="template"></div>
<button @click="toggle">Toggle</button>
<button x-render="'index'">Back</button>
</template>
example.html
<div>
This is the example template
</div>index.js
import 'alpinejs-app/dist/app.js'
import './hello'
import './example.html'
app.debug = 1
app.start();hello.js
app.components.hello = class extends app.AlpineComponent {
template = ""
toggle() {
this.template = !this.template ? "example" : "";
}
}What is happening:
- The script defines a template and a component,
app.startcallsapp.restorePathwhen the page is ready, defaulting to renderindexsince the static path doesn’t match. (running locally with file:// origin will not replace history) - The body includes a placeholder for the main app.
- The
indextemplate is the default starting page, with a button to display thehellocomponent with parameters. - Clicking 'Say Hello' switches the display to the
hellocomponent viax-renderdirective. - The
hellocomponent is a class extendingapp.AlpineComponentwith a toggle function. - A
x-templatedirective remains empty until thetemplatevariable is populated by clicking the 'Show' button, which triggers the component's toggle method to render theexampletemplate in the contained div. The.showmodifier keeps the div hidden until template is set. - The
exampletemplate is defined in theexamples/example.htmlfile and bundled into bundle.js.
Nothing much, all the work is done by Alpine.js actually.
Directive: x-template
Render a template or component inside a container from the expression which must return a template URL/name or nothing to clear the container.
This is an alternative to the x-if Alpine directive especially with multiple top elements because x-if only supports one top element and
ablity to render server-side templates.
If the expression is URL like, i.e. https?:// or /path or file.html it will be retrieved from the
server first and then rendered into the target.
To cache the HTML use the modifier cache, the path base name is used by default for cached template.
<div x-template="template"></div>
<div x-template="show ? 'index' : ''"></div>
<div x-template.cache="show ? '/path/index.html' : ''"></div>Modifiers:
show- behave asx-show, i.e. hide if no template and display if it is set,x-template.show="..."nonempty- force to hide the container if it is empty even if the expression is not, only works withshowflex- set display to flex instead of blockinline- set display to inline-block instead of blockimportant- apply !important similar tox-showparams.opts- pass an objectoptsin the current scope as theparamsto the component, similar toapp.render({ name, params: opts })cache- cache external templates by name, the name is either the file name or special param$namepost- use the POST method when retrieving external template
The directive supports special parameters to be set in this.params, similar to x-render, for example:
<div x-template="'docs?$nohistory=1&id=123'">Show</div>The component docs will have this.params.$nohistory set to 1 on creation and params.id=123
The other way to pass params to be sure the component will not use by accident wrong data is to use modifier .params.NAME
<div x-data="{ opts: { $nohistory: 1, docid: 123 } }">
<div x-template.params.opts="'docs'">Show</div>
</divThe component docs will have opts as the this.params.
For HTML only templates the params passed in the url can be accessed via the $params magic, see below for examples.
Directive: x-render
Binds to click event to display components. Can render components by name, path, or URL with parameters using app.parsePath.
Nothing happens in case the expression is empty. Event's default action is cancelled automatically.
If the expression is URL like, i.e. https?:// or /path or file.html it will be retrieved from the
server first and then rendered into the target.
To cache the HTML use the modifier cache.
All query parameters will be stored in the component's params object or template's $params magic.
Special options include:
$target- to define a specific container for rendering, it is always set in the params even if empty$history- to explicitly manage browser history.$name- a name to be used to cache external templates
Modifiers:
stop- stop event propagationcache- cache external templates by name, the name is either the file name or special param$namepost- use the POST method when retrieving external template
<a x-render="'hello/hi?reason=World'">Say Hello</a>
in the hello component `this.params.reason` will be set with 'World'
<button x-render="'index?$target=#div'">Show Bundled or Cached</button>
<button x-render.cache="'/index.html?$target=#div'">Show External</button>Directive: x-scope-level
Reduce data scope depth for the given element, it basically cuts off data inheritance at the requested depth. Useful for sub-components not to interfere with parent's properties. In most cases declaring local properties would work but limiting scope for children might as well be useful.
<div x-scope-level></div>
<div x-scope-level=1></div>Magic: $app
The $app object is an alias to the global app object to be called directly in the Alpine.js directives.
<a @click="$app.render('page/1/2?$target=#section')">Render Magic</a>
<a @click="$app.render({ name: 'page', params: { $target: '#section' }})">Render Magic</a>Magic: $component
The $component magic returns the immediate component. It may be convenient to directly call the component method or access property
to avoid confusion with intermediate x-data scopes especially if same property names are used.
<a @click="$component.method()">Component Method</a>Magic: $parent
The $parent magic returns a parent component for the immediate component i.e. a parent for $component magic.
<a @click="$parent.method()">Parent Method</a>Magic: $params
The $params magic returns the params object from the current or first parent element,
it works for both components and HTML only templates.
The params object is query parameters passed via URL or an object passed directly to app.render.
<a x-render="'hello?$target=h&t='+Date.now()">Show</a>
<template id="hello">
This template was rendered at <span x-text="new Date(parseInt($params.t))"></span>
</div>Component Lifecycle and Event Handling
While Alpine.js has several ways how to reuse the data this app makes it more unified, it is opinionated of course.
Here is the life-cycle of a component:
on creation a component calls
onCreatemethod if exists. it can be created in derived class to implement custom initialization logic and create properties. At this time the context is already initialized, theparamsproperty is set with parameters passed in the render call and event handler forcomponent:eventis registered on the app.The
component:createevent is broadcasted with an object { name, element, params, component }when a component is removed from the DOM
onDeletemethod is called to cleanup resources like event handlers, timers...app event
component:eventis sent to all live components, this can be used to broadcast important events happening and each component will decide what to do with it. This is uses app event emitter instead of DOM events to keep it separate and not overload browser with app specific messages.
Using the example above:
Add a new button to the index template:
<button x-render="'hello2'">Say Hello2</button>Introduce another component:
hello.js
app.templates.hello2 = "#hello"
app.components.hello2 = class extends app.components.hello {
onCreate() {
this.params.reason = "Hello2 World"
this._timer = setInterval(() => { this.params.param1 = Date() }, 1000);
}
onDelete() {
clearInterval(this._timer)
}
onToggle(data) {
console.log("received toggle event:", data)
}
toggle() {
super.toggle();
app.emit(app.event, "toggle", this.template)
}
}Changes to the previous example:
- A
hello2template referencing existinghellofor shared markup. - A new
hello2component extending fromhello, showcasing class inheritance.
The hello2 component takes advantage of the lifecycle methods to customize the behaviour:
onCreatesets up initialization like overriding reasons and running a timer.onDeletemanages cleanup by stopping timers.togglemethod reuses the toggling but adds broadcasting changes via events.
For complete interaction, access live demo at the index.html.
Custom Elements
Component classes are registered as Custom Elements with app- prefix,
using the example above hello component can be placed inside HTML as <app-hello></app-hello>.
See also how the dropdown component is implemented.
Examples
The examples/ folder contains more components to play around and a bundle.sh script to show a simple way of bundling components together.
Simple bundled example: index.html
An example to show very simple way to bundle .html and .js files into a single file.
To run live example do npm run watch.
Then go to http://localhost:8090/ to see the example with router and external rendering.
Esbuild app plugin
The scripots/esbuild-html.js script is an esbuild plugin that bundles templates from .html files to be used by the app.
Running node build.js in the examples folder will generate the bundle.js which includes all .js and .html files used by the index.html
API
Global settings
base: "/app/"Defines the root path for the application, must be framed with slashes.
index: "index"Specifies a fallback component for unrecognized paths on initial load, it is used by
app.restorePath.templates: {}HTML templates, this is the central registry of HTML templates to be rendered on demand, this is an alternative to using
<template>tags which are kept in the DOM all the time even if not used.This object can be populated in the bundle or loaded later as JSON, this all depends on the application environment.
components: {}Component classes, this is the registry of all components logic to be used with corresponding templates. Only classed derived from
app.AlpineComponentwill be used, internally they are registered withAlpine.data()to be reused by name.$target: "#app-main"Central app container for rendering main components.
Rendering
app.render(options, dflt)Show a component,
optionscan be a string to be parsed byparsePathor an object{ name, params }.if no
params.$targetprovided a component will be shown inside the main element defined byapp.$target.It returns the resolved component as described in
resolvemethod after rendering or nothing if nothing was shown.When showing main app the current component is asked to be deleted first by sending an event
prepare:delete, a component that is not ready to be deleted yet must set the propertyevent.stopin the event handleronPrepareDelete(event)in order to prevent rendering new component.To explicitly disable history pass
options.$nohistoryorparams.$nohistoryotherwise main components are saved automatically by sending thepath:saveevent.A component can globally disable history by creating a static property
$nohistoryin the class definition.To disable history all together set
app.$nohistory = true.app.resolve(path, dflt)Returns an object with
templateandcomponentproperties:{ name, params, template, component }.Calls
app.parsePathfirst to resolve component name and params.Passing an object with
templateset will reuse it, for case when template is already resolved.The template property is set as:
- try
app.templates[.name] - try an element with ID
nameand use innerHTML - if not found and
dfltis given try the same with it - if template texts starts with # it means it is a reference to another element's innerHTML,
otemplateis set with the original template before replacing thetemplateproperty - if template text starts with $ it means it is a reference to another template in
app.templates,otemplateis set with the original template before replacing thetemplateproperty
The component property is set as:
- try
app.components[.name] - try
app.components[dflt] - if resolved to a function return
- if resolved to a string it refers to another component, try
app.templates[component],ocomponentis set with the original component string before replacing thecomponentproperty
if the
componentproperty is empty then this component is HTML template.- try
Router
app.startSetup default handlers:
- on
path:restoreevent callapp.restorePathto render a component from the history - on
path:savecallapp.savePathto save the current component in the history - on page ready call
app.restorePathto render the initial page
If not called then no browser history will not be handled, up to the app to do it some other way.. One good reason is to create your own handlers to build different path and then save/restore.
- on
app.restorePath(path)Show a component by path, it is called on
path:restoreevent by default fromapp.startand is used to show first component on initial page load. If the path is not recognized or no component is found then the defaultapp.indexcomponent is shown.app.savePath(options)Saves the given component in the history as
/name/param1/param2/param3/....It is called on every
component:createevent for main components as a microtask, meaning immediate callbacks have a chance to modify the behaviour.app.parsePath(path, dflt)Parses component path and returns an object with at least
{ name, params }ready for rendering. External urls are ignored.Passing an object will retun a shallow copy of it with name and params properties possibly set if not provided.
The path can be:
- component name
- relative path: name/param1/param2/param3/....
- absolute path: /app/name/param1/param2/...
- URL: https://host/app/name/param1/...
All parts from the path and query parameters will be placed in the
paramsobject.The
.htmlextention will be stripped to support extrernal loading but other exts will be kept as is.
DOM utilities
app.$ready(callback)Run callback once the document is loaded and ready, it uses setTimeout to schedule callbacks
app.$(selector[, doc])An alias to
document.querySelector, doc can be an Element, empty or non-string selectors will return nullapp.$on(element, event, callback)An alias for
element.addEventListenerapp.$elem(name, ...arg)Create a DOM element with attributes,
-namemeansstyle.name,.namemeans a propertyname, all other are attributes, functions are event listenersapp.$elem("div", "id", "123", "-display", "none", "._x-prop", "value", "click", () => {})app.$elem(name, object [, options])Similar to above but all properties and attributes are taken from an object, in this form options can be passed, at the moment only options for addEventListener are supported.
app.$elem("div", { id: "123", "-display": "none", "._x-prop": "value", click: () => {} }, { signal })app.$parse(text, format)A shortcut to DOMParser, default is to return the .body.
Second argument defined the result format:
list- the result will be an array with all body child nodes, i.e. simpler to feed it to Element.append()doc- return the whole parsed document
document.append(...app.$parse("<div>...</div>"), 'list'))app.$empty(element, cleanup)Remove all nodes from the given element, call the cleanup callback for each node if given
app.$append(element, template, setup)Append nodes from the template to the given element, call optional setup callback for each node.
The
templatecan be a string with HTML or a<template>element.```app.$append(document, "<div>...</div>")```app.$data(element, level)Return component data instance for the given element or the main component if omitted. This is for debugging purposes or cases when calling some known method is required.
if the
levelis not a number then the closest scope is returned otherwise only the requested scope at the level or undefined. This is useful for components to make sure they use only the parent's scope for example.This returns Proxy object, to get the actual object pass it to
Alpine.raw(app.$data())
Event emitter
The app implements very simple event emitter to handle internal messages separate from the DOM events.
There are predefined system events:
alpine:init- is sent on document alpine:init event from Alpine. Initializes custom elements at the moment, in case some components were loaded after app and Alpine loaded it is necessary to emit it again viaapp.emit('alpine:init')path:restore- is sent by the windowpopstateevent fromapp.startpath:save- is sent byapp.renderfor main components onlypath:push- is sent just before callinghistory.pushStatewith the path to be pushedcomponent:create- is sent when a new component is created, { type, name, element, params }component:delete- is sent when a component is deleted, { type, name, element, params }component:event- a generic event defined inapp.eventis received by every live component and is handled byonEvent(...)method if exist. Then convert the first string argument into camel format likeonFirstArg(...)and call this method if exists
Methods:
app.on(event, callback, [namespace])Listen on event, the callback is called synchronously, optional namespace allows deleting callbacks later easier by not providing exact function by just namespace.
app.once(event, callback, [namespace])Listen on event, the callback is called only once
app.only(event, callback, [namespace])Remove all current listeners for the given event, if a callback is given make it the only listener.
app.off(event, callback)Remove all event listeners by event name and exact callback function
app.off(event, namespace)Remove all event listeners by event name and namespace
app.off(namespace)Remove all event listeners by namespace
app.emit(event, ...args)Send an event to all listeners at once, one by one.
If the event ends with
:*it means notify all listeners that match the beginning of the given pattern, for example:`app.emit("topic:*",....)` will notify `topic:event1`, `topic:event2`, ...
General utilities
app.call(obj, method, ...arg)Call a function safely with context and arguments:
- app.call(func,..)
- app.call(context, func, ...)
- app.call(context, method, ...)
app.fetch(options, callback)Fetch remote content, wrapper around Fetch API, options can be a string URL or an object compatible with $.ajax:
- method - GET, POST,...GET is default (also can be specified as post: 1)
- body - a body, can be a string, an object, FormData
- dataType - explicit return type: text, blob, default is auto detected between text or json
- headers - an object with additional headers to send
- options - properties to pass to fetch options according to
RequestInit
The callback(err, data, info) - where info is an object { status, headers, type }
app.afetch(options)Promisified
app.fetchwhich returns a Promise, all exceptions are passed to the reject handler, no need to use try..catch Return everything in an object{ ok, status, err, data, info }.const { err, data } = await app.afetch("https://localhost:8000")Compatibility with native fetch:
const res = await app.afetch("https://localhost:8000") if (!res.ok) console.log(res.status, res.err);app.trace(...)if
app.debugis set then it will log arguments in the console
Plugins
app.plugin(name, options)Register a render plugin, at least 2 functions must be defined in the options object:
render(element, options)- show a component, called byapp.rendercleanup(element)- optional, run additional cleanups before destroying a componentdata(element)- return the component class instance for the given element or the maindefault- if not empty make this plugin defaultComponent- optional base component constructor, it will be registered as app.{Type}Component, like AlpineComponent, KoComponent,... to easy create custom components
The reason for plugins is that while this is designed for Alpine.js, the idea originated by using Knockout.js with this system, the plugin can be found at app.ko.js.
There is a simple plugin in
examples/simple.jsto show how to use it without any rendering engine with vanillla HTML, not very useful though.
Author
Vlad Seryakov
License
Licensed under MIT
