Skip to content

Commit cee310e

Browse files
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
1 parent 448db78 commit cee310e

File tree

3 files changed

+109
-23
lines changed

3 files changed

+109
-23
lines changed

src/htmx.js

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2431,23 +2431,22 @@ var htmx = (function() {
24312431
* @returns {boolean}
24322432
*/
24332433
function shouldCancel(evt, elt) {
2434-
if (evt.type === 'submit' || evt.type === 'click') {
2435-
// use elt from event that was submitted/clicked where possible to determining if default form/link behavior should be canceled
2436-
elt = asElement(evt.target) || elt
2437-
if (elt.tagName === 'FORM') {
2438-
return true
2439-
}
2440-
// find button wrapping the event elt
2441-
const btn = elt.closest('input[type="submit"], button')
2442-
// @ts-ignore Do not cancel on buttons that 1) don't have a related form or 2) have a type attribute of 'reset'/'button'.
2443-
// The properties will resolve to undefined for elements that don't define 'type' or 'form', which is fine
2434+
if (evt.type === 'submit' && elt.tagName === 'FORM') {
2435+
return true
2436+
} else if (evt.type === 'click') {
2437+
// find button wrapping the trigger element
2438+
const btn = /** @type {HTMLButtonElement|HTMLInputElement|null} */ (elt.closest('input[type="submit"], button'))
2439+
// Do not cancel on buttons that 1) don't have a related form or 2) have a type attribute of 'reset'/'button'.
24442440
if (btn && btn.form && btn.type === 'submit') {
24452441
return true
24462442
}
2447-
elt = elt.closest('a')
2448-
// @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
2449-
if (elt && elt.href &&
2450-
(elt.getAttribute('href') === '#' || elt.getAttribute('href').indexOf('#') !== 0)) {
2443+
2444+
// find link wrapping the trigger element
2445+
const link = elt.closest('a')
2446+
// Allow links with href="#fragment" (anchors with content after #) to perform normal fragment navigation.
2447+
// Cancel default action for links with href="#" (bare hash) to prevent scrolling to top and unwanted URL changes.
2448+
const samePageAnchor = /^#.+/
2449+
if (link && link.href && !samePageAnchor.test(link.getAttribute('href'))) {
24512450
return true
24522451
}
24532452
}
@@ -2524,7 +2523,7 @@ var htmx = (function() {
25242523
if (ignoreBoostedAnchorCtrlClick(elt, evt)) {
25252524
return
25262525
}
2527-
if (explicitCancel || shouldCancel(evt, elt)) {
2526+
if (explicitCancel || shouldCancel(evt, eltToListenOn)) {
25282527
evt.preventDefault()
25292528
}
25302529
if (maybeFilterEvent(triggerSpec, elt, evt)) {

test/core/internals.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,20 +98,20 @@ describe('Core htmx internals Tests', function() {
9898

9999
var form = make('<form></form>')
100100
htmx._('shouldCancel')({ type: 'submit', target: form }, form).should.equal(true)
101-
htmx._('shouldCancel')({ type: 'click', target: form }, form).should.equal(true)
102-
103-
// falls back to check elt tag when target is not an element
104-
htmx._('shouldCancel')({ type: 'click', target: null }, form).should.equal(true)
105101

106102
// check that events targeting elements that shouldn't cancel don't cancel
107-
htmx._('shouldCancel')({ type: 'submit', target: anchorThatShouldNotCancel }, form).should.equal(false)
108103
htmx._('shouldCancel')({ type: 'click', target: divThatShouldNotCancel }, form).should.equal(false)
109104

110105
// check elements inside links getting click events should cancel parent links
111106
var anchorWithButton = make("<a href='/foo'><button></button></a>")
112107
htmx._('shouldCancel')({ type: 'click', target: anchorWithButton.firstChild }, anchorWithButton).should.equal(true)
113108
htmx._('shouldCancel')({ type: 'click', target: anchorWithButton.firstChild }, anchorWithButton.firstChild).should.equal(true)
114109

110+
// check that links inside htmx elements should not cancel
111+
var divWithLink = make("<div hx-get='/data'><a href='/page'>Link</a></div>")
112+
var link = divWithLink.querySelector('a')
113+
htmx._('shouldCancel')({ type: 'click', target: link }, divWithLink).should.equal(false)
114+
115115
form = make('<form id="f1">' +
116116
'<input id="insideInput" type="submit">' +
117117
'<button id="insideFormBtn"></button>' +

test/core/regressions.js

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,11 @@ describe('Core htmx Regression Tests', function() {
100100
it('does not submit with a false condition on a form', function() {
101101
this.server.respondWith('POST', '/test', 'Submitted')
102102
var defaultPrevented = false
103-
htmx.on('click', function(evt) {
103+
htmx.on('submit', function(evt) {
104104
defaultPrevented = evt.defaultPrevented
105105
})
106-
var form = make('<form hx-post="/test" hx-trigger="click[false]"></form>')
107-
form.click()
106+
var form = make('<form hx-post="/test" hx-trigger="submit[false]"><button id="b1">submit</button></form>')
107+
byId('b1').click()
108108
this.server.respond()
109109
defaultPrevented.should.equal(true)
110110
})
@@ -385,6 +385,93 @@ describe('Core htmx Regression Tests', function() {
385385
span.click()
386386
})
387387

388+
it('a htmx enabled element inside a form button will prevent the button submitting a form', function(done) {
389+
var defaultPrevented = 'unset'
390+
var form = make('<form><button><span hx-get="/foo">test</span></button></form>')
391+
var button = form.firstChild
392+
var span = button.firstChild
393+
394+
htmx.on(button, 'click', function(evt) {
395+
// we need to wait so the state of the evt is finalized
396+
setTimeout(() => {
397+
defaultPrevented = evt.defaultPrevented
398+
try {
399+
defaultPrevented.should.equal(true)
400+
done()
401+
} catch (err) {
402+
done(err)
403+
}
404+
}, 0)
405+
})
406+
407+
span.click()
408+
})
409+
410+
it('from: trigger on form prevents default form submission', function(done) {
411+
var defaultPrevented = 'unset'
412+
var form = make('<form id="test-form" action="/submit"><input type="submit" value="Submit"></form>')
413+
var div = make('<div hx-post="/test" hx-trigger="submit from:#test-form"></div>')
414+
var submitBtn = form.firstChild
415+
416+
htmx.on(form, 'submit', function(evt) {
417+
defaultPrevented = evt.defaultPrevented // Capture state before our preventDefault
418+
evt.preventDefault() // Prevent navigation in case test fails
419+
setTimeout(() => {
420+
try {
421+
defaultPrevented.should.equal(true)
422+
done()
423+
} catch (err) {
424+
done(err)
425+
}
426+
}, 0)
427+
})
428+
429+
submitBtn.click()
430+
})
431+
432+
it('from: trigger on button prevents default form submission', function(done) {
433+
var defaultPrevented = 'unset'
434+
var form = make('<form><button id="test-btn" type="submit">Submit</button></form>')
435+
var div = make('<div hx-post="/test" hx-trigger="click from:#test-btn"></div>')
436+
var button = byId('test-btn')
437+
438+
htmx.on(button, 'click', function(evt) {
439+
defaultPrevented = evt.defaultPrevented // Capture state before our preventDefault
440+
evt.preventDefault() // Prevent form submission in case test fails
441+
setTimeout(() => {
442+
try {
443+
defaultPrevented.should.equal(true)
444+
done()
445+
} catch (err) {
446+
done(err)
447+
}
448+
}, 0)
449+
})
450+
451+
button.click()
452+
})
453+
454+
it('from: trigger on link prevents default navigation', function(done) {
455+
var defaultPrevented = 'unset'
456+
var link = make('<a id="test-link" href="/page">Go to page</a>')
457+
var div = make('<div hx-get="/test" hx-trigger="click from:#test-link"></div>')
458+
459+
htmx.on(link, 'click', function(evt) {
460+
defaultPrevented = evt.defaultPrevented // Capture state before our preventDefault
461+
evt.preventDefault() // Prevent navigation in case test fails
462+
setTimeout(() => {
463+
try {
464+
defaultPrevented.should.equal(true)
465+
done()
466+
} catch (err) {
467+
done(err)
468+
}
469+
}, 0)
470+
})
471+
472+
link.click()
473+
})
474+
388475
it('check deleting button during click does not trigger exception error in getRelatedFormData when button can no longer find form', function() {
389476
var defaultPrevented = 'unset'
390477
var form = make('<form><button>delete</button></form>')

0 commit comments

Comments
 (0)