Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ext/hx-preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
let preloadEvents = []
let timeout = 5000;
if (preloadSpec) {
let specs = api.parseTriggerSpecs(preloadSpec);
let specs = api.parseEventSpecs(preloadSpec);
if (specs.length === 0) return;
for (const spec of specs) {
preloadEvents.push(spec.name)
Expand Down
2 changes: 1 addition & 1 deletion src/ext/hx-sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@
if (element._htmx?.sse) return; // already set up

let specString = api.attributeValue(element, 'hx-trigger') || 'load';
api.onTrigger(element, specString, () => {
api.onEvent(element, specString, () => {
if (element._htmx?.sse) return; // prevent duplicate connections
htmx.ajax('GET', connectUrl, {source: element});
});
Expand Down
4 changes: 2 additions & 2 deletions src/ext/hx-ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@
if (!connectUrl) return;

let specString = api.attributeValue(element, 'hx-trigger') || 'load';
api.onTrigger(element, specString, () => {
api.onEvent(element, specString, () => {
if (element._htmx?.ws?.url) return;
let connection = getOrCreateConnection(connectUrl, element);
if (connection) {
Expand All @@ -505,7 +505,7 @@
'click';
}

api.onTrigger(element, specString, async (evt) => {
api.onEvent(element, specString, async (evt) => {
if (element.matches('form') && evt.type === 'submit') {
evt.preventDefault();
}
Expand Down
138 changes: 85 additions & 53 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ var htmx = (() => {
this.#hxOnQuery = new XPathEvaluator().createExpression(`.//*[@*[${this.__prefixes("hx-on").map(p => `starts-with(name(), "${p}")`).join(' or ')}]]`);
this.#internalAPI = {
attributeValue: this.__attributeValue.bind(this),
parseTriggerSpecs: this.__parseTriggerSpecs.bind(this),
parseEventSpecs: this.__parseEventSpecs.bind(this),
determineMethodAndAction: this.__determineMethodAndAction.bind(this),
createRequestContext: this.__createRequestContext.bind(this),
collectFormData: this.__collectFormData.bind(this),
Expand All @@ -92,7 +92,7 @@ var htmx = (() => {
if (syncFn) this.#Function = syncFn;
if (asyncFn) this.#AsyncFunction = asyncFn;
},
onTrigger: this.__onTrigger.bind(this),
onEvent: this.__onEvent.bind(this),
htmxProp: this.__htmxProp.bind(this),
triggerHtmxEvent: this.__trigger.bind(this),
executeJavaScript: this.__executeJavaScript.bind(this)
Expand Down Expand Up @@ -276,7 +276,7 @@ var htmx = (() => {
return target;
}

__parseTriggerSpecs(spec) {
__parseEventSpecs(spec) {
// Split on commas that are NOT inside [...] — handles filters like click[myFunc(a,b)]
return spec.split(/,(?![^\[]*\])/).flatMap(s => {
let [,name,rest] = s.match(/^\s*(\S+\[[^\]]*\]|\S+)\s*(.*?)\s*$/) ?? [];
Expand Down Expand Up @@ -319,7 +319,7 @@ var htmx = (() => {

__htmxProp(elt) {
if (!elt._htmx) {
elt._htmx = { listeners: [], triggerSpecs: [] };
elt._htmx = { listeners: [], eventSpecs: [] };
elt.setAttribute('data-htmx-powered', 'true');
}
return elt._htmx;
Expand Down Expand Up @@ -690,13 +690,14 @@ var htmx = (() => {
elt.matches("input:not([type=button]):not([type=submit]),select,textarea") ? "change" :
"click";
}
this.__onTrigger(elt, specString, initialHandler)
this.__onEvent(elt, specString, initialHandler)
}

// Wire up trigger listeners with full modifier support (delay, throttle, once, etc.)
__onTrigger(elt, specString, handler) {
let specs = this.__parseTriggerSpecs(specString)
this.__htmxProp(elt).triggerSpecs.push(...specs)
// Wire up event listeners with full modifier support (once, prevent, stop,
// delay, throttle, changed, capture, passive, from, filter, etc.)
__onEvent(elt, specString, handler) {
let specs = this.__parseEventSpecs(specString)
this.__htmxProp(elt).eventSpecs.push(...specs)

for (let spec of specs) {
spec.handler = handler
Expand All @@ -705,17 +706,32 @@ var htmx = (() => {

let [eventName, filter] = this.__extractFilter(spec.name);

// should be first so logic is called only when all other filters pass
if (spec.once) {
let original = spec.handler
spec.handler = (evt) => {
original(evt)
for (let listenerInfo of spec.listeners) {
listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler)
for (let info of spec.listeners) {
info.fromElt.removeEventListener(info.eventName, info.handler, info)
}
}
}

if (spec.halt || spec.prevent) {
let original = spec.handler
spec.handler = (evt) => {
evt.preventDefault()
original(evt)
}
}

if (spec.halt || spec.stop || spec.consume) {
let original = spec.handler
spec.handler = (evt) => {
evt.stopPropagation()
original(evt)
}
}

if (eventName === 'intersect' || eventName === "revealed") {
let observerOptions = {}
if (spec.root) observerOptions.root = this.__findOrWarn(elt, spec.root)
Expand Down Expand Up @@ -757,7 +773,6 @@ var htmx = (() => {
spec.throttleTimeout = setTimeout(() => {
spec.throttled = false
if (spec.throttledEvent) {
// implement trailing-edge throttling
let throttledEvent = spec.throttledEvent;
spec.throttledEvent = null
spec.handler(throttledEvent);
Expand Down Expand Up @@ -787,14 +802,6 @@ var htmx = (() => {
}, this.parseInterval(interval));
}

if (spec.consume) {
let original = spec.handler
spec.handler = (evt) => {
evt.stopPropagation()
original(evt)
}
}

if (filter) {
let original = spec.handler
spec.handler = (evt) => {
Expand All @@ -807,7 +814,18 @@ var htmx = (() => {
}

let fromElts = [elt];
if (spec.from) {
if (spec.from === 'self') {
let original = spec.handler
spec.handler = (evt) => {
if (evt.target === elt) original(evt)
}
} else if (spec.from === 'outside') {
fromElts = [document];
let original = spec.handler
spec.handler = (evt) => {
if (!elt.contains(evt.target)) original(evt)
}
} else if (spec.from) {
fromElts = this.__findAllExt(elt, spec.from)
}

Expand All @@ -834,10 +852,11 @@ var htmx = (() => {
}

for (let fromElt of fromElts) {
let listenerInfo = {fromElt, eventName, handler: spec.handler};
let listenerInfo = {fromElt, eventName, handler: spec.handler,
capture: !!spec.capture, passive: !!spec.passive};
elt._htmx.listeners.push(listenerInfo)
spec.listeners.push(listenerInfo)
fromElt.addEventListener(eventName, spec.handler);
fromElt.addEventListener(eventName, spec.handler, listenerInfo);
}
}
}
Expand Down Expand Up @@ -969,14 +988,14 @@ var htmx = (() => {
__cleanup(elt) {
if (elt._htmx) {
this.__trigger(elt, "htmx:before:cleanup")
for (let spec of elt._htmx.triggerSpecs || []) {
for (let spec of elt._htmx.eventSpecs || []) {
if (spec.interval) clearInterval(spec.interval);
if (spec.timeout) clearTimeout(spec.timeout);
if (spec.throttleTimeout) clearTimeout(spec.throttleTimeout);
spec.observer?.disconnect()
}
for (let listenerInfo of elt._htmx.listeners || []) {
listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler);
listenerInfo.fromElt.removeEventListener(listenerInfo.eventName, listenerInfo.handler, listenerInfo);
}
this.__trigger(elt, "htmx:after:cleanup")
}
Expand Down Expand Up @@ -1651,36 +1670,49 @@ var htmx = (() => {

// hx-on:<event> binds to <event> directly
// hx-on::<event> is shorthand for hx-on:htmx:<event> (htmx events)
// Modifiers (dot-separated): .prevent .stop .halt .once .self .outside .capture .passive .cc
__handleHxOnAttributes(node) {
let searchStrings = this.__prefixes("hx-on:").map(p => this.__maybeAdjustMetaCharacter(p));
let mc = this.config.metaCharacter || ':';
let prefixes = this.__prefixes("hx-on:").map(p => this.__maybeAdjustMetaCharacter(p)); // e.g. ["hx-on:"]
let hxOnNames = this.__prefixes("hx-on"); // e.g. ["hx-on"]
let handler = (code) => async (evt) => {
try {
await this.__executeJavaScript(node, { event: evt },
`with(event?.detail||{}){${code}}`, false);
} catch (e) {
if (typeof e !== 'symbol') this.__trigger(node, 'htmx:error', { error: e });
}
};
for (let attr of node.getAttributeNames()) {
let searchString = searchStrings.find(s => attr.startsWith(s));
if (!searchString) continue;
let [evtName, ...mods] = attr.substring(searchString.length).split('.');
let has = m => mods.includes(m);
if (evtName.startsWith(mc)) evtName = 'htmx' + evtName;
if (has('cc')) evtName = evtName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
let code = node.getAttribute(attr);
let target = has('outside') ? document : node;
let opts = { capture: has('capture'), passive: has('passive') };
let halt = has('halt');
let handler = async (evt) => {
if (has('self') && evt.target !== node) return;
if (has('outside') && node.contains(evt.target)) return;
if (halt || has('prevent')) evt.preventDefault();
if (halt || has('stop')) evt.stopPropagation();
if (has('once')) target.removeEventListener(evtName, handler, opts);
try {
await this.__executeJavaScript(node, { event: evt },
`with(event?.detail||{}){${code}}`, false);
} catch (e) {
if (typeof e !== 'symbol') this.__trigger(node, 'htmx:error', { error: e });
// hx-on="click once -> doA(); blur -> doB()"
if (hxOnNames.includes(attr)) {
let value = node.getAttribute(attr);
// Split on ";" at depth 0 so braces protect multi-statement JS:
// "click -> { a(); b() }; blur -> c()" → ["click -> { a(); b() }", " blur -> c()"]
let parts = [], current = '', depth = 0;
for (let char of value) {
if (char === '{' || char === '(') depth++;
else if (char === '}' || char === ')') depth--;
if (char === ';' && depth === 0) { parts.push(current); current = '' }
else current += char;
}
};
target.addEventListener(evtName, handler, opts);
this.__htmxProp(node).listeners.push({fromElt: target, eventName: evtName, handler});
parts.push(current);
for (let part of parts) { // e.g. "click once -> doA()"
let arrowIdx = part.indexOf('->');
if (arrowIdx === -1) continue;
this.__onEvent(node,
part.substring(0, arrowIdx).trim(), // "click once"
handler(part.substring(arrowIdx + 2).trim())); // "doA()"
}
continue;
}
// hx-on:click="code" (simple, no modifiers)
let prefix = prefixes.find(p => attr.startsWith(p));
if (!prefix) continue;
let eventName = attr.substring(prefix.length);
// hx-on::before:request → eventName starts as ":before:request"
// expand the :: shorthand to "htmx:before:request"
let metaChar = this.config.metaCharacter || ':';
if (eventName.startsWith(metaChar)) eventName = 'htmx' + eventName;
this.__onEvent(node, eventName, handler(node.getAttribute(attr)));
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/skills/htmx-extension-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ init: (internalAPI) => { api = internalAPI; },
| Method | Description |
|--------|-------------|
| `api.attributeValue(elt, name, defaultVal, returnElt)` | Get attribute value with inheritance support |
| `api.parseTriggerSpecs(spec)` | Parse trigger spec string into array of spec objects |
| `api.parseEventSpecs(spec)` | Parse event spec string into array of spec objects |
| `api.determineMethodAndAction(elt, evt)` | Get `{method, action}` for an element |
| `api.createRequestContext(elt, evt)` | Create a full request context object |
| `api.collectFormData(elt, form, submitter)` | Collect form data as FormData |
Expand Down Expand Up @@ -237,7 +237,7 @@ From `src/ext/hx-preload.js` -- prefetches requests on trigger events:
let preloadSpec = api.attributeValue(elt, "hx-preload");
if (!preloadSpec) return;

let specs = api.parseTriggerSpecs(preloadSpec);
let specs = api.parseEventSpecs(preloadSpec);
let preloadListener = async (evt) => {
let {method} = api.determineMethodAndAction(elt, evt);
if (method !== 'GET') return;
Expand Down
2 changes: 1 addition & 1 deletion test/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
<script src="./tests/unit/__normalizeSwapStyle.js"></script>
<script src="./tests/unit/__parseConfig.js"></script>
<script src="./tests/unit/__parseSwapSpec.js"></script>
<script src="./tests/unit/__parseTriggerSpecs.js"></script>
<script src="./tests/unit/__parseEventSpecs.js"></script>
<script src="./tests/unit/__queryEltAndDescendants.js"></script>
<script src="./tests/unit/__resolveTarget.js"></script>
<script src="./tests/unit/__shouldBoost.js"></script>
Expand Down
Loading