DEV Community

Cover image for Like htmx, but with only one attribute per element - a prototype
Henrique Dias
Henrique Dias

Posted on • Edited on

Like htmx, but with only one attribute per element - a prototype

Introduction

The htmx is a great tool that provides access to AJAX, CSS transitions, WebSockets, and server-sent events directly in HTML. It accomplishes this by using attributes such as hx-get, hx-post, hx-trigger, etc. within HTML tags. However, in certain scenarios, the use of many attributes in the same element can contribute to element pollution. Picture a train of attributes within the same element.

The Prototype

The concept behind this prototype is to apply the same principle as htmx but with a simplified approach, utilizing only one attribute (data-tasks) like the class attribute. The class attribute is often used to point to a class name in a style sheet. This prototype also uses an attribute but to perform tasks that are defined in a json file, to mimic the styles file used by classes.

We can imagine the "data-tasks" attribute like a class of css, but instead of styling the elements styles the actions?

Let's create a simple example to elucidate the concept. Essentially, it involves a button that, upon being clicked, triggers an action on the server, returning a html list. The list includes a button at the end to clear it and restore the initial state.

Example:

<div id="bag">
    <button data-tasks="get-fruits" class="fruits-button">
        Get Fresh Fruits
    </button>
</div>
Enter fullscreen mode Exit fullscreen mode

The idea is that one or more tasks can be defined in an element, similar to classes, where one or more blocks can be defined within an element.
<element data-tasks="task-1 task-n" class="block-1 block-n"></element>

styles.css

.fruits-button {
    color: #37474F;
    font-weight: bold;
    background-color: #fff;
    border: 1px solid #000;
    cursor: pointer;
}
.fruits-button:hover {
    background-color: #c0c0c0;
}
Enter fullscreen mode Exit fullscreen mode

To "stylize" the actions, we will use a JSON file because it is simple to define blocks with the values of the properties (key/value).

tasks.json

{
    "get-fruits": {
        "action": "/getfruits",
        "method": "get",
        "target": "#bag",
        "swap": "innerHTML",
        "trigger": "click"
    },
    "empty-bag": {
        "action": "/emptybag",
        "method": "get",
        "target": "#bag",
        "swap": "innerHTML",
        "trigger": "click"
    }
}
Enter fullscreen mode Exit fullscreen mode

To implement the idea, I created a basic JavaScript library named "automata.js" with a class called "Automata". This library reads a json file containing tasks and executes them.

The crucial component is the MutationObserver interface, which enables the monitoring of changes to the DOM tree when HTML fragments are inserted or removed.

For testing purposes only a few events are used.

automata.js

/* <![CDATA[ */

class Automata {
    constructor(parameters = {
        "attribute": "data-tasks",
        "json_file": "tasks.json"
    }) {
        // https://developer.mozilla.org/en-US/docs/Web/API/Element#events
        // Some events to test
        this.triggers = new Set([
            'click',
            'mouseenter',
            'mouseover',
            'mouseup',
            'mouseleave'
        ]);
        this.dataAttribute = parameters["attribute"];
        this.jsonFile = parameters["json_file"];
        this.tasks = {};
    }

    async makeRequest(data = {}) {
        const response = await fetch(data.action, {
            method: data.method,
            cache: "no-cache"
        });

        const fragment = await response.text()
        return fragment;
    }

    async getDefinedTasks() {
        const response = await fetch(this.jsonFile, {
            cache: "no-cache"
        });

        this.tasks = await response.json();
    }

    setTask(element) {
        const elementTasks = element.dataset.tasks.split(/ +/);

        let _this = this;
        elementTasks.forEach(function (task) {
            if (_this.tasks.hasOwnProperty(task)) {
                // console.log("Task:", task);

                const parameters = _this.tasks[task];
                // the default trigger for Button is "click"
                if (!(parameters.hasOwnProperty('trigger') &&
                    _this.triggers.has(parameters.trigger)) &&
                    (element.nodeName === 'BUTTON' ||
                        (element.nodeName === 'INPUT' && element.type === 'button'))) {
                    parameters.trigger = 'click';
                }

                element.addEventListener(parameters.trigger, (event) => {
                    _this.makeRequest(parameters).then((data) => {
                        if (parameters.hasOwnProperty('target')) {
                            // console.log(data);
                            if (parameters.target === 'this') {
                                if (parameters.hasOwnProperty('swap') &&
                                    parameters.swap === 'outerHTML') {
                                    element.outerHTML = data;
                                } else {
                                    element.innerHTML = data;
                                }
                            } else {
                                if (parameters.hasOwnProperty('swap') &&
                                    parameters.swap === 'outerHTML') {
                                    document.querySelector(parameters.target).outerHTML = data;
                                } else {
                                    document.querySelector(parameters.target).innerHTML = data;
                                }
                            }
                        }
                    });
                }, false);
            }
        });
    }

    search4Tasks(parentNode) {

        const elemWithTasks = parentNode.querySelectorAll("[" + this.dataAttribute + "]");

        if (elemWithTasks.length > 0) {
            let _this = this;
            elemWithTasks.forEach(function (element, index) {
                // console.log(element.dataset.tasks);
                _this.setTask(element);
            });
        }
    }

    findTasksRecursively(addedNode) {

        if (addedNode.nodeType !== Node.TEXT_NODE &&
            addedNode.nodeType !== Node.COMMENT_NODE) {

            for (const node of addedNode.childNodes) {
                if (node.hasChildNodes) {
                    this.findTasksRecursively(node);
                }
            }

            if (addedNode.hasAttribute(this.dataAttribute) &&
                addedNode.getAttribute(this.dataAttribute) !== "") {
                this.setTask(addedNode);
            }
        }
    }

    init() {

        // https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver

        const targetNode = document.getElementsByTagName('body');

        const config = {
            childList: true,
            subtree: true
        };

        const callback = (mutations, observer) => {
            mutations.forEach(mutation => {
                if (mutation.type === "childList") {

                    for (const addedNode of mutation.addedNodes) {
                        if (addedNode.nodeType === Node.TEXT_NODE ||
                            addedNode.nodeType === Node.COMMENT_NODE) {
                            continue;
                        }

                        if (addedNode.hasChildNodes) {
                            this.findTasksRecursively(addedNode);
                            continue;
                        }
                        if (addedNode.hasAttribute(this.dataAttribute) &&
                            addedNode.getAttribute(this.dataAttribute) !== "") {
                            this.setTask(addedNode);
                        }
                    }

                    for (const removedNode of mutation.removedNodes) {
                        if (removedNode.childNodes.length > 0 &&
                            removedNode.hasAttribute(this.dataAttribute)) {
                            console.log("Unimplemented removeEventListener...");
                        }
                    }

                    if (mutation.target.childNodes.length === 0) {
                        observer.disconnect();
                    }
                }
            });
        }

        const observer = new MutationObserver(callback);
        observer.observe(targetNode[0], config);

        this.getDefinedTasks().then(() => {
            this.search4Tasks(targetNode[0]);
        });
    }

}
// const automata = new Automata();
// automata.init();
/* ]]> */
Enter fullscreen mode Exit fullscreen mode

Now, a basic HTML page.

index.html

<!DOCTYPE html>
<html lang="en">

<head>
    <title>Test</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta http-equiv="Cache-control" content="no-cache">
    <meta name="author" content="Henrique Dias">
    <link rel=stylesheet href="styles.css">
</head>

<body>

    <div id="bag">
        <button data-tasks="get-fruits" class="fruits-button">
            Get Fresh Fruits
        </button>
    </div>

    <script src="automata.js"></script>
    <script>
        /* <![CDATA[ */
        const automata = new Automata();
        automata.init();
        /* ]]> */
    </script>
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Finally, we need a web server to make this work, in this case I coded it in Golang, but it could be in another programming language.

server.go

package main

import (
    "fmt"
    "net/http"
    "os/exec"
    "runtime"
    "strings"
    "time"
)

func openUrlInBrowser(url string) error {
    var cmd string
    var args []string

    switch runtime.GOOS {
    case "windows":
        cmd = "cmd"
        args = []string{"/c", "start"}
    case "darwin":
        cmd = "open"
    default: // "linux", "freebsd", "openbsd", "netbsd"
        cmd = "xdg-open"
    }
    args = append(args, url)

    return exec.Command(cmd, args...).Start()
}

func getFruits(w http.ResponseWriter, r *http.Request) {

    w.Header().Set("Content-Type", "text/html")
    fmt.Fprintf(w, "<strong>%s</strong><ul>%s</ul><button data-tasks=\"empty-bag\" class=\"fruits-button\">Empty Bag</button>",
        "Bag contents:",
            strings.Join(func() []string {
            list := []string{}
            for _, fruit := range []string{
                "Orange",
                "Apples",
                "Pears",
                "Pineapple",
            } {
                list = append(list, fmt.Sprintf("<li>%s</li>", fruit))
            }
            return list
        }(), "\r\n"))
}

func emptyBag(w http.ResponseWriter, r *http.Request) {

        w.Header().Set("Content-Type", "text/html")
        fmt.Fprint(w, "<button data-tasks=\"get-fruits\" class=\"fruits-button\">Get Fresh Fruits</button>")
}

func main() {

    mux := http.NewServeMux()
    mux.HandleFunc("/getfruits", getFruits)
    mux.HandleFunc("/emptybag", emptyBag)
    mux.Handle("/", http.FileServer(http.Dir("public/")))

    // go openUrlInBrowser("http://localhost:8080")
    go func() {
        <-time.After(100 * time.Millisecond)
        openUrlInBrowser("http://localhost:8080")
    }()

    server := http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    fmt.Printf("Server listening on %s", server.Addr)
    if err := server.ListenAndServe(); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

In the end, the directory structure should be as follows:

  • fresh-fruits/
    • public/
      • automata.js
      • index.html
      • styles.css
      • tasks.json
    • server.go

To perform the test, follow these simple steps: navigate to the root of the prototype directory and execute the following command:

$ go run .
Server listening on :8080
Enter fullscreen mode Exit fullscreen mode

The web page will open automatically in your default browser. Alternatively, you can manually enter the address http://localhost:8080 to access it. Once on the page, you should see a button with the text "Get Fresh Fruits".

Image description

Conclusion

This is merely a basic prototype with ample room for improvement. However, I believe it presents a straightforward alternative to appending a lot of attributes to HTML elements.


The source code for this prototype is available here: Automata

Thank you for reading!

Top comments (1)

Collapse
 
khaled17 profile image
khaled-17

Repository Title:
"Tech Hub: Curating Top GitHub Projects"

Message:
Hello everyone!

I've created a new GitHub repository hosting a diverse set of top tech projects. It's a hub for innovation and knowledge exchange.

Project Link:

RepoLand - Tech Hub

Features:

  1. Wide range of tech repositories.
  2. Comprehensive and user-friendly documentation.
  3. Opportunities for collaboration and improvement.

How to Contribute:

  1. Visit the RepoLand - Tech Hub on GitHub.
  2. Explore available projects and choose those of interest.
  3. Click "Fork" to copy the project to your own account.
  4. Make improvements or add new projects.
  5. Submit a Pull Request for merging.

Invitation:
I invite you to participate and follow the repository. This project serves as a platform for innovation and skill development. Feel free to share this invitation with anyone you think might benefit and contribute positively.

Thank you for your support and future contributions!