diff --git a/src/ext/hx-preload.js b/src/ext/hx-preload.js index 38c4f700e..e43c09d9b 100644 --- a/src/ext/hx-preload.js +++ b/src/ext/hx-preload.js @@ -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) diff --git a/src/ext/hx-sse.js b/src/ext/hx-sse.js index 450f18856..b86f63112 100644 --- a/src/ext/hx-sse.js +++ b/src/ext/hx-sse.js @@ -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}); }); diff --git a/src/ext/hx-ws.js b/src/ext/hx-ws.js index 31984bb2d..3fc99b12b 100644 --- a/src/ext/hx-ws.js +++ b/src/ext/hx-ws.js @@ -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) { @@ -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(); } diff --git a/src/htmx.js b/src/htmx.js index 91d5fb5b0..87c617c59 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -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), @@ -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) @@ -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*$/) ?? []; @@ -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; @@ -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 @@ -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) @@ -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); @@ -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) => { @@ -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) } @@ -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); } } } @@ -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") } @@ -1651,36 +1670,49 @@ var htmx = (() => { // hx-on: binds to directly // hx-on:: is shorthand for hx-on:htmx: (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))); } } diff --git a/src/skills/htmx-extension-authoring.md b/src/skills/htmx-extension-authoring.md index 9eed9ea9b..de27e34d1 100644 --- a/src/skills/htmx-extension-authoring.md +++ b/src/skills/htmx-extension-authoring.md @@ -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 | @@ -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; diff --git a/test/test.html b/test/test.html index 0a5950d76..1db9f6832 100644 --- a/test/test.html +++ b/test/test.html @@ -105,7 +105,7 @@ - + diff --git a/test/tests/attributes/hx-on.js b/test/tests/attributes/hx-on.js index b72775516..b2674d139 100644 --- a/test/tests/attributes/hx-on.js +++ b/test/tests/attributes/hx-on.js @@ -95,15 +95,102 @@ describe('hx-on attribute', function() { assert.equal(window.foo, undefined) }) + it('event.detail keys are exposed as bare names in handler scope', function() { + let btn = createProcessedHTML(''); + btn.dispatchEvent(new CustomEvent('zap', { detail: { path: 'hello' } })); + window.foo.should.equal('HELLO'); + delete window.foo; + }); + + // --- synthetic events in simple form --- + + it('hx-on:load fires immediately when element is processed', function() { + window.foo = false; + createProcessedHTML('
x
'); + window.foo.should.equal(true); + delete window.foo; + }); + + it('hx-on:load provides this as the element', function() { + let div = createProcessedHTML('
x
'); + window.foo.should.equal(div); + delete window.foo; + }); + + it('hx-on:load fires on children inside a swap response', function() { + window.parentLoaded = false; + window.childLoaded = false; + playground().innerHTML = '
old
'; + htmx.process(playground()); + let target = playground().querySelector('#target'); + // Simulate what htmx does during a swap: insert new content and process it + target.innerHTML = '
new
'; + htmx.process(target); + window.parentLoaded.should.equal(true); + window.childLoaded.should.equal(true); + delete window.parentLoaded; + delete window.childLoaded; + }); + + it('hx-on:load passes a CustomEvent as event', function() { + createProcessedHTML('
x
'); + window.foo.should.equal('load'); + delete window.foo; + }); + }) -describe('hx-on attribute modifiers', function() { +describe('hx-on="eventSpec -> code" syntax', function() { beforeEach(() => { setupTest(this.currentTest); }); afterEach(() => { cleanupTest(); }); - it('.prevent calls preventDefault before body', function() { - let form = createProcessedHTML('
'); + // --- basic --- + + it('basic: fires handler on event', function() { + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.equal(true); + delete window.foo; + }); + + it('this refers to the element', function() { + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.equal(btn); + delete window.foo; + }); + + it('event is available in handler', function() { + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.equal('click'); + delete window.foo; + }); + + it('event.detail keys are exposed as bare names', function() { + let btn = createProcessedHTML(''); + btn.dispatchEvent(new CustomEvent('zap', { detail: { path: 'hello' } })); + window.foo.should.equal('hello'); + delete window.foo; + }); + + // --- once --- + + it('once: removes listener after first fire', function() { + window.fooCount = 0; + let btn = createProcessedHTML(''); + btn.click(); + btn.click(); + btn.click(); + window.fooCount.should.equal(1); + delete window.fooCount; + }); + + // --- prevent --- + + it('prevent: calls preventDefault before handler', function() { + let form = createProcessedHTML('
'); let evt = new SubmitEvent('submit', { cancelable: true, bubbles: true }); form.dispatchEvent(evt); evt.defaultPrevented.should.equal(true); @@ -111,8 +198,10 @@ describe('hx-on attribute modifiers', function() { delete window.foo; }); - it('.stop calls stopPropagation before body', function() { - playground().innerHTML = '
'; + // --- stop --- + + it('stop: calls stopPropagation before handler', function() { + playground().innerHTML = '
'; htmx.process(playground()); let outerFired = false; playground().querySelector('#outer').addEventListener('click', () => outerFired = true); @@ -122,8 +211,25 @@ describe('hx-on attribute modifiers', function() { delete window.foo; }); - it('.halt is shorthand for .prevent.stop', function() { - playground().innerHTML = '
'; + // --- halt (shorthand for prevent + stop) --- + + it('halt: preventDefault and stopPropagation', function() { + playground().innerHTML = '
'; + htmx.process(playground()); + let outerFired = false; + playground().querySelector('#outer').addEventListener('click', () => outerFired = true); + let btn = playground().querySelector('button'); + let evt = new MouseEvent('click', { cancelable: true, bubbles: true }); + btn.dispatchEvent(evt); + evt.defaultPrevented.should.equal(true); + outerFired.should.equal(false); + delete window.foo; + }); + + // --- prevent + stop --- + + it('prevent stop: equivalent to halt', function() { + playground().innerHTML = '
'; htmx.process(playground()); let outerFired = false; playground().querySelector('#outer').addEventListener('click', () => outerFired = true); @@ -135,101 +241,381 @@ describe('hx-on attribute modifiers', function() { delete window.foo; }); - it('.once removes the listener after first fire', function() { + // --- delay --- + + it('delay: debounces handler', async function() { window.fooCount = 0; - let btn = createProcessedHTML(''); + playground().innerHTML = ''; + htmx.process(playground()); + let btn = playground().querySelector('button'); btn.click(); btn.click(); btn.click(); + window.fooCount.should.equal(0); + await htmx.timeout(60); + window.fooCount.should.equal(1); + delete window.fooCount; + }); + + // --- throttle --- + + it('throttle: throttles handler with trailing edge', async function() { + window.fooCount = 0; + playground().innerHTML = ''; + htmx.process(playground()); + let btn = playground().querySelector('button'); + btn.click(); // fires immediately (first) + btn.click(); // queued + btn.click(); // replaces queued + window.fooCount.should.equal(1); + await htmx.timeout(60); + window.fooCount.should.equal(2); // trailing edge fires + delete window.fooCount; + }); + + // --- changed --- + + it('changed: only fires when value changes', function() { + window.fooCount = 0; + playground().innerHTML = ''; + htmx.process(playground()); + let inp = playground().querySelector('input'); + // First fire: value is "a", no previous — should fire + inp.dispatchEvent(new Event('input', { bubbles: true })); window.fooCount.should.equal(1); + // Second fire: value still "a" — should NOT fire + inp.dispatchEvent(new Event('input', { bubbles: true })); + window.fooCount.should.equal(1); + // Change value and fire — should fire + inp.value = 'b'; + inp.dispatchEvent(new Event('input', { bubbles: true })); + window.fooCount.should.equal(2); delete window.fooCount; }); - it('.self only fires when event.target is the element', function() { + // --- from --- + + it('from:document: listens on document', function() { + window.foo = false; + let btn = createProcessedHTML(''); + document.dispatchEvent(new CustomEvent('custom-evt')); + window.foo.should.equal(true); + // Clean up document-level listener + for (let l of (btn._htmx?.listeners || [])) l.fromElt.removeEventListener(l.eventName, l.handler); + delete window.foo; + }); + + // --- from:self --- + + it('from:self: only fires when event.target is the element', function() { window.fooCount = 0; - playground().innerHTML = '
child
'; + playground().innerHTML = '
child
'; htmx.process(playground()); let div = playground().querySelector('div'); - playground().querySelector('span').click(); // bubbles to div, target=span → skip + playground().querySelector('span').click(); // bubbles, target=span → skip window.fooCount.should.equal(0); div.click(); // target=div → fire window.fooCount.should.equal(1); delete window.fooCount; }); - it('.outside fires only when click happens outside the element', function() { + // --- from:outside --- + + it('from:outside: fires only when event is outside the element', function() { window.outsideCount = 0; - let btn; - playground().innerHTML = '
'; + playground().innerHTML = '
'; htmx.process(playground()); - btn = playground().querySelector('button'); + let btn = playground().querySelector('button'); btn.click(); // inside → skip window.outsideCount.should.equal(0); playground().querySelector('#other').click(); // outside → fire window.outsideCount.should.equal(1); - // Manually remove the document-level listener so it doesn't leak across tests - for (let l of btn._htmx.listeners) l.fromElt.removeEventListener(l.eventName, l.handler); + // Clean up document-level listener + for (let l of (btn._htmx?.listeners || [])) l.fromElt.removeEventListener(l.eventName, l.handler); delete window.outsideCount; }); - it('.cc camel-cases the event name', function() { - window.foo = null; - let btn = createProcessedHTML(''); - btn.dispatchEvent(new CustomEvent('myEvent')); - window.foo.should.equal('myEvent'); - delete window.foo; + // --- capture --- + + it('capture: fires during capture phase', function() { + let order = []; + playground().innerHTML = '
'; + window.captureOrder = order; + htmx.process(playground()); + // Add a bubbling listener on the same button for comparison + playground().querySelector('button').addEventListener('click', () => order.push('bubble')); + playground().querySelector('button').click(); + // Capture fires before bubble + order[0].should.equal('capture'); + order[1].should.equal('bubble'); + delete window.captureOrder; }); - it('event.detail keys are exposed as bare names in handler scope', function() { - let btn = createProcessedHTML(''); - btn.dispatchEvent(new CustomEvent('zap', { detail: { path: 'hello' } })); - window.foo.should.equal('HELLO'); + // --- passive --- + + it('passive: addEventListener is called with passive option', function() { + // passive handlers cannot call preventDefault without a browser warning, + // so we just verify the handler fires (option is passed internally) + window.foo = false; + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.equal(true); delete window.foo; }); - it('multiple modifiers can be chained (.halt.once)', function() { + // --- filter --- + + it('[filter]: only fires when filter expression is truthy', function() { window.fooCount = 0; - let btn = createProcessedHTML(''); - let evt = new MouseEvent('click', { cancelable: true, bubbles: true }); - btn.dispatchEvent(evt); - evt.defaultPrevented.should.equal(true); - btn.dispatchEvent(new MouseEvent('click', { cancelable: true, bubbles: true })); + let btn = createProcessedHTML(''); + btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + window.fooCount.should.equal(0); + btn.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); window.fooCount.should.equal(1); delete window.fooCount; }); - it('.self.once only counts toward "once" when self condition matches', function() { - // A bubbled event from a child should NOT consume the .once budget. + // --- combined --- + + it('changed + delay: debounced changed-only handler', async function() { window.fooCount = 0; - playground().innerHTML = '
child
'; + playground().innerHTML = ''; + htmx.process(playground()); + let inp = playground().querySelector('input'); + // Rapid changes — should debounce + inp.value = 'b'; + inp.dispatchEvent(new Event('input', { bubbles: true })); + inp.value = 'c'; + inp.dispatchEvent(new Event('input', { bubbles: true })); + inp.value = 'd'; + inp.dispatchEvent(new Event('input', { bubbles: true })); + window.fooCount.should.equal(0); + await htmx.timeout(60); + window.fooCount.should.equal(1); + delete window.fooCount; + }); + + it('once + from:self: only counts toward once when self matches', function() { + window.fooCount = 0; + playground().innerHTML = '
child
'; htmx.process(playground()); let div = playground().querySelector('div'); let span = playground().querySelector('span'); - span.click(); // bubbles, target=span → skipped, listener should remain - span.click(); // same, listener still there + span.click(); // bubbles, target=span → skipped + span.click(); // same window.fooCount.should.equal(0); - div.click(); // target=div → fires; THIS is the once + div.click(); // target=div → fires; this is the once window.fooCount.should.equal(1); div.click(); // listener now removed window.fooCount.should.equal(1); delete window.fooCount; }); - it('.outside.once only counts toward "once" when outside condition matches', function() { + it('once + from:outside: only counts toward once when outside matches', function() { window.outsideCount = 0; - playground().innerHTML = '
'; + playground().innerHTML = '
'; htmx.process(playground()); let btn = playground().querySelector('button'); let other = playground().querySelector('#other'); - btn.click(); // inside → skipped, listener should remain + btn.click(); // inside → skipped btn.click(); // same window.outsideCount.should.equal(0); other.click(); // outside → fires; consumes once window.outsideCount.should.equal(1); other.click(); // listener now removed window.outsideCount.should.equal(1); - // Clean up document-level listener if any remain for (let l of (btn._htmx?.listeners || [])) l.fromElt.removeEventListener(l.eventName, l.handler); delete window.outsideCount; }); -}) \ No newline at end of file + + // --- multiple events --- + + it('multiple events: separated by semicolons', function() { + window.focusFired = false; + window.blurFired = false; + playground().innerHTML = ''; + htmx.process(playground()); + let inp = playground().querySelector('input'); + inp.dispatchEvent(new Event('focus')); + window.focusFired.should.equal(true); + inp.dispatchEvent(new Event('blur')); + window.blurFired.should.equal(true); + delete window.focusFired; + delete window.blurFired; + }); + + // --- multi-statement JS --- + + it('multi-statement JS works with braces', function() { + window.foo = 0; + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.equal(2); + delete window.foo; + }); + + it('semicolons inside nested braces are preserved', function() { + window.foo = 0; + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.equal(2); + delete window.foo; + }); + + it('semicolons inside parens are preserved', function() { + window.foo = 0; + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.equal(2); + delete window.foo; + }); + + // --- comma: multiple events, same code --- + + it('comma: multiple events fire the same handler', function() { + window.fooCount = 0; + let btn = createProcessedHTML(''); + btn.click(); + btn.dispatchEvent(new CustomEvent('customevt')); + window.fooCount.should.equal(2); + delete window.fooCount; + }); + + // --- arrow functions in JS code --- + + it('arrow functions in JS code do not break parsing', function() { + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.deep.equal([2, 3]); + delete window.foo; + }); + + // --- multiple events + multi-statement combined --- + + it('multi-statement and multi-event combined', function() { + window.clickCount = 0; + window.blurFired = false; + playground().innerHTML = ''; + htmx.process(playground()); + let btn = playground().querySelector('button'); + btn.click(); + window.clickCount.should.equal(2); + btn.dispatchEvent(new Event('blur')); + window.blurFired.should.equal(true); + delete window.clickCount; + delete window.blurFired; + }); + + // --- from with CSS selector --- + + it('from:.selector: listens on matching elements', function() { + window.foo = false; + playground().innerHTML = '
'; + htmx.process(playground()); + playground().querySelector('#source').dispatchEvent(new CustomEvent('customevt')); + window.foo.should.equal(true); + delete window.foo; + }); + + // --- hx-on: and hx-on= coexist --- + + it('hx-on: and hx-on= coexist on the same element', function() { + window.colonFired = false; + window.arrowFired = false; + let btn = createProcessedHTML(''); + btn.click(); + window.colonFired.should.equal(true); + btn.dispatchEvent(new CustomEvent('customevt')); + window.arrowFired.should.equal(true); + delete window.colonFired; + delete window.arrowFired; + }); + + // --- synthetic events --- + + it('load: fires immediately when element is processed', function() { + window.foo = false; + createProcessedHTML('
x
'); + window.foo.should.equal(true); + delete window.foo; + }); + + it('load: works with modifiers', function() { + window.foo = false; + createProcessedHTML('
x
'); + window.foo.should.equal(true); + delete window.foo; + }); + + it('load: provides this and event', function() { + let div = createProcessedHTML('
x
'); + window.fooThis.should.equal(div); + window.fooType.should.equal('load'); + delete window.fooThis; + delete window.fooType; + }); + + it('every: fires repeatedly on interval', async function() { + window.fooCount = 0; + playground().innerHTML = '
x
'; + htmx.process(playground()); + window.fooCount.should.equal(0); + await htmx.timeout(130); + window.fooCount.should.be.at.least(2); + // Clean up: remove element so interval stops (checks elt.isConnected) + playground().innerHTML = ''; + delete window.fooCount; + }); + + // --- doc examples: synthetic events --- + + it('load: focuses first input when element is processed', function() { + let div = createProcessedHTML('
'); + document.activeElement.id.should.equal('myinput'); + }); + + it('revealed: adds class when element intersects', function() { + let originalIO = window.IntersectionObserver; + let observerCallback; + window.IntersectionObserver = function(cb, opts) { + observerCallback = cb; + return { observe: function() {}, disconnect: function() {} }; + }; + let div = createProcessedHTML('
x
'); + div.classList.contains('visible').should.equal(false); + observerCallback([{ isIntersecting: true }]); + div.classList.contains('visible').should.equal(true); + window.IntersectionObserver = originalIO; + }); + + it('intersect once: adds class when element becomes visible', function() { + let originalIO = window.IntersectionObserver; + let observerCallback, disconnected = false; + window.IntersectionObserver = function(cb, opts) { + observerCallback = cb; + return { observe: function() {}, disconnect: function() { disconnected = true; } }; + }; + let div = createProcessedHTML('
x
'); + div.classList.contains('in-view').should.equal(false); + observerCallback([{ isIntersecting: true }]); + div.classList.contains('in-view').should.equal(true); + window.IntersectionObserver = originalIO; + }); + + it('every: updates text content on interval', async function() { + let div = createProcessedHTML('
original
'); + div.textContent.should.equal('original'); + await htmx.timeout(80); + div.textContent.should.equal('updated'); + playground().innerHTML = ''; + }); + + // --- backward compat --- + + it('hx-on:click still works (backward compat)', function() { + let btn = createProcessedHTML(''); + btn.click(); + window.foo.should.equal(true); + delete window.foo; + }); +}) diff --git a/test/tests/ext/hx-live.js b/test/tests/ext/hx-live.js index c9b631f98..c01067c62 100644 --- a/test/tests/ext/hx-live.js +++ b/test/tests/ext/hx-live.js @@ -783,4 +783,82 @@ describe('hx-live extension', function () { window.foo.should.equal('tgt'); delete window.foo; }); + + // ------------------------------------------------------------------------- + // hx-live helpers inside hx-on="event -> code" syntax + // ------------------------------------------------------------------------- + + it('q() works inside hx-on="event -> code"', function() { + playground().innerHTML = '
tgt
'; + htmx.process(playground()); + playground().querySelector('button').click(); + window.foo.should.equal('tgt'); + delete window.foo; + }); + + it('take() works inside hx-on="event -> code"', function() { + playground().innerHTML = ` +
+ + + +
+ `; + htmx.process(playground()); + let tabs = playground().querySelectorAll('.tab'); + tabs[2].click(); + tabs[0].classList.contains('selected').should.equal(false); + tabs[1].classList.contains('selected').should.equal(false); + tabs[2].classList.contains('selected').should.equal(true); + }); + + it('toggle() works inside hx-on="event -> code"', function() { + playground().innerHTML = ''; + htmx.process(playground()); + let btn = playground().querySelector('button'); + btn.click(); + btn.classList.contains('active').should.equal(true); + btn.getAttribute('aria-pressed').should.equal('true'); + btn.click(); + btn.classList.contains('active').should.equal(false); + btn.getAttribute('aria-pressed').should.equal('false'); + }); + + it('debounce() works inside hx-on="event -> code"', async function() { + window.__dbCount = 0; + playground().innerHTML = ''; + htmx.process(playground()); + let btn = playground().querySelector('button'); + btn.click(); + btn.click(); + btn.click(); + await htmx.timeout(60); + window.__dbCount.should.equal(1); + delete window.__dbCount; + }); + + it('trigger() works inside hx-on="event -> code"', function() { + let fired = null; + playground().innerHTML = ''; + htmx.process(playground()); + let btn = playground().querySelector('button'); + btn.addEventListener('zap', e => fired = e); + btn.click(); + assert.isNotNull(fired); + fired.detail.x.should.equal(1); + }); + + it('hx-live helpers work with hx-on modifiers', async function() { + window.__dbCount = 0; + playground().innerHTML = ''; + htmx.process(playground()); + let inp = playground().querySelector('input'); + inp.value = 'a'; + inp.dispatchEvent(new Event('input', { bubbles: true })); + inp.value = 'b'; + inp.dispatchEvent(new Event('input', { bubbles: true })); + await htmx.timeout(80); + window.__dbCount.should.equal(1); + delete window.__dbCount; + }); }); diff --git a/test/tests/unit/__parseTriggerSpecs.js b/test/tests/unit/__parseEventSpecs.js similarity index 72% rename from test/tests/unit/__parseTriggerSpecs.js rename to test/tests/unit/__parseEventSpecs.js index 7ac21dc0a..0943a806e 100644 --- a/test/tests/unit/__parseTriggerSpecs.js +++ b/test/tests/unit/__parseEventSpecs.js @@ -1,34 +1,34 @@ -describe('__parseTriggerSpecs unit tests', function() { +describe('__parseEventSpecs unit tests', function() { it('parses single event', function () { - let specs = htmx.__parseTriggerSpecs('click') + let specs = htmx.__parseEventSpecs('click') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click') }) it('parses multiple events with comma', function () { - let specs = htmx.__parseTriggerSpecs('click, change') + let specs = htmx.__parseEventSpecs('click, change') assert.equal(specs.length, 2) assert.equal(specs[0].name, 'click') assert.equal(specs[1].name, 'change') }) it('parses event with modifier value', function () { - let specs = htmx.__parseTriggerSpecs('click delay:100ms') + let specs = htmx.__parseEventSpecs('click delay:100ms') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click') assert.equal(specs[0].delay, '100ms') }) it('parses event with boolean modifier', function () { - let specs = htmx.__parseTriggerSpecs('click once') + let specs = htmx.__parseEventSpecs('click once') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click') assert.equal(specs[0].once, true) }) it('parses event with multiple modifiers', function () { - let specs = htmx.__parseTriggerSpecs('click delay:100ms throttle:200ms once') + let specs = htmx.__parseEventSpecs('click delay:100ms throttle:200ms once') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click') assert.equal(specs[0].delay, '100ms') @@ -37,53 +37,53 @@ describe('__parseTriggerSpecs unit tests', function() { }) it('parses event with from modifier', function () { - let specs = htmx.__parseTriggerSpecs('click from:#other') + let specs = htmx.__parseEventSpecs('click from:#other') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click') assert.equal(specs[0].from, '#other') }) it('parses event with target modifier', function () { - let specs = htmx.__parseTriggerSpecs('click target:.item') + let specs = htmx.__parseEventSpecs('click target:.item') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click') assert.equal(specs[0].target, '.item') }) it('parses event with consume modifier', function () { - let specs = htmx.__parseTriggerSpecs('click consume') + let specs = htmx.__parseEventSpecs('click consume') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click') assert.equal(specs[0].consume, true) }) it('parses event with changed modifier', function () { - let specs = htmx.__parseTriggerSpecs('change changed') + let specs = htmx.__parseEventSpecs('change changed') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'change') assert.equal(specs[0].changed, true) }) it('parses event with filter', function () { - let specs = htmx.__parseTriggerSpecs('click[ctrlKey]') + let specs = htmx.__parseEventSpecs('click[ctrlKey]') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click[ctrlKey]') }) it('parses event with filter containing spaces', function () { - let specs = htmx.__parseTriggerSpecs('click[event.detail > 5]') + let specs = htmx.__parseEventSpecs('click[event.detail > 5]') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click[event.detail > 5]') }) it('preserves whitespace in string literals in filters', function () { - let specs = htmx.__parseTriggerSpecs('click[event.detail === "hello world"]') + let specs = htmx.__parseEventSpecs('click[event.detail === "hello world"]') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click[event.detail === "hello world"]') }) it('parses multiple events with different modifiers', function () { - let specs = htmx.__parseTriggerSpecs('click once, change delay:100ms') + let specs = htmx.__parseEventSpecs('click once, change delay:100ms') assert.equal(specs.length, 2) assert.equal(specs[0].name, 'click') assert.equal(specs[0].once, true) @@ -92,37 +92,37 @@ describe('__parseTriggerSpecs unit tests', function() { }) it('parses every trigger', function () { - let specs = htmx.__parseTriggerSpecs('every 1s') + let specs = htmx.__parseEventSpecs('every 1s') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'every') assert.equal(specs[0]['1s'], true) }) it('handles empty string', function () { - let specs = htmx.__parseTriggerSpecs('') + let specs = htmx.__parseEventSpecs('') assert.equal(specs.length, 0) }) it('handles whitespace only', function () { - let specs = htmx.__parseTriggerSpecs(' ') + let specs = htmx.__parseEventSpecs(' ') assert.equal(specs.length, 0) }) it('throws on unterminated filter', function () { assert.throws(() => { - htmx.__parseTriggerSpecs('click[ctrlKey') + htmx.__parseEventSpecs('click[ctrlKey') }) }) it('does not split on comma inside function call filter when combined with other events', function () { - let specs = htmx.__parseTriggerSpecs('click[myFunc(a,b)], keyup') + let specs = htmx.__parseEventSpecs('click[myFunc(a,b)], keyup') assert.equal(specs.length, 2) assert.equal(specs[0].name, 'click[myFunc(a,b)]') assert.equal(specs[1].name, 'keyup') }) - it('parses complex trigger spec', function () { - let specs = htmx.__parseTriggerSpecs('click[ctrlKey] delay:100ms throttle:200ms from:body target:.item once') + it('parses complex event spec', function () { + let specs = htmx.__parseEventSpecs('click[ctrlKey] delay:100ms throttle:200ms from:body target:.item once') assert.equal(specs.length, 1) assert.equal(specs[0].name, 'click[ctrlKey]') assert.equal(specs[0].delay, '100ms') diff --git a/test/tests/unit/bootstrap.js b/test/tests/unit/bootstrap.js index e9ec5e892..93700c61e 100644 --- a/test/tests/unit/bootstrap.js +++ b/test/tests/unit/bootstrap.js @@ -77,23 +77,23 @@ describe('bootstrap unit tests', function() { assert.equal(result, 'default'); }) - it("__parseTriggerSpecs parses simple event", function() { - const result = htmx.__parseTriggerSpecs('click'); + it("__parseEventSpecs parses simple event", function() { + const result = htmx.__parseEventSpecs('click'); assert.equal(result.length, 1); assert.equal(result[0].name, 'click'); }) - it("__parseTriggerSpecs parses event with option", function() { - const result = htmx.__parseTriggerSpecs('click delay:500'); + it("__parseEventSpecs parses event with option", function() { + const result = htmx.__parseEventSpecs('click delay:500'); assert.equal(result.length, 1); assert.equal(result[0].name, 'click'); assert.equal(result[0].delay, '500'); }) - it("__parseTriggerSpecs parses event with multiple options", function() { - const result = htmx.__parseTriggerSpecs('click delay:500 throttle:100'); + it("__parseEventSpecs parses event with multiple options", function() { + const result = htmx.__parseEventSpecs('click delay:500 throttle:100'); assert.equal(result.length, 1); assert.equal(result[0].name, 'click'); @@ -101,8 +101,8 @@ describe('bootstrap unit tests', function() { assert.equal(result[0].throttle, '100'); }) - it("__parseTriggerSpecs parses event with boolean opts", function() { - const result = htmx.__parseTriggerSpecs('click once changed'); + it("__parseEventSpecs parses event with boolean opts", function() { + const result = htmx.__parseEventSpecs('click once changed'); assert.equal(result.length, 1); assert.equal(result[0].name, 'click'); @@ -110,8 +110,8 @@ describe('bootstrap unit tests', function() { assert.equal(result[0].changed, true); }) - it("__parseTriggerSpecs parses event with options and boolean opts", function() { - const result = htmx.__parseTriggerSpecs('click delay:1s once changed'); + it("__parseEventSpecs parses event with options and boolean opts", function() { + const result = htmx.__parseEventSpecs('click delay:1s once changed'); assert.equal(result.length, 1); assert.equal(result[0].name, 'click'); @@ -120,16 +120,16 @@ describe('bootstrap unit tests', function() { assert.equal(result[0].changed, true); }) - it("__parseTriggerSpecs parses multiple events", function() { - const result = htmx.__parseTriggerSpecs('click, submit'); + it("__parseEventSpecs parses multiple events", function() { + const result = htmx.__parseEventSpecs('click, submit'); assert.equal(result.length, 2); assert.equal(result[0].name, 'click'); assert.equal(result[1].name, 'submit'); }) - it("__parseTriggerSpecs parses multiple events with options", function() { - const result = htmx.__parseTriggerSpecs('click delay:500, keyup changed'); + it("__parseEventSpecs parses multiple events with options", function() { + const result = htmx.__parseEventSpecs('click delay:500, keyup changed'); assert.equal(result.length, 2); assert.equal(result[0].name, 'click'); @@ -138,36 +138,36 @@ describe('bootstrap unit tests', function() { assert.equal(result[1].changed, true); }) - it("__parseTriggerSpecs parses event filter", function() { - const result = htmx.__parseTriggerSpecs('click[ctrlKey]'); + it("__parseEventSpecs parses event filter", function() { + const result = htmx.__parseEventSpecs('click[ctrlKey]'); assert.equal(result.length, 1); assert.equal(result[0].name, 'click[ctrlKey]'); }) - it("__parseTriggerSpecs parses event filter with spaces", function() { - const result = htmx.__parseTriggerSpecs('click[target.value == "test"]'); + it("__parseEventSpecs parses event filter with spaces", function() { + const result = htmx.__parseEventSpecs('click[target.value == "test"]'); assert.equal(result.length, 1); assert.equal(result[0].name, 'click[target.value == "test"]'); }) - it("__parseTriggerSpecs parses event with from option", function() { - const result = htmx.__parseTriggerSpecs('click from:body'); + it("__parseEventSpecs parses event with from option", function() { + const result = htmx.__parseEventSpecs('click from:body'); assert.equal(result.length, 1); assert.equal(result[0].name, 'click'); assert.equal(result[0].from, 'body'); }) - it("__parseTriggerSpecs throws on unterminated filter", function() { + it("__parseEventSpecs throws on unterminated filter", function() { assert.throws(() => { - htmx.__parseTriggerSpecs('click[ctrlKey'); + htmx.__parseEventSpecs('click[ctrlKey'); }, /unterminated/); }) - it("__parseTriggerSpecs handles complex real-world spec", function() { - const result = htmx.__parseTriggerSpecs('keyup[target.value.length > 3] changed delay:500ms from:input'); + it("__parseEventSpecs handles complex real-world spec", function() { + const result = htmx.__parseEventSpecs('keyup[target.value.length > 3] changed delay:500ms from:input'); assert.equal(result.length, 1); assert.equal(result[0].name, 'keyup[target.value.length > 3]'); @@ -176,8 +176,8 @@ describe('bootstrap unit tests', function() { assert.equal(result[0].from, 'input'); }) - it("__parseTriggerSpecs handles complex real-world spec w string and preserves spaces in string", function() { - const result = htmx.__parseTriggerSpecs('keyup[target.value == "hello world"] changed delay:500ms from:input'); + it("__parseEventSpecs handles complex real-world spec w string and preserves spaces in string", function() { + const result = htmx.__parseEventSpecs('keyup[target.value == "hello world"] changed delay:500ms from:input'); assert.equal(result.length, 1); assert.equal(result[0].name, 'keyup[target.value == "hello world"]'); diff --git a/www/src/content/docs/01-get-started/02-migration.md b/www/src/content/docs/01-get-started/02-migration.md index 4b0b07efd..add386a99 100644 --- a/www/src/content/docs/01-get-started/02-migration.md +++ b/www/src/content/docs/01-get-started/02-migration.md @@ -129,6 +129,19 @@ In htmx 4, the main content swaps first. OOB and [``](/docs/core-con This matters if an OOB swap creates or modifies DOM that the main swap depends on. If your app relies on that ordering, restructure so each swap is independent. +### `hx-trigger` `queue` modifier removed + +The `queue` modifier on [`hx-trigger`](/reference/attributes/hx-trigger) (e.g. `hx-trigger="click queue:all"`) no longer +works. Request queuing is now controlled exclusively by [`hx-sync`](/reference/attributes/hx-sync). + +```html + +
...
+ + +
...
+``` + ### 60-second timeout htmx 2 had no timeout (`0`). htmx 4 sets [`defaultTimeout`](/reference/config/htmx-config-defaultTimeout) to `60000`. diff --git a/www/src/content/docs/02-core-concepts/04-client-scripting.md b/www/src/content/docs/02-core-concepts/04-client-scripting.md index c576a5e64..8bc953070 100644 --- a/www/src/content/docs/02-core-concepts/04-client-scripting.md +++ b/www/src/content/docs/02-core-concepts/04-client-scripting.md @@ -184,3 +184,9 @@ htmx.onLoad((content) => { ``` This will ensure that as new content is added to the DOM by htmx, sortable elements are properly initialized. + +## See Also + +- [`hx-on`](/reference/attributes/hx-on) (attribute) +- [Hypermedia-Friendly Scripting](/essays/hypermedia-friendly-scripting) (essay) +- [Locality of Behaviour](/essays/locality-of-behaviour) (essay) diff --git a/www/src/content/reference/01-attributes/06-hx-trigger.md b/www/src/content/reference/01-attributes/06-hx-trigger.md index 316a65d37..668fcc5e6 100644 --- a/www/src/content/reference/01-attributes/06-hx-trigger.md +++ b/www/src/content/reference/01-attributes/06-hx-trigger.md @@ -3,65 +3,105 @@ title: "hx-trigger" description: "Control when an element issues a request" --- -The `hx-trigger` attribute specifies what triggers an AJAX request. +The `hx-trigger` attribute controls which event(s) trigger an element's AJAX request (set via [`hx-get`](/reference/attributes/hx-get), [`hx-post`](/reference/attributes/hx-post), etc.). -## Examples +Defaults to: +- [`change`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event) → `` / `