Handle not preventing link when inside htmx enabled element (#3396)

* Handle not preventing link when inside htmx enabled element

* Simplify shouldCancel and pass in eltToListenOn to solve from: issue without regressions

* move regex to local variable format
This commit is contained in:
MichaelWest22
2025-09-08 06:09:05 +12:00
committed by GitHub
parent 448db781ef
commit cee310e4d5
3 changed files with 109 additions and 23 deletions

View File

@@ -2431,23 +2431,22 @@ var htmx = (function() {
* @returns {boolean}
*/
function shouldCancel(evt, elt) {
if (evt.type === 'submit' || evt.type === 'click') {
// use elt from event that was submitted/clicked where possible to determining if default form/link behavior should be canceled
elt = asElement(evt.target) || elt
if (elt.tagName === 'FORM') {
return true
}
// find button wrapping the event elt
const btn = elt.closest('input[type="submit"], button')
// @ts-ignore Do not cancel on buttons that 1) don't have a related form or 2) have a type attribute of 'reset'/'button'.
// The properties will resolve to undefined for elements that don't define 'type' or 'form', which is fine
if (evt.type === 'submit' && elt.tagName === 'FORM') {
return true
} else if (evt.type === 'click') {
// find button wrapping the trigger element
const btn = /** @type {HTMLButtonElement|HTMLInputElement|null} */ (elt.closest('input[type="submit"], button'))
// Do not cancel on buttons that 1) don't have a related form or 2) have a type attribute of 'reset'/'button'.
if (btn && btn.form && btn.type === 'submit') {
return true
}
elt = elt.closest('a')
// @ts-ignore check for a link wrapping the event elt or if elt is a link. elt will be link so href check is fine
if (elt && elt.href &&
(elt.getAttribute('href') === '#' || elt.getAttribute('href').indexOf('#') !== 0)) {
// find link wrapping the trigger element
const link = elt.closest('a')
// Allow links with href="#fragment" (anchors with content after #) to perform normal fragment navigation.
// Cancel default action for links with href="#" (bare hash) to prevent scrolling to top and unwanted URL changes.
const samePageAnchor = /^#.+/
if (link && link.href && !samePageAnchor.test(link.getAttribute('href'))) {
return true
}
}
@@ -2524,7 +2523,7 @@ var htmx = (function() {
if (ignoreBoostedAnchorCtrlClick(elt, evt)) {
return
}
if (explicitCancel || shouldCancel(evt, elt)) {
if (explicitCancel || shouldCancel(evt, eltToListenOn)) {
evt.preventDefault()
}
if (maybeFilterEvent(triggerSpec, elt, evt)) {

View File

@@ -98,13 +98,8 @@ describe('Core htmx internals Tests', function() {
var form = make('<form></form>')
htmx._('shouldCancel')({ type: 'submit', target: form }, form).should.equal(true)
htmx._('shouldCancel')({ type: 'click', target: form }, form).should.equal(true)
// falls back to check elt tag when target is not an element
htmx._('shouldCancel')({ type: 'click', target: null }, form).should.equal(true)
// check that events targeting elements that shouldn't cancel don't cancel
htmx._('shouldCancel')({ type: 'submit', target: anchorThatShouldNotCancel }, form).should.equal(false)
htmx._('shouldCancel')({ type: 'click', target: divThatShouldNotCancel }, form).should.equal(false)
// check elements inside links getting click events should cancel parent links
@@ -112,6 +107,11 @@ describe('Core htmx internals Tests', function() {
htmx._('shouldCancel')({ type: 'click', target: anchorWithButton.firstChild }, anchorWithButton).should.equal(true)
htmx._('shouldCancel')({ type: 'click', target: anchorWithButton.firstChild }, anchorWithButton.firstChild).should.equal(true)
// check that links inside htmx elements should not cancel
var divWithLink = make("<div hx-get='/data'><a href='/page'>Link</a></div>")
var link = divWithLink.querySelector('a')
htmx._('shouldCancel')({ type: 'click', target: link }, divWithLink).should.equal(false)
form = make('<form id="f1">' +
'<input id="insideInput" type="submit">' +
'<button id="insideFormBtn"></button>' +

View File

@@ -100,11 +100,11 @@ describe('Core htmx Regression Tests', function() {
it('does not submit with a false condition on a form', function() {
this.server.respondWith('POST', '/test', 'Submitted')
var defaultPrevented = false
htmx.on('click', function(evt) {
htmx.on('submit', function(evt) {
defaultPrevented = evt.defaultPrevented
})
var form = make('<form hx-post="/test" hx-trigger="click[false]"></form>')
form.click()
var form = make('<form hx-post="/test" hx-trigger="submit[false]"><button id="b1">submit</button></form>')
byId('b1').click()
this.server.respond()
defaultPrevented.should.equal(true)
})
@@ -385,6 +385,93 @@ describe('Core htmx Regression Tests', function() {
span.click()
})
it('a htmx enabled element inside a form button will prevent the button submitting a form', function(done) {
var defaultPrevented = 'unset'
var form = make('<form><button><span hx-get="/foo">test</span></button></form>')
var button = form.firstChild
var span = button.firstChild
htmx.on(button, 'click', function(evt) {
// we need to wait so the state of the evt is finalized
setTimeout(() => {
defaultPrevented = evt.defaultPrevented
try {
defaultPrevented.should.equal(true)
done()
} catch (err) {
done(err)
}
}, 0)
})
span.click()
})
it('from: trigger on form prevents default form submission', function(done) {
var defaultPrevented = 'unset'
var form = make('<form id="test-form" action="/submit"><input type="submit" value="Submit"></form>')
var div = make('<div hx-post="/test" hx-trigger="submit from:#test-form"></div>')
var submitBtn = form.firstChild
htmx.on(form, 'submit', function(evt) {
defaultPrevented = evt.defaultPrevented // Capture state before our preventDefault
evt.preventDefault() // Prevent navigation in case test fails
setTimeout(() => {
try {
defaultPrevented.should.equal(true)
done()
} catch (err) {
done(err)
}
}, 0)
})
submitBtn.click()
})
it('from: trigger on button prevents default form submission', function(done) {
var defaultPrevented = 'unset'
var form = make('<form><button id="test-btn" type="submit">Submit</button></form>')
var div = make('<div hx-post="/test" hx-trigger="click from:#test-btn"></div>')
var button = byId('test-btn')
htmx.on(button, 'click', function(evt) {
defaultPrevented = evt.defaultPrevented // Capture state before our preventDefault
evt.preventDefault() // Prevent form submission in case test fails
setTimeout(() => {
try {
defaultPrevented.should.equal(true)
done()
} catch (err) {
done(err)
}
}, 0)
})
button.click()
})
it('from: trigger on link prevents default navigation', function(done) {
var defaultPrevented = 'unset'
var link = make('<a id="test-link" href="/page">Go to page</a>')
var div = make('<div hx-get="/test" hx-trigger="click from:#test-link"></div>')
htmx.on(link, 'click', function(evt) {
defaultPrevented = evt.defaultPrevented // Capture state before our preventDefault
evt.preventDefault() // Prevent navigation in case test fails
setTimeout(() => {
try {
defaultPrevented.should.equal(true)
done()
} catch (err) {
done(err)
}
}, 0)
})
link.click()
})
it('check deleting button during click does not trigger exception error in getRelatedFormData when button can no longer find form', function() {
var defaultPrevented = 'unset'
var form = make('<form><button>delete</button></form>')