Design System Best Practices with ESLint Series

Part 3: Encouraging design system best practices with ESLint rules

Back in part 2, we implemented our no-inline-styles ESLint rule for a fictional design system.

The rule ensured that developers using the library avoid directly using the style html attribute, and we learned how to:

  • Create a rule
  • Parse our HTML
  • Check whether our parsed HTML contains the style attribute
  • Report a problem to ESLint when it does

For the final part of this series, we’ll go a step further and create a rule that ensures our tooltips don't contain interactive content. This requires a little knowledge about what constitutes an interactive element, which means learning a little about the HTML spec.

While we won’t need to interact with a tooltip directly, we’ll create our rule around the recommended usage of the tooltip in the Simba design system. Here’s the tooltip in action:

tooltip-gif.gif

If you look at the documentation for the tooltip, you will see the following recommendation:

Interactive content should not be placed in a tooltip content slot. Tooltips are just meant for showing additional information.

This is great advice, as there are some serious accessibility concerns about interactive tooltips. However, it does require the consumers to read the documentation and remember this information. Browsers may also render a functioning page even with invalid markup or accessibility concerns. Coupled together, these problems make it easy to neglect a design system’s best practices. Instead, building ESLint rules for our best practices means that our consumers will get immediate feedback when they violate a rule. This means our consumers need to remember fewer best practices while still contributing to a more accessible web.

How can we determine if an element is interactive?

There are two common ways of enabling user interaction to a web page:

  • Using interactive elements
  • Applying the tabindex attribute to an element

Using interactive elements

By default, HTML describes a handful of elements that are specifically designed for user interaction. These elements include the usual suspects a, input, select, and textarea. Other elements are only interactive under certain conditions, like audio if the controls attribute is present. So great! The HTML spec has given us a list of interactive elements, which we can use in our ESLint rule to evaluate whether the current node is interactive.

Using the tabindex attribute

You may be familiar with the (not-so-good) practice of web developers rebuilding buttons from scratch using a div. As we saw in the HTML spec, a div isn’t an interactive element. We can style the div to look and do as we please but the browser won’t allow a keyboard user to tab to it unless we explicitly add a tabindex attribute.

Applying tabindex to an element adds it to the web page’s focus order. For reasons that are beyond the scope of this article series, you must be very considerate when it comes to using tabindex. A document with an incorrect tab order fails several WCAG requirements (1.3.2, 2.4.3, 2.4.7).

With that said, because tabindex is an HTML attribute, checking for its existence is very similar to how we checked for the style attribute in the previous article.

A minute on web components

If you’re not familiar with the anatomy of web components, the markup we're going to be linting may be a little different than what you’re used to. If you’re familiar with slots then feel free to skip this section.

Instead of parsing HTML elements, we'll be parsing web components. The good thing is that we don't have to learn the ins and outs of web components to get by. All the tools we've used so far will still come in handy as we parse and make assertions against our web components.

If we jump back to the documentation for the simba-tooltip, we're given the following markup as an example use case:

html`
  <simba-tooltip>
    <simba-icon icon-id="icon-id" slot="invoker"></simba-icon>
    <span slot="content"> Error! </span>
  </simba-tooltip>
`;
  • We have a top-level wrapper simba-tooltip
  • We can specify an element to render in the invoker slot
  • We can specify an element to render in the content slot

What does the slot attribute do?

The slot attribute acts as a placeholder for some custom markup the consumer will provide. A web component may have zero, one, or many slots, and in the case of the simba-tooltip, we have two. These slots are:

  • Invoker - The markup that's displayed within the regular flow of the document. Hovering over the invoker will display the content.
  • Content - The markup that's displayed once the user has hovered over the invoker.

Under the hood, the simba-tooltip component might have some predefined markup for structure, styling, or accessibility purposes. This kind of markup can go a long way in making a web component easy and safe to reuse across web applications, but web components shine partly because they can give a great deal of control to our consumers.

Why not give the Simba tooltips a play around on the Backlight platform?

Slots allow for inversion of control

If you've used any tooltip components in the past, you might have been limited by the icons available to use as an invoker. In the case of simba-tooltip instead of choosing from pre-defined icons to use as the invoker, we can supply our own icon. We can go even further and supply any markup to act as the invoker. The same goes for the content, instead of only being able to supply some a text node, we can pass through anything we like (though it doesn’t mean we should).

I won't spend any more time covering the basics of web components, but they're a powerful browser technology and adoption rate seems to be increasing.

If you’re interested in utilizing the browser platform to its fullest, I encourage you to learn more about them.

Writing your tests

Back when we were writing our first rule, our test suite was prepped and ready to run. Writing a handful of unit tests before writing our ESLint rule proved to be a good litmus test for how our lint rule was coming along.

As part of this article, we are tasked with crafting a handful of test cases before we start implementing our rule. Our test suite doesn't need to be comprehensive, a handful of valid and invalid cases should be sufficient.

Take a moment to think of a handful of valid and invalid test cases. I'll share mine underneath. Write them as statements in plain language, we'll then translate them over to code in a little bit.

If you’re stuck for ideas, here’s one valid and invalid case that I’ve written:

Valid: The element with slot="content" child is a text node

Invalid: The element with slot="content" contains a button element as a child

Describing our test cases

Here’s the set of test cases I came up with

valid

  • The element with slot="content" child is a text node
  • The element with slot="content" doesn't contain any children
  • The element with slot="content" contains one level of non-interactive html markup
  • The element with slot="content" contains more the one level of non-interactive markup

invalid

  • The element with slot="content" contains a button element as a child
  • The element with slot="content" contains a button element nested within a non-interactive element
  • The element with slot="content" contains a non-interactive element with a tabindex value
  • The element with slot="content" contains a deeply nested non-interactive element with a tabindex value

How did your written cases come out? Were they different than mine? If I missed out on any that you think are important, please reach out to me, I'd love to hear your suggestions.

I chose these 8 because I imagine these to be common implementations for the simba-tooltip.

Writing our test cases

Now that we know what tests we're going to write, our next step is to go ahead and write them.

If you haven't done so already here are the setup instructions for the repo.

Repo setup

To get started with the repo, follow the following steps:

The next step is to enter the no-interactive-tooltips.js file and to start turning our written test cases into code. Let's do one valid case and one invalid case together.

Valid: The element with slot="content" child is a text node

Because we're not going to worry about the invoker slot, we can omit it completely from our test markup. In this test case, you can see I've copied the example presented in the Simba docs and reduced any unnecessary details.

{
  valid: [
    'html`<simba-tooltip><span slot="content"> Error! </span></simba-tooltip>`',
  ];
}

Invalid: The element with slot="content" contains a button element as a child

While we've specifically called out "button" here. This rule can act as an umbrella for all interactive elements. Or if you like, you can create separate rules for each element.

Let's copy the above code snippet, and we'll tweak it to surround the "Error!" text node with a button.

{
  invalid: [
    {
      code: 'html`<simba-tooltip><span slot="content"><button> Error! </button></span></simba-tooltip>`',
      errors: [
        {
          message: '',
        },
      ],
    },
  ];
}

Try running your test suite using npm run test and look at the output.

The first test will pass, while the second test will fail. When we finish writing our tests and begin writing our rule we can keep the test suite running. We can use it to check our progress as we aim to write rules that satisfy our test suite.

Try and write the rest of your test cases. I'll share my finished code below if you get stuck:

ruleTester.run('no-interactive-tooltips', rule, {
  valid: [
    'html`<simba-tooltip><span slot="content"> Error! </span></simba-tooltip>`',
    'html`<simba-tooltip><span slot="content"></span></simba-tooltip>`',
    'html`<simba-tooltip><span slot="content"><p>Error!</p></span></simba-tooltip>`',
    'html`<simba-tooltip><span slot="content"><ul><li>Error!</li></ul></span></simba-tooltip>`',
  ],
  invalid: [
    {
      code: 'html`<simba-tooltip><span slot="content"><button> Error! </button></span></simba-tooltip>`',
      errors: [
        {
          message: 'no-interactive-tooltips',
        },
      ],
    },
    {
      code: 'html`<simba-tooltip><span slot="content"><div><button> Error! </button></div></span></simba-tooltip>`',
      errors: [
        {
          message: 'no-interactive-tooltips',
        },
      ],
    },
    {
      code: 'html`<simba-tooltip><span slot="content"><div tabindex="1"> Error! </div></span></simba-tooltip>`',
      errors: [
        {
          message: 'no-interactive-tooltips',
        },
      ],
    },
    {
      code: 'html`<simba-tooltip><span slot="content"><div><div tabindex="1"> Error! </div><div></span></simba-tooltip>`',

      errors: [
        {
          message: 'no-interactive-tooltips',
        },
      ],
    },
  ],
});

Now we've written our test cases, let's move on to writing our rules.

Writing your rule

For this exercise, I’ll be hands-off. I’ll give you a few requirements and hints, but try your best to implement the rule yourself before seeing my solution.

Here’s a gentle nudge if you need it:

  1. Create a new rule file and scaffold it in the same way as the no-inline-styles rule
  2. While there are others, for the sake of this tutorial the elements that we’ll consider interactive are:
    1. button
    2. a
    3. input
    4. textarea
    5. select
  3. Feel free to copy any code over from your no-inline-styles.js rule, don’t worry too much about code duplication
  4. Try and group your logic into 2 distinct sections
    1. Checks against the node’s name
    2. Checks against the node’s attributes

Try and timebox your attempt on this solution. Once you run out of time, have a look at my solution and see where you got stuck.

Solution

Did you have any luck?

You can view my solution below, which might look very different from yours.

analyzer.traverse({
  element(elNode) {
    // 1.
    const INTERACTIVE_ELEMENTS = ['textarea', 'a', 'button', 'input', 'select'];

    // 2.
    if (INTERACTIVE_ELEMENTS.includes(elNode.nodeName)) {
      context.report({
        message: 'no-interactive-tooltips',
        node,
      });
    }

    const { attrs = [] } = elNode;

    // 3.
    const hasTabIndexAttr = attrs.some(
      (attr) => attr.name === 'tabindex' && attr.value
    );

    if (hasTabIndexAttr) {
      context.report({
        message: 'no-interactive-tooltips',
        node,
      });
    }
  },
});

At “1”, we can see that I’ve defined the list of interactive elements. These are the node names of the elements as they should appear within the node given to us from our traversal function.

At “2”, we check to see if the nodeName of our current node matches one of the names in our INTERACTIVE_ELEMENTS array. If it does, we call the report function.

At “3”, we pull out the list of attributes given passed through to our node. We iterate over the list. If the name of the attribute is tabindex and has a valid value. If it does, then we call the report function.

How does your solution look? Did you provide additional, more comprehensive checks? I’d love to know how your solution differs from mine.

Conclusion

You might be thinking, “whoa Andrico, we covered a lot of theory for what amounts to a few short lines of JavaScript”, and you’d be completely right.

Every design system is different and each one will aim to satisfy different needs, so you may find it’s not sensible to copy the code we’ve covered here directly into your own library’s lint rules. Instead, I hope that we’ve covered enough ground for you to intuitively write your own ESLint rules. It’s now up to you to write whichever rules you feel appropriate for your design system.

Even if we didn’t write heaps of code, we covered a lot of interesting ground like:

  • How build tools understand and transform JavaScript
  • How to effectively traverse a tree-like data structure
  • How ESLint traverses your code’s AST to help you find problems
  • And a lot more

Beyond the coding practices discussed in this series, the ultimate goal is to consider how to reduce the amount of cognitive load needed for your library’s consumers to adhere to your library’s best practices. Writing ESLint rules is one way of achieving this, and there may be others that work better for your design system.

Ensuring that it’s easy for the developer’s consuming the design system to adhere to your best practices doesn’t just make the developer’s life easier. It also makes the web experience better for those that come across your design system in the wild.


The Design System Best Practices with ESLint Series: