Skip to content

Commit

Permalink
Add Conductors for managing multiple elements
Browse files Browse the repository at this point in the history
  • Loading branch information
croxton committed Dec 21, 2023
1 parent a9da6ee commit 969a1d2
Show file tree
Hide file tree
Showing 12 changed files with 479 additions and 16 deletions.
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

### A minimalistic component framework for htmx.

Booster Pack for [htmx](https://github.com/bigskysoftware/htmx) helps with managing your own and third-party scripts, especially when using the powerful [hx-boost](https://htmx.org/attributes/hx-boost/) attribute. Since htmx can effectively turn a website into a single page app, it’s easy to get into a muddle when trying to (re)instantiate and destruct scripts, particularly when it comes to history navigation. This framework provides a simple component lifecycle to wrap your logic in; it allows you to load scripts on demand rather than up-front, and avoid memory leaks. No bundler required, all you need is `<script>`.
Booster Pack for [htmx](https://github.com/bigskysoftware/htmx) helps with managing your own and third-party scripts, especially when using the powerful [hx-boost](https://htmx.org/attributes/hx-boost/) attribute. Since htmx can effectively turn a website into a single page app, it’s easy to get into a muddle when trying to (re)instantiate and destruct scripts as they user navigates your app. This framework provides a simple component lifecycle to wrap your logic in; it allows you to load scripts on demand rather than up-front, and avoid memory leaks. No bundler required, all you need is `<script>`.

You can try it out online with StackBlitz:
https://stackblitz.com/github/croxton/htmx-booster-pack
Expand Down Expand Up @@ -43,7 +43,7 @@ A core tenet of htmx is to inline implementation details, so that the behaviour
1. Include `booster.min.js` in the `<head>` of your page, right after `htmx`:
```html
<script defer src="https://cdn.jsdelivr.net/gh/bigskysoftware/htmx@1.9.9/src/htmx.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/gh/croxton/htmx-booster-pack@1.0.9/dist/booster.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/gh/croxton/htmx-booster-pack@1.1.0/dist/booster.min.js"></script>
```

2. Create a folder in the webroot of your project to store your scripts, e.g. `/scripts/boosts/`. Add a `<meta>` tag and set the `basePath` of your folder:
Expand Down Expand Up @@ -131,7 +131,7 @@ npm i htmx-booster-pack

Use like this:
```js
import { Booster, BoosterExt, BoosterFactory, loadStrategies } from 'htmx-booster-pack';
import { Booster, BoosterExt, BoosterFactory, BoosterConductor, loadStrategies } from 'htmx-booster-pack';
```

You'll need to write your own factory to make components, so that your bundler can do code splitting and file hashing. See [/lib/boosterFactory.js](https://github.com/croxton/htmx-booster-pack/blob/main/lib/boosterFactory.js) for an example.
Expand Down Expand Up @@ -336,7 +336,6 @@ this.destroyState('component');
### Example class
#### html
```html
<div id="my-thing-1" data-booster="myThing" data-options='{"message":"Hello!"}'></div>
Expand Down Expand Up @@ -398,6 +397,19 @@ export default class MyThing extends Booster {
}
```
### Conductors
Conductors are a special type of Booster class for managing **multiple** elements matching a selector, rather than being attached to a individual elements via `data-booster=""` attributes. They can be a more efficient way to coordinate the behaviour of groups of separated elements, such as lazy loading images, or updating the active state of navigation menus. To load a conductor add a `conductors` array to your meta tag. Specify the conductor name, CSS selector, loading strategy and version for each conductor you want to register. A conductor is loaded and mounted using the specified strategy when its selector is detected in the dom, and unmounted (but not destroyed) when it is no longer found in the dom; as such, conductors retain any properties that you set on the class, unless you destroy them in `unmount()`.
```html
<meta name="booster-config" content='{
"basePath" : "/scripts/boosts/",
"conductors" : [
{ "conductor": "myClass1", "selector" : "[data-thing1]", "strategy": "eager", "version": "1" },
{ "conductor": "myClass2", "selector" : ".thing2", "strategy": "visible", "version": "1" },
]
}'>
```
## Loading strategies
Loading strategies allow you to load components asynchronously on demand instead of up-front, freeing up the main thread and speeding up page rendering.
Expand Down
68 changes: 68 additions & 0 deletions dist/booster-pack.min.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,76 @@ class BoosterFactory extends Booster {
});
}
}
class BoosterConductor extends BoosterFactory {
// Only loaded conductor instances
constructor(extension = "booster", conductors = []) {
super(extension);
__publicField(this, "registered", []);
// ALL registered conductors
__publicField(this, "loaded", []);
this.defaults = {
conductors
}, this.config = {
...this.defaults,
...this.config
}, this.config.conductors.forEach((conductor) => {
this.register(conductor);
});
}
mount() {
htmx.on("htmx:afterSettle", (htmxEvent) => {
htmx.config.currentTargetId = htmxEvent.target.id;
for (const [key, entry] of Object.entries(this.registered))
this.lifeCycle(entry);
}), htmx.on("htmx:historyRestore", (htmxEvent) => {
htmx.config.currentTargetId = null;
for (const [key, entry] of Object.entries(this.registered))
this.lifeCycle(entry);
});
}
unmount() {
}
/**
* Manage the conductor lifecycle
*
* @param {object} entry
*/
lifeCycle(entry) {
entry.conductor in this.loaded ? entry.selector && (document.querySelector(entry.selector) ? this.loaded[entry.conductor].mounted ? this.loaded[entry.conductor].refresh() : (this.loaded[entry.conductor].mount(), this.loaded[entry.conductor].mounted = !0) : this.loaded[entry.conductor].mounted && (this.loaded[entry.conductor].unmount(), this.loaded[entry.conductor].mounted = !1)) : entry.selector ? document.querySelector(entry.selector) && this.lazyload(entry) : this.lazyload(entry);
}
/**
* Register a conductor
*
* @param entry
* @param {string} conductor
* @param {string | null} selector
* @param {string | null} strategy
* @param {number} version
*/
register(entry, { conductor, selector = null, strategy = "eager", version = 1 } = entry) {
this.registered.push(entry), this.lifeCycle(entry);
}
/**
* Import a conductor and run its constructor
* We'll use lazy loading for the chunk file
*
* @param {object} entry
*/
lazyload(entry) {
let promises = loadStrategies(entry.strategy, entry.selector);
Promise.all(promises).then(() => {
import(
/* @vite-ignore */
`${this.config.origin}/${this.config.basePath}/${entry.conductor}.js?v=${entry.version}`
).then((lazyConductor) => {
this.loaded[entry.conductor] = new lazyConductor.default(entry.selector), this.loaded[entry.conductor].mounted = !0;
});
});
}
}
export {
Booster,
BoosterConductor,
BoosterExt,
BoosterFactory,
loadStrategies
Expand Down
68 changes: 68 additions & 0 deletions dist/booster.min.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,72 @@ class BoosterFactory extends Booster {
});
}
}
class BoosterConductor extends BoosterFactory {
// Only loaded conductor instances
constructor(extension = "booster", conductors = []) {
super(extension);
__publicField(this, "registered", []);
// ALL registered conductors
__publicField(this, "loaded", []);
this.defaults = {
conductors
}, this.config = {
...this.defaults,
...this.config
}, this.config.conductors.forEach((conductor) => {
this.register(conductor);
});
}
mount() {
htmx.on("htmx:afterSettle", (htmxEvent) => {
htmx.config.currentTargetId = htmxEvent.target.id;
for (const [key, entry] of Object.entries(this.registered))
this.lifeCycle(entry);
}), htmx.on("htmx:historyRestore", (htmxEvent) => {
htmx.config.currentTargetId = null;
for (const [key, entry] of Object.entries(this.registered))
this.lifeCycle(entry);
});
}
unmount() {
}
/**
* Manage the conductor lifecycle
*
* @param {object} entry
*/
lifeCycle(entry) {
entry.conductor in this.loaded ? entry.selector && (document.querySelector(entry.selector) ? this.loaded[entry.conductor].mounted ? this.loaded[entry.conductor].refresh() : (this.loaded[entry.conductor].mount(), this.loaded[entry.conductor].mounted = !0) : this.loaded[entry.conductor].mounted && (this.loaded[entry.conductor].unmount(), this.loaded[entry.conductor].mounted = !1)) : entry.selector ? document.querySelector(entry.selector) && this.lazyload(entry) : this.lazyload(entry);
}
/**
* Register a conductor
*
* @param entry
* @param {string} conductor
* @param {string | null} selector
* @param {string | null} strategy
* @param {number} version
*/
register(entry, { conductor, selector = null, strategy = "eager", version = 1 } = entry) {
this.registered.push(entry), this.lifeCycle(entry);
}
/**
* Import a conductor and run its constructor
* We'll use lazy loading for the chunk file
*
* @param {object} entry
*/
lazyload(entry) {
let promises = loadStrategies(entry.strategy, entry.selector);
Promise.all(promises).then(() => {
import(
/* @vite-ignore */
`${this.config.origin}/${this.config.basePath}/${entry.conductor}.js?v=${entry.version}`
).then((lazyConductor) => {
this.loaded[entry.conductor] = new lazyConductor.default(entry.selector), this.loaded[entry.conductor].mounted = !0;
});
});
}
}
new BoosterExt(BoosterFactory, "booster");
new BoosterConductor("booster");
10 changes: 8 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
<title>Home</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="booster-config" content='{ "basePath" : "/scripts/boosts/" }'>
<meta name="booster-config" content='{
"basePath" : "/scripts/boosts/",
"conductors" : [
{ "conductor": "unveil", "selector" : "[data-unveil]", "strategy": "eager", "version": "3" }
]
}'>
<link rel="stylesheet" href="/styles/styles.css" />
<script defer src="https://cdn.jsdelivr.net/gh/bigskysoftware/htmx@1.9.9/src/htmx.min.js"></script>
<script src="/main.js" type="module"></script>
Expand All @@ -20,13 +25,14 @@
<a href="/">Home</a>
<a href="/page2.html">Page 2</a>
<a href="/page3.html">Page 3</a>
<a href="/page4.html">Page 4</a>
</nav>
</header>
<main id="main" hx-history-elt>
<h1>Home page</h1>
<section>
<header>
<h2>Example htmx component: Celebrate</h2>
<h2>Example component: celebrate.js</h2>
</header>
<div id="celebrate" data-booster="celebrate" data-options='{"message":"Hello, stranger!"}'></div>
</section>
Expand Down
112 changes: 112 additions & 0 deletions lib/boosterConductor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import BoosterFactory from './boosterFactory.js';
import {loadStrategies} from './loadStrategies.js';

export default class BoosterConductor extends BoosterFactory {

registered = []; // ALL registered conductors
loaded = []; // Only loaded conductor instances

constructor(extension='booster', conductors = []) {
super(extension);

// register any conductors defined in the config
this.defaults = {
conductors: conductors,
}
this.config = {
...this.defaults,
...this.config
};
this.config.conductors.forEach(conductor => {
this.register(conductor);
});
}

mount() {
htmx.on('htmx:afterSettle', (htmxEvent) => {
htmx.config.currentTargetId = htmxEvent.target.id;
for (const [key, entry] of Object.entries(this.registered)) {
this.lifeCycle(entry);
}
});
htmx.on('htmx:historyRestore', (htmxEvent) => {
htmx.config.currentTargetId = null;
for (const [key, entry] of Object.entries(this.registered)) {
this.lifeCycle(entry);
}
});
}

unmount(/* @vite-ignore */) {}

/**
* Manage the conductor lifecycle
*
* @param {object} entry
*/
lifeCycle(entry) {
if (entry.conductor in this.loaded) {
// Conductor has already been loaded
if (entry.selector) {
// If the conductor must match a selector,
// mount/unmount as necessary if found in DOM
if (document.querySelector(entry.selector)) {
if (this.loaded[entry.conductor].mounted) {
this.loaded[entry.conductor].refresh();
} else {
this.loaded[entry.conductor].mount();
this.loaded[entry.conductor].mounted = true;
}
} else if (this.loaded[entry.conductor].mounted) {
this.loaded[entry.conductor].unmount();
this.loaded[entry.conductor].mounted = false;
}
}
} else {
// Not loaded yet
if (entry.selector) {
if (document.querySelector(entry.selector)) {
// we matched selector in the DOM, so load the entry
this.lazyload(entry);
}
} else {
// load immediately (only once)
this.lazyload(entry);
}
}
}

/**
* Register a conductor
*
* @param entry
* @param {string} conductor
* @param {string | null} selector
* @param {string | null} strategy
* @param {number} version
*/
register(entry, {conductor, selector=null, strategy='eager', version=1}=entry) {

// register conductor
this.registered.push(entry);

// lazyload
this.lifeCycle(entry);
}

/**
* Import a conductor and run its constructor
* We'll use lazy loading for the chunk file
*
* @param {object} entry
*/
lazyload(entry) {
let promises = loadStrategies(entry.strategy, entry.selector);
Promise.all(promises).then(() => {
import(/* @vite-ignore */ `${this.config.origin}/${this.config.basePath}/${entry.conductor}.js?v=${entry.version}`).then((lazyConductor) => {
this.loaded[entry.conductor] = new lazyConductor.default(entry.selector);
this.loaded[entry.conductor].mounted = true;
});
});
}
}
7 changes: 4 additions & 3 deletions lib/ext/booster-pack.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
*/

import Booster from '../booster.js';
import BoosterExt from '../boosterExt.js'
import BoosterFactory from '../boosterFactory.js'
import BoosterExt from '../boosterExt.js';
import BoosterFactory from '../boosterFactory.js';
import BoosterConductor from '../boosterConductor.js';
import { loadStrategies } from '../loadStrategies.js';
export { Booster, BoosterExt, BoosterFactory, loadStrategies };
export { Booster, BoosterExt, BoosterFactory, BoosterConductor, loadStrategies };
2 changes: 2 additions & 0 deletions lib/ext/booster.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

import BoosterExt from '../boosterExt.js'
import BoosterFactory from '../boosterFactory.js';
import BoosterConductor from '../boosterConductor.js';

// Load extension
new BoosterExt(BoosterFactory, 'booster');
new BoosterConductor('booster');

Loading

0 comments on commit 969a1d2

Please sign in to comment.