DEV Community

Ben Read
Ben Read

Posted on

Using Aria States To Toggle Tailwind Classes

I maintain an internal UI library for a number of large sites. It's got a number of JavaScript interactions for menus, search buttons and similar.

In my first iteration of the project I used event handlers to add and remove classes directly in the code base, like this:

desktopSearchButton?.addEventListener("click", event => {
        // Show the search box
        desktopWrapper?.classList.toggle("hidden");

        // Add active state to button
        let button = event.target.closest("button");
        button?.classList.toggle("bg-blue-800");
        button?.classList.toggle("rounded-tr-none");
        button?.classList.toggle("rounded-br-none");

        if (!desktopWrapper?.classList.contains("hidden")) {
                desktopWrapper?.querySelector("input[type=search]")?.focus();
        }
});
Enter fullscreen mode Exit fullscreen mode

Apart from being verbose this is quite fragile code. I'm looking for a style class and initializing an interactive state that is fixed inside the JS.

This means if the style of the button changed, I would also have to change the JS. Also it could break the JavaScript if you weren't very careful.

Second iteration

I started to refactor the code to instead use data attributes:

desktopSearchButton?.addEventListener("click", event => {
        // Show the search box
        if(desktopWrapper) {
                desktopWrapper.dataset.open = desktopWrapper.dataset.open === "true" ? "false" : "true";
        }

        // Add active state to button
        let button = event.target.closest("button");
        if(button) {
                button.dataset.open = button.dataset.open === "true" ? "false" : "true";
        }

        if (desktopWrapper?.dataset.open === "true") {
                desktopWrapper.querySelector("input[type=search]")?.focus();
        }
});
Enter fullscreen mode Exit fullscreen mode

This is a lot more robust and easily re-useable. Now I can use Tailwind arbitrary selectors in the template to toggle the states:

<button data-open="false" class="hidden data-[open]:block" ...>
Enter fullscreen mode Exit fullscreen mode

Third iteration

With this one, I really wanted to make sure that accessibility was intrinsic to the project, and not an optional extra. To that end, instead of data attributes wherever possible I used aria states to toggle the visuals.

desktopSearchButton?.addEventListener("click", event => {
        // Show the search box
        if(desktopWrapper) {
                desktopWrapper.ariaExpanded = desktopWrapper.ariaExpanded === "true" ? "false" : "true";
        }

        // Add active state to button
        let button = event.target.closest("button");
        if(button) {
                button.ariaPressed = button.ariaPressed === "true" ? "false" : "true";
        }

        if (desktopWrapper?.ariaExpanded === "false") {
                desktopWrapper.querySelector("input[type=search]")?.focus();
        }
});
Enter fullscreen mode Exit fullscreen mode
<button aria-pressed="false" class="hidden aria-pressed:block" ...>
Enter fullscreen mode Exit fullscreen mode

The only disadvantage here is just an inconvenience: they have to add the aria state to the DOM. And to be honest, that is a positive friction to make them more aware of how aria states work.

Also my compiler complains when I try to make left-hand assignments with optional chaining:

button?.ariapressed = "true"
// The left-hand side of an assignment expression may not be an optional property access.ts(2779)
Enter fullscreen mode Exit fullscreen mode

I could escape this rule but instead opted for an if statement rather than risk making my entire codebase less type safe.

This way, any developers using my library would need to use aria states too, making it at least a bit better for all of our users.

Top comments (0)