I'm really impressed with what you have built with this library.
One issue that I'm seeing is the docs say to use the responseError.htmx event to handle errors. It seems to me there should be a way to have error responses handled directly in the HTML.
<form hx-post="/ajax/contact" hx-target-4xx=".messages">
<div class="messages"></div>
<div>
<label>Name</label>
<input type="text" name="name" value="" />
</div>
<div>
<label>Email</label>
<input type="text" name="email" value="" />
</div>
<div>
<label>Message</label>
<textarea name="message"></textarea>
</div>
<div>
<input type="submit" value="Send" />
<span class="loading"></span>
</div>
</form>
STATUS: 200
<p>Thank you for contacting us!</p>
The whole form gets replaced.
STATUS: 422
<p class="error">All fields are required.</p>
I put together a rough example with an extension showing it working in more detail. It adds the ability to target specific error messages: hx-target-422=".messages"
It has a lot of duplicate functions at the end because the API does not provide a lot of access to the swap functions (unless I'm missing something).
Here is the roughed out working example (assume the server responds with the example responses above):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="">
<meta name="author" content="">
<title>Contact Us</title>
</head>
<body>
<form hx-post="/ajax/contact" hx-target-4xx=".messages" hx-ext="hx-target-error">
<div class="messages"></div>
<div>
<label>Name</label>
<input type="text" name="name" value="" />
</div>
<div>
<label>Email</label>
<input type="text" name="email" value="" />
</div>
<div>
<label>Message</label>
<textarea name="message"></textarea>
</div>
<div>
<input type="submit" value="Send" />
<span class="loading"></span>
</div>
</form>
<script src="https://unpkg.com/[email protected]"></script>
<script>
htmx.defineExtension('hx-target-error', {
onEvent : function(name, evt) {
if(name === "responseError.htmx") {
var elt = evt.detail.elt;
var response = evt.detail.xhr.response;
var status = evt.detail.xhr.status;
var targetError = getTargetError(elt, status);
var settleInfo = makeSettleInfo(targetError);
var fragment = makeFragment(response);
if(targetError) {
swapInnerHTML(targetError, fragment, settleInfo);
}
}
}
})
function getTargetError(elt, status) {
var match = getClosestMatchError(elt, status);
if (match && match.target) {
var explicitTarget = match.target;
var attr = match.attr;
var targetStr = getAttributeValue(explicitTarget, attr);
if (targetStr === "this") {
return explicitTarget;
} else if (targetStr.indexOf("closest ") === 0) {
return closest(elt, targetStr.substr(8));
} else {
return getDocument().querySelector(targetStr);
}
}
}
function getClosestMatchError(elt, status) {
var output = {};
output.attr = "hx-target-" + status;
output.target = getClosestMatch(elt, function(e){return getAttributeValue(e,output.attr) !== null});
if(!output.target) {
output.attr = "hx-target-" + Math.floor(status/10) + "x";
output.target = getClosestMatch(elt, function(e){return getAttributeValue(e,output.attr) !== null});
}
if(!output.target) {
output.attr = "hx-target-" + Math.floor(status/100) + "xx";
output.target = getClosestMatch(elt, function(e){return getAttributeValue(e,output.attr) !== null});
}
if(!output.target) {
output.attr = "hx-target-error";
output.target = getClosestMatch(elt, function(e){return getAttributeValue(e,output.attr) !== null});
}
return output;
}
// Duplicate functions because the API is locked down with limited access.
// No need to read further.
function closest(elt, selector) {
do if (elt == null || matches(elt, selector)) return elt;
while (elt = elt && parentElt(elt));
}
function forEach(arr, func) {
if (arr) {
for (var i = 0; i < arr.length; i++) {
func(arr[i]);
}
}
}
function getAttributeValue(elt, qualifiedName) {
return getRawAttribute(elt, qualifiedName) || getRawAttribute(elt, "data-" + qualifiedName);
}
function getClosestMatch(elt, condition) {
if (condition(elt)) {
return elt;
} else if (parentElt(elt)) {
return getClosestMatch(parentElt(elt), condition);
} else {
return null;
}
}
function getDocument() {
return document;
}
function getRawAttribute(elt, name) {
return elt.getAttribute && elt.getAttribute(name);
}
function getStartTag(str) {
var tagMatcher = /<([a-z][^\/\0>\x20\t\r\n\f]*)/i
var match = tagMatcher.exec( str );
if (match) {
return match[1].toLowerCase();
} else {
return "";
}
}
function handleAttributes(parentNode, fragment, settleInfo) {
forEach(fragment.querySelectorAll("[id]"), function (newNode) {
var oldNode = parentNode.querySelector(newNode.tagName + "[id=" + newNode.id + "]")
if (oldNode && oldNode !== parentNode) {
var newAttributes = newNode.cloneNode();
cloneAttributes(newNode, oldNode);
settleInfo.tasks.push(function () {
cloneAttributes(newNode, newAttributes);
});
}
});
}
function insertNodesBefore(parentNode, insertBefore, fragment, settleInfo) {
handleAttributes(parentNode, fragment, settleInfo);
while(fragment.childNodes.length > 0){
var child = fragment.firstChild;
parentNode.insertBefore(child, insertBefore);
if (child.nodeType !== Node.TEXT_NODE) {
//settleInfo.tasks.push(makeLoadTask(child));
}
}
}
function makeFragment(resp) {
var startTag = getStartTag(resp);
switch (startTag) {
case "thead":
case "tbody":
case "tfoot":
case "colgroup":
case "caption":
return parseHTML("<table>" + resp + "</table>", 1);
case "col":
return parseHTML("<table><colgroup>" + resp + "</colgroup></table>", 2);
case "tr":
return parseHTML("<table><tbody>" + resp + "</tbody></table>", 2);
case "td":
case "th":
return parseHTML("<table><tbody><tr>" + resp + "</tr></tbody></table>", 3);
default:
return parseHTML(resp, 0);
}
}
function makeLoadTask(child) {
return function () {
processNode(child);
triggerEvent(child, 'load.htmx', {});
};
}
function makeSettleInfo(target) {
return {tasks: [], elts: [target]};
}
function parentElt(elt) {
return elt.parentElement;
}
function parseHTML(resp, depth) {
var parser = new DOMParser();
var responseDoc = parser.parseFromString(resp, "text/html");
var responseNode = responseDoc.body;
while (depth > 0) {
depth--;
responseNode = responseNode.firstChild;
}
if (responseNode == null) {
responseNode = getDocument().createDocumentFragment();
}
return responseNode;
}
function swap(swapStyle, elt, target, fragment, settleInfo) {
switch (swapStyle) {
case "outerHTML":
swapOuterHTML(target, fragment, settleInfo);
return;
case "afterbegin":
swapAfterBegin(target, fragment, settleInfo);
return;
case "beforebegin":
swapBeforeBegin(target, fragment, settleInfo);
return;
case "beforeend":
swapBeforeEnd(target, fragment, settleInfo);
return;
case "afterend":
swapAfterEnd(target, fragment, settleInfo);
return;
default:
var extensions = getExtensions(elt);
for (var i = 0; i < extensions.length; i++) {
var ext = extensions[i];
try {
if (ext.handleSwap(swapStyle, target, fragment, settleInfo)) {
return;
}
} catch (e) {
logError(e);
}
}
swapInnerHTML(target, fragment, settleInfo);
}
}
function swapInnerHTML(target, fragment, settleInfo) {
var firstChild = target.firstChild;
insertNodesBefore(target, firstChild, fragment, settleInfo);
if (firstChild) {
while (firstChild.nextSibling) {
target.removeChild(firstChild.nextSibling);
}
target.removeChild(firstChild);
}
}
</script>
</body>
</html>