diff --git a/src/htmx.js b/src/htmx.js index 365f602d7..4e3b79d9e 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -693,151 +693,123 @@ var htmx = (() => { this.__onTrigger(elt, specString, initialHandler) } - // Wire up trigger listeners with full modifier support (delay, throttle, once, etc.) + // Wire up event listeners with full modifier support (once, prevent, stop, + // delay, throttle, changed, capture, passive, from, filter, etc.) __onTrigger(elt, specString, handler) { let specs = this.__parseTriggerSpecs(specString) this.__htmxProp(elt).triggerSpecs.push(...specs) for (let spec of specs) { - spec.handler = handler spec.listeners = [] - spec.values = new WeakMap() 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) - } + // Resolve from: elements (self listens on elt but filters by event.target in guard) + let fromElts = [elt]; + if (spec.from === 'outside') fromElts = [document]; + else if (spec.from && spec.from !== 'self') fromElts = this.__findAllExt(elt, spec.from); + + // Inner: runs after delay/throttle resolves + let inner = (evt) => { + if (spec.halt || spec.prevent) evt.preventDefault(); + if (spec.halt || spec.stop || spec.consume) evt.stopPropagation(); + if (spec.once) { + for (let info of spec.listeners) info.fromElt.removeEventListener(info.eventName, info.handler, info); } - } - - if (eventName === 'intersect' || eventName === "revealed") { - let observerOptions = {} - if (spec.root) observerOptions.root = this.__findOrWarn(elt, spec.root) - if (spec.threshold) observerOptions.threshold = parseFloat(spec.threshold) - let isRevealed = eventName === "revealed" - spec.observer = new IntersectionObserver((entries) => { - for (let i = 0; i < entries.length; i++) { - let entry = entries[i] - if (entry.isIntersecting) { - this.trigger(elt, 'intersect', {}, false) - if (isRevealed) { - spec.observer.disconnect() - } - break; - } - } - }, observerOptions) - eventName = "intersect" - spec.observer.observe(elt) - } + handler(evt); + }; + // Wrap inner with delay/throttle if needed + let timed = inner; if (spec.delay) { - let original = spec.handler - spec.handler = evt => { - clearTimeout(spec.timeout) - spec.timeout = setTimeout(() => original(evt), - this.parseInterval(spec.delay)); - } - } - - if (spec.throttle) { - let original = spec.handler - spec.handler = evt => { + timed = evt => { + clearTimeout(spec.timeout); + spec.timeout = setTimeout(() => inner(evt), this.parseInterval(spec.delay)); + }; + } else if (spec.throttle) { + timed = evt => { if (spec.throttled) { - spec.throttledEvent = evt + spec.throttledEvent = evt; } else { - spec.throttled = true - original(evt); + spec.throttled = true; + inner(evt); spec.throttleTimeout = setTimeout(() => { - spec.throttled = false + spec.throttled = false; if (spec.throttledEvent) { - // implement trailing-edge throttling - let throttledEvent = spec.throttledEvent; - spec.throttledEvent = null - spec.handler(throttledEvent); + let e = spec.throttledEvent; + spec.throttledEvent = null; + timed(e); } - }, this.parseInterval(spec.throttle)) + }, this.parseInterval(spec.throttle)); } - } + }; } - if (spec.target) { - let original = spec.handler - spec.handler = evt => { - if (evt.target?.matches?.(spec.target)) { - original(evt) + // Guarded: pre-timing checks that determine if event should proceed + spec.handler = (evt) => { + if (spec.from === 'self' && evt.target !== elt) return; + if (spec.from === 'outside' && elt.contains(evt.target)) return; + if (spec.target && !evt.target?.matches?.(spec.target)) return; + if (spec.changed) { + let values = spec.values ??= new WeakMap(); + let changed = false; + for (let fromElt of fromElts) { + if (values.get(fromElt) !== fromElt.value) { + changed = true; + values.set(fromElt, fromElt.value); + } } + if (!changed) return; } + if (filter) { + if (this.__shouldCancel(evt)) evt.preventDefault(); + let evtArgs = {}; for (let k in evt) evtArgs[k] = evt[k]; + if (!this.__executeJavaScript(elt, evtArgs, filter, true, false)) return; + } + timed(evt); + }; + + // Intersect/revealed: set up observer + if (eventName === 'intersect' || eventName === 'revealed') { + let observerOptions = {rootMargin: spec.rootMargin}; + if (spec.root) observerOptions.root = this.__findOrWarn(elt, spec.root); + if (spec.threshold) observerOptions.threshold = parseFloat(spec.threshold); + let isRevealed = eventName === 'revealed'; + spec.observer = new IntersectionObserver((entries) => { + for (let i = 0; i < entries.length; i++) { + if (entries[i].isIntersecting) { + this.trigger(elt, 'intersect', {}, false); + if (isRevealed) spec.observer.disconnect(); + break; + } + } + }, observerOptions); + eventName = 'intersect'; + spec.observer.observe(elt); } + // Every: set up interval if (eventName === "every") { let interval = Object.keys(spec).find(k => k !== 'name'); spec.interval = setInterval(() => { - if (elt.isConnected) { - this.__trigger(elt, 'every', {}, false); - } else { - clearInterval(spec.interval) - } + if (elt.isConnected) this.__trigger(elt, 'every', {}, false); + else clearInterval(spec.interval); }, 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) => { - if (this.__shouldCancel(evt)) evt.preventDefault() - let evtArgs = {}; for (let k in evt) evtArgs[k] = evt[k]; - if (this.__executeJavaScript(elt, evtArgs, filter, true, false)) { - original(evt) - } - } - } - - let fromElts = [elt]; - if (spec.from) { - fromElts = this.__findAllExt(elt, spec.from) - } - - if (spec.changed) { - let original = spec.handler - spec.handler = (evt) => { - let trigger = false - for (let fromElt of fromElts) { - if (spec.values.get(fromElt) !== fromElt.value) { - trigger = true - spec.values.set(fromElt, fromElt.value); - } - } - if (trigger) { - original(evt) - } - } - } - - // load: fire handler directly (no listener needed) + // Load: fire immediately, no listener needed if (eventName === 'load') { - spec.handler(new CustomEvent('load')) - continue + spec.handler(new CustomEvent('load')); + continue; } + // Register listeners for (let fromElt of fromElts) { - let listenerInfo = {fromElt, eventName, handler: spec.handler}; - elt._htmx.listeners.push(listenerInfo) - spec.listeners.push(listenerInfo) - fromElt.addEventListener(eventName, 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, listenerInfo); } } } @@ -976,7 +948,7 @@ var htmx = (() => { 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 +1623,35 @@ 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 hxOnNames = this.__prefixes("hx-on"); let mc = this.config.metaCharacter || ':'; + 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 }); + let prefix = hxOnNames.find(p => attr.startsWith(p)); + if (!prefix) continue; + let rest = attr.substring(prefix.length); + let value = node.getAttribute(attr); + // hx-on="click once -> doA(); blur -> doB()" + if (!rest) { + for (let part of value.split(/;(?=[^;]*->)/)) { + let idx = part.indexOf('->'); + if (idx !== -1) this.__onTrigger(node, part.substring(0, idx).trim(), handler(part.substring(idx + 2).trim())); } - }; - target.addEventListener(evtName, handler, opts); - this.__htmxProp(node).listeners.push({fromElt: target, eventName: evtName, handler}); + continue; + } + // hx-on:click="code" or hx-on::before:request="code" + if (rest[0] !== mc) continue; + let eventName = rest.substring(1); + if (eventName.startsWith(mc)) eventName = 'htmx' + mc + eventName.substring(1); + this.__onTrigger(node, eventName, handler(value)); } } 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/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 ff8631760..70ff8d574 100644 --- a/www/src/content/reference/01-attributes/06-hx-trigger.md +++ b/www/src/content/reference/01-attributes/06-hx-trigger.md @@ -3,263 +3,232 @@ title: "hx-trigger" description: "Controls when the 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) → `` / `