diff --git a/src/htmx.js b/src/htmx.js index c2b6080db..ef2449190 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -699,6 +699,7 @@ var htmx = (function() { * @property {boolean} [triggeredOnce] * @property {number} [delayed] * @property {number|null} [throttle] + * @property {number|null} [holdTimer] * @property {WeakMap>} [lastValue] * @property {boolean} [loaded] * @property {string} [path] @@ -2290,6 +2291,9 @@ var htmx = (function() { } else if (token === 'throttle' && tokens[0] === ':') { tokens.shift() triggerSpec.throttle = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)) + } else if (token === 'hold' && tokens[0] === ':') { + tokens.shift() + triggerSpec.hold = parseInterval(consumeUntil(tokens, WHITESPACE_OR_COMMA)) } else if (token === 'queue' && tokens[0] === ':') { tokens.shift() triggerSpec.queue = consumeUntil(tokens, WHITESPACE_OR_COMMA) @@ -2492,6 +2496,7 @@ var htmx = (function() { * @param {boolean} [explicitCancel] */ function addEventListener(elt, handler, nodeData, triggerSpec, explicitCancel) { + /** @type {HtmxNodeInternalData} */ const elementData = getInternalData(elt) /** @type {(Node|Window)[]} */ let eltsToListenOn @@ -2513,11 +2518,12 @@ var htmx = (function() { elementData.lastValue.get(triggerSpec).set(eltToListenOn, eltToListenOn.value) }) } + const actualTrigger = triggerSpec.hold ? 'pointerdown' : triggerSpec.trigger forEach(eltsToListenOn, function(eltToListenOn) { /** @type EventListener */ const eventListener = function(evt) { if (!bodyContains(elt)) { - eltToListenOn.removeEventListener(triggerSpec.trigger, eventListener) + eltToListenOn.removeEventListener(actualTrigger, eventListener) return } if (ignoreBoostedAnchorCtrlClick(elt, evt)) { @@ -2568,7 +2574,23 @@ var htmx = (function() { return } - if (triggerSpec.throttle > 0) { + if (triggerSpec.hold > 0) { + if (elementData.holdTimer) clearTimeout(elementData.holdTimer) + elementData.holdTimer = getWindow().setTimeout(function() { + triggerEvent(elt, 'htmx:trigger') + handler(elt, evt) + }, triggerSpec.hold) + const cancelListener = function() { + if (elementData.holdTimer) { + clearTimeout(elementData.holdTimer) + elementData.holdTimer = null + } + } + eltToListenOn.addEventListener('pointerup', cancelListener, { once: true }) + nodeData.listenerInfos.push({ trigger: 'pointerup', listener: cancelListener, on: eltToListenOn }) + eltToListenOn.addEventListener('pointercancel', cancelListener, { once: true }) + nodeData.listenerInfos.push({ trigger: 'pointercancel', listener: cancelListener, on: eltToListenOn }) + } else if (triggerSpec.throttle > 0) { if (!elementData.throttle) { triggerEvent(elt, 'htmx:trigger') handler(elt, evt) @@ -2591,11 +2613,29 @@ var htmx = (function() { nodeData.listenerInfos = [] } nodeData.listenerInfos.push({ - trigger: triggerSpec.trigger, + trigger: actualTrigger, listener: eventListener, on: eltToListenOn }) - eltToListenOn.addEventListener(triggerSpec.trigger, eventListener) + eltToListenOn.addEventListener(actualTrigger, eventListener) + if (triggerSpec.hold) { + if (actualTrigger !== 'mousedown') { + nodeData.listenerInfos.push({ + trigger: 'mousedown', + listener: eventListener, + on: eltToListenOn + }) + eltToListenOn.addEventListener('mousedown', eventListener) + } + if (actualTrigger !== 'touchstart') { + nodeData.listenerInfos.push({ + trigger: 'touchstart', + listener: eventListener, + on: eltToListenOn + }) + eltToListenOn.addEventListener('touchstart', eventListener) + } + } }) } @@ -5209,6 +5249,7 @@ var htmx = (function() { * @property {string} [queue] * @property {string} [root] * @property {string} [threshold] + * @property {number} [hold] */ /** diff --git a/test/attributes/hx-trigger.js b/test/attributes/hx-trigger.js index ba892d42b..52bf1d9e2 100644 --- a/test/attributes/hx-trigger.js +++ b/test/attributes/hx-trigger.js @@ -297,6 +297,9 @@ describe('hx-trigger attribute', function() { 'event throttle:0s': [{ trigger: 'event', throttle: 0 }], 'event throttle:0ms': [{ trigger: 'event', throttle: 0 }], 'event throttle:1s, foo': [{ trigger: 'event', throttle: 1000 }, { trigger: 'foo' }], + 'event hold:1s': [{ trigger: 'event', hold: 1000 }], + 'event hold:0s': [{ trigger: 'event', hold: 0 }], + 'event hold:0ms': [{ trigger: 'event', hold: 0 }], 'event delay:1s': [{ trigger: 'event', delay: 1000 }], 'event delay:1s, foo': [{ trigger: 'event', delay: 1000 }, { trigger: 'foo' }], 'event delay:0s, foo': [{ trigger: 'event', delay: 0 }, { trigger: 'foo' }], @@ -1370,4 +1373,137 @@ describe('hx-trigger attribute', function() { complete() }, 30) }) + + it('hold does not trigger before time', function(done) { + this.server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var event = htmx._('makeEvent')('pointerdown') + div.dispatchEvent(event) + setTimeout(function() { + div.innerHTML.should.equal('Not Held') + done() + }, 25) + }) + + it('hold triggers after specified time', function(done) { + var server = this.server + server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var event = htmx._('makeEvent')('pointerdown') + div.dispatchEvent(event) + setTimeout(function() { + server.respond() + div.innerHTML.should.equal('Held!') + done() + }, 75) + }) + + it('hold cancels on pointerup before time', function(done) { + this.server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var downEvent = htmx._('makeEvent')('pointerdown') + var upEvent = htmx._('makeEvent')('pointerup') + div.dispatchEvent(downEvent) + setTimeout(function() { + div.dispatchEvent(upEvent) + }, 25) + setTimeout(function() { + div.innerHTML.should.equal('Not Held') + done() + }, 75) + }) + + it('hold cancels on pointercancel before time', function(done) { + this.server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var downEvent = htmx._('makeEvent')('pointerdown') + var cancelEvent = htmx._('makeEvent')('pointercancel') + div.dispatchEvent(downEvent) + setTimeout(function() { + div.dispatchEvent(cancelEvent) + }, 25) + setTimeout(function() { + div.innerHTML.should.equal('Not Held') + done() + }, 75) + }) + + it('hold started with touchstart cancels on pointerup', function(done) { + var server = this.server + server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var touchStart = htmx._('makeEvent')('touchstart') + var pointerUp = htmx._('makeEvent')('pointerup') + div.dispatchEvent(touchStart) + setTimeout(function() { + div.dispatchEvent(pointerUp) + }, 25) + setTimeout(function() { + div.innerHTML.should.equal('Not Held') + done() + }, 75) + }) + + it('hold started with mousedown cancels on pointerup', function(done) { + var server = this.server + server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var mouseDown = htmx._('makeEvent')('mousedown') + var pointerUp = htmx._('makeEvent')('pointerup') + div.dispatchEvent(mouseDown) + setTimeout(function() { + div.dispatchEvent(pointerUp) + }, 25) + setTimeout(function() { + div.innerHTML.should.equal('Not Held') + done() + }, 75) + }) + + it('hold works with mouse events', function(done) { + var server = this.server + server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var event = htmx._('makeEvent')('mousedown') + div.dispatchEvent(event) + setTimeout(function() { + server.respond() + div.innerHTML.should.equal('Held!') + done() + }, 75) + }) + + it('hold works with touch events', function(done) { + var server = this.server + server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var event = htmx._('makeEvent')('touchstart') + div.dispatchEvent(event) + setTimeout(function() { + server.respond() + div.innerHTML.should.equal('Held!') + done() + }, 75) + }) + + it('hold works with pointer events', function(done) { + var server = this.server + server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + var event = htmx._('makeEvent')('pointerdown') + div.dispatchEvent(event) + setTimeout(function() { + server.respond() + div.innerHTML.should.equal('Held!') + done() + }, 75) + }) + + it('hold:0ms triggers immediately', function() { + this.server.respondWith('GET', '/test', 'Held!') + var div = make('
Not Held
') + div.click() + this.server.respond() + div.innerHTML.should.equal('Held!') + }) }) diff --git a/www/content/attributes/hx-trigger.md b/www/content/attributes/hx-trigger.md index a2410f401..9957802a5 100644 --- a/www/content/attributes/hx-trigger.md +++ b/www/content/attributes/hx-trigger.md @@ -61,6 +61,7 @@ Standard events can also have modifiers that change how they behave. The modifi is seen again it will reset the delay. * `throttle:` - a throttle will occur after an event triggers a request. If the event is seen again before the delay completes, it is ignored, the element will trigger at the end of the delay. +* `hold:` - requires the user to hold the pointer down for the specified duration before triggering the request. Cancels on pointer up or cancel. * `from:` - allows the event that triggers a request to come from another element in the document (e.g. listening to a key event on the body, to support hot keys) * A standard CSS selector resolves to all elements matching that selector. Thus, `from:input` would listen on every input on the page. * The CSS selector is only evaluated once and is not re-evaluated when the page changes. If you need to detect dynamically added elements use a [standard event filter](#standard-event-filters), for example `hx-trigger="click[event.target.matches('button')] from:body"` which would [catch](https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Event_bubbling) click events from every button on the page. diff --git a/www/static/src/htmx.js b/www/static/src/htmx.js index c2b6080db..36bf368ba 100644 --- a/www/static/src/htmx.js +++ b/www/static/src/htmx.js @@ -5209,6 +5209,7 @@ var htmx = (function() { * @property {string} [queue] * @property {string} [root] * @property {string} [threshold] + * @property {number} [hold] */ /** diff --git a/www/themes/htmx-theme/static/js/htmx.js b/www/themes/htmx-theme/static/js/htmx.js index c2b6080db..36bf368ba 100644 --- a/www/themes/htmx-theme/static/js/htmx.js +++ b/www/themes/htmx-theme/static/js/htmx.js @@ -5209,6 +5209,7 @@ var htmx = (function() { * @property {string} [queue] * @property {string} [root] * @property {string} [threshold] + * @property {number} [hold] */ /**