Turboprop - Toolkit to create accessible component libraries

Hey all!

There’s been a lot of conversation lately both around server/client-side interactivity and a bunch of component libraries popping up for Phoenix. That’s great - and if we’re honest, many of the components that are available for Phoenix are not fully accessible. It’s a hard problem to solve, with keyboard interaction, focus trapping, ARIA labeling and more to keep in mind.
Thankfully, it’s a mostly solved problem in the JavaScript world, with libraries like HeadlessUI, Radix and… Zag, which is a framework-agnostic library packing a bunch of state machines to build accessible components.

So I’ve gone on a mission to create some Phoenix hooks that wrap these state machines, and a handful (not nearly enough at the moment!) are ready to see the light of day.
Along the path, I realised that I want a way to easily write different variants for my components, and, given that overrides are needed, also intelligently merge Tailwind classes to avoid conflicts.

So, this small idea turned into a whole toolkit that offers accessibility-enabling hooks, a variant API and Tailwind class merging utilities.

It’s called Turboprop, it’s still extremely early with especially the hooks API probably changing a lot in the near future, but I’m excited for it, so it’s time to share it!

https://hexdocs.pm/turboprop/Turboprop.html

Here’s a bunch of examples:

Variants

@variants %{
  variants: %{
    variant: %{
      default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
      destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
      outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
      secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
      ghost: "hover:bg-accent hover:text-accent-foreground",
      link: "text-primary underline-offset-4 hover:underline",
    },
    size: %{
      default: "h-9 px-4 py-2",
      sm: "h-8 rounded-md px-3 text-xs",
      lg: "h-10 rounded-md px-8",
      icon: "h-9 w-9",
    },
  },
}
variant(@variants, variant: "destructive", size: "sm")
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 h-8 rounded-md px-3 text-xs"

Merge

merge(["px-2 py-1 bg-red hover:bg-dark-red", "p-3 bg-[#B91C1C]"])
"hover:bg-dark-red p-3 bg-[#B91C1C]"

Hooks

<div {dialog()}>
  <button
    class="rounded-md bg-blue-500 px-3 py-1.5 text-sm text-white shadow-sm hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
    {dialog_trigger()}
  >
    Open dialog
  </button>
  <div class="absolute inset-0 w-full h-full bg-gray-200" {dialog_backdrop()}></div>
  <div class="fixed inset-0 z-10 w-screen overflow-y-auto flex min-h-full items-center justify-center p-4" {dialog_positioner()}>
    <div class="w-full max-w-md rounded-xl bg-white p-6 outline-0" {dialog_content()}>
      <h2 class="text-base font-medium" {dialog_title()}>Dialog</h2>
      <span class="mt-2 text-sm" {dialog_description()}>Welcome!</span>
      <div class="mt-4">
        <button
          class="rounded-md bg-blue-500 px-3 py-1.5 text-sm text-white shadow-sm hover:bg-blue-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
          {dialog_close_trigger()}
        >
          Close
        </button>
      </div>
    </div>
  </div>
</div>

I’ll be adding a bunch more hooks in the future, probably revisiting the hooks API and options, documentation is a work in progress as always… The entire thing is less than two weeks old, so it’ll be a bit before we’re reaching 1.0!

Let me know if there’s a hook you really want to see next, or if something is desperately missing in the documentation, or just let me know you’re gonna use this in your projects!

Maybe, just maybe, there’ll be a component library from me as well soon.

19 Likes

Very nice idea to incorporate Zag like this. Will definitely take a look soon and give some feedback.

Have you had a look at @zachdaniel’s library? zachdaniel/tails: Utilities for working with tailwind classes, like semantic merge, and conditional class lists. (github.com) He describes a method to share themes between the tailwind compiler and Elixir.

1 Like

Hey! Thanks for having a look.
I actually didn’t know about Tails, so it’s nice to see some alternatives around.

Merge is definitely the tool in this library that has gotten the least love, and I have a few scratchpad ideas on how to incorporate configuration, especially with colors, sizes and so on. Will definitely require a bit of experimenting to get it the way I’d like to.

Other than that, I took the freedom of running a few tests against Tails and it gets a few things wrong that Turboprop gets right:

  • Touch utilities are being removed when they shouldn’t.
    Tails.classes(["touch-pan-x", "touch-pan-y", "touch-pinch-zoom"]) results in touch-pinch-zoom when they are actually allowed to be combined.
  • It does not handle Font Variant Numeric classes correctly.
    Tails.classes(["normal-nums", "tabular-nums", "diagonal-fractions"]) returns "normal-nums tabular-nums diagonal-fractions" where normal-nums and tabular-nums conflict.
  • Negative values aren’t handled correctly.
    Tails.classes(["-top-12", "-top-2000"]) returns -top-12 -top-2000.
  • Arbitrary modifiers don’t seem to be supported.
    Tails.classes("[&[data-open]]:underline [&[data-open]]:line-through") returns both classes when it should not.

Not to discredit Zach’s work of course, I’d love to push each other with this to create better stuff :slight_smile:

1 Like

Nice, thanks for pointing out these bugs :slight_smile: Will take a look at addressing them. EDIT: do you have the tests you ran somewhere available? Would be nice to use those to fix it :slight_smile:

Copied merge_test.exs from my repo and ran %s/merge(/Tails.classes(/ :smiley:

Do you mind if I just copy this into my project? With proper attribution of course :slight_smile:

Mine already has attribution because I just copied it from tailwind-merge and translated it to Elixir, so go ahead, but please just attribute it to their maintainers instead of me :slight_smile:

1 Like

The caching approach is interesting, my original focus on Tails was to compile the merger so that it was fast and stateless, would be very curious to see if the caching approach behaves better, although to properly benchmark against eachother would probably need to heat up the cache or something :slight_smile:

To be clear, that isn’t like a challenge or anything, I never benchmarked Tails myself. I threw it together because I needed it for some very specific work, and announced it as a project others could use/contribute to, but I’ve been confident that someone would either come along and improve it or make a better thing :slight_smile:

Didn’t even know you could do this:

[paint-order:markers] [paint-order:normal], and that definitely breaks given that I didn’t write a tokenizer. In two places its using String.split(":") to split variants which is naturally quite problematic.

Yeah, there’s some really funky stuff you can do with Tailwind and supporting it all isn’t trivial. That’s why I did write a parser for it :smiley:
I mean, these are valid classes, what the hell:

      assert merge([
               "[&[data-foo][data-bar]:not([data-baz])]:nod:noa:[color:red]",
               "[&[data-foo][data-bar]:not([data-baz])]:noa:nod:[color:blue]"
             ]) == "[&[data-foo][data-bar]:not([data-baz])]:noa:nod:[color:blue]"

Also, not taking it as a challenge! There’s some really slow stuff in this library (count the map merges in ClassTree.generate/0 and it will haunt you in your dreams), which is why I also added the cache in front of it. The good thing is that basically every application has a finite amount of class combinations, so slowness doesn’t matter as much with a cache. Definitely still a thing I want to overhaul before calling this stable and usable.

1 Like

I don’t want conversations about Tails to be a distraction from your announcement, but I very much appreciate your taking the time to check it out and highlight some issues. What you’re doing with component accessibility is far more than the scope of Tails and is very very cool! :partying_face:

I’ve soft-deprecated tails, primarily based on the issues your tests pointed out, and point folks at turboprop instead :slight_smile: I spent ~2 hours fixing it, and got some of the issues addressed, before I remembered that I maintain like 50 packages at this point and I just don’t realistically have the time to actually fix tails :slight_smile:

5 Likes

We also have cva that I think does a similar job…

1 Like

I like cva, also in the JavaScript world, but it doesn’t offer slots and by extension compound slots, or easy overrides, so I haven’t found it powerful enough to build a full design system.

cva creator (or person who ported it to elixir) here. Can you elaborate a bit on whats missing for you? i’m not sure I fully understand it and if it’s something how cva could be extended to. :slight_smile:

Heya!

What I’m missing is the ability to define component variants that span multiple HTML elements - and by extension with compound slots, the ability to define multiple similar but distinct elements without repetition.

For example, something like this:

defmodule Pagination
  import Turboprop.Variants

  @variants %{
    slots: %{
      base: "flex flex-wrap relative gap-1 max-w-fit",
      item: "data-[active]:bg-blue-500 data-[active]:text-white"
    },
    variants: %{},
    default_variants: [
      size: "sm"
    ],
    compound_slots: [
      %{
        slots: [:item, :prev, :next],
        class: [
          "flex flex-wrap px-2 py-1 truncate box-border outline-none items-center justify-center bg-neutral-100 hover:bg-neutral-200 active:bg-neutral-300 text-neutral-500"
        ]
      },
      %{slots: [:item, :prev, :next], size: "xs", class: "w-7 h-7 text-xs"},
      %{slots: [:item, :prev, :next], size: "sm", class: "w-8 h-8 text-sm"},
      %{slots: [:item, :prev, :next], size: "md", class: "w-9 h-9 text-base"}
    ]
  }

  def render(assigns) do
    assigns = assign(assigns, :variants, @variants)

    ~H"""
    <ul aria-label="pagination navigation" class={variant(@variants, slot: :base)}>
      <li aria-label="Go to previous page" class={variant(@variants, slot: :prev)} role="button">&lt;</li>
      <li aria-label="Page 1" class={variant(@variants, slot: :item)} role="button">1</li>
      <li aria-label="Page 2" class={variant(@variants, slot: :item)} role="button">2</li>
      <li aria-label="Page 3" class={variant(@variants, slot: :item)} data-active role="button">3</li>
      <li aria-label="Page 4" class={variant(@variants, slot: :item)} role="button">4</li>
      <li aria-label="Page 5" class={variant(@variants, slot: :item)} role="button">5</li>
      <li aria-hidden="true" class={variant(@variants, slot: :item)} role="button">...</li>
      <li aria-label="page 10" class={variant(@variants, slot: :item)} role="button">10</li>
      <li aria-label="Go to next page" class={variant(@variants, slot: :next)} role="button">></li>
    </ul>
    """
  end
end

We have a pagination component that has a list of pages, a back/forward button, and different sizes for the buttons available. The page buttons should respect active state, which is the only differentiator from the back/forward buttons, which obviously don’t have active state. Something like this is really difficult to build with CVA.

On top of that, overrides. So this is our variants, but design comes along and wants a slightly different pagination on the help center page. It’s a one-off.

  def render(assigns) do
    assigns = assign(assigns, :variants, @variants)

    ~H"""
    <ul aria-label="pagination navigation" class={variant(@variants, slot: :base, class: "gap-2")}>
    ...
    </ul>
    """
  end

This is not supported by CVA. Just adding class="gap-2" on the ul element itself does not necessarily work because it’s a conflict with the existing gap-1 and application depends on the order in which Tailwind defines the stylesheet. Since Turboprop implictly tosses its entire output to merge/1, this gets resolved for us.

I still think CVA is great, though. If this kind of functionality isn’t what you need, by all means, use it! If you’re already using it and haven’t ran into limitations, don’t change to something else. Having options is always great, and it doesn’t always have to be a competition :slight_smile:

Thanks for your work! The package looks very promising.

I’d love to see a Combobox hook.

2 Likes

Actually had already started working on it :smiley: this is a bit of a difficult one to keep working across LiveView updates (in this case I’m doing the search on the server and actually streaming in new countries), so it’ll need some love before I’ll push it.
https://x.com/cschmatzler/status/1803044703245398130

An accessible component library for phoenix/liveview built with turpoprop sounds rad. We’re currently using daisyui (a component library built on tailwind) in combination with Surface, but we end up manually writing the hooks for almost all the client interactions (which includes standard interactions like a modal, popup, accordion, tabs, wyswyg editor, emoji menu, popover, etc.) that we use across our application ( bonfire - a federated social network )
We’d love to contribute to this effort somehow!