Handling z-index and implementing a custom linter

TL;DR: A small guide on implementing a custom linter that checks the validity of style variables.

Working example on GitHub

Motivation

Who doesn’t know it? You join a new team and issue your first PR only to be told that: “This is not how we do things around here.” We can’t catch all issues before a PR gets issued, but we can get surprisingly far with custom linters.

Scalable approach to using z indices

Let’s look at an example. First, we have a page with a menu that, when open, is higher than the content in the body. So we give the menu a z-index of 1. Then, with time we add dialogs to the page, with a z-index of 100, to ensure it’s on top of everything. Next, we’re asked to add a menu inside a dialog, so we give the menu a z-index of 105, just in case.

In this manner, the values get out of hand quickly, so there should be a better way to deal with them.

// For simplicity, let’s say these are the levels we have on a page.
const LEVELS = ['BODY', 'ABOVE_BODY', 'DIALOG', 'ABOVE_DIALOG']

// From the levels, we can automatically generate our z-indices as follows:
const Z_INDEX = LEVELS.reduce(
  (mapper, level, index) => ({ ...mapper, [level]: index }),
  {}
)

// The output will be an object of this format:
// {
//   BODY: 0,
//   ABOVE_BODY: 1,
//   DIALOG: 2,
//   ABOVE_DIALOG: 3
// }

// In the code, we can use this object like so:
const style = { zIndex: Z_INDEX.ABOVE_BODY }

Generating levels this way has the benefit of keeping the values limited to this set of values. Adding new levels is easy and will not break already used values.

Of course, the downside to this approach is that it’s pretty manual. For example, you must know that a Z_INDEX constant exists. Apart from that, you must also remember to use it and remind your colleagues about it. So overall not a very robust experience. This is where our custom linter comes into play.

Writing a custom linter

ESLint is fantastic since it saves us so much time. We can catch issues early on while writing code, reducing mistakes and frustration. In addition, ESLint rules are essential because they make practices used in the codebase explicit, which helps have a common understanding of what the standards for the code are and keeps it consistent.

The plan for our work will be the following:

  1. Find use cases and create test scenarios
  2. Create the rule
  3. Install the rule and add it to the ESLint config

Find use cases and create test scenarios

See all test cases on GitHub.

The simplest case is checking whether the constant was used or not.

// Valid:
const style = { zIndex: Z_INDEX.NOTIFICATION }

// Invalid:
const style = { zIndex: 9999 }

Beyond that there are two other scenarios worth mentioning. The first is allowing users to use conditionals for z-index:

// Valid:
const validConditional = ({ isVisible }) => ({
  zIndex: props.isVisible ? Z_INDEX.BODY : Z_INDEX.NOTIFICATION,
})

// Invalid:
const invalidConditional = ({ isVisible }) => ({
  zIndex: props.isVisible ? 9999 : Z_INDEX.NOTIFICATION,
})

The second scenario which should be accounted for is that we might want to override the z-index by setting it to undefined or global values (inherit, initial, unset, etc.). This might be handy in conditional styles or media queries.

// Valid:
const style = ({ isVisible }) => ({
  zIndex: props.isVisible ? Z_INDEX.NOTIFICATION : undefined,
})
const style = { zIndex: 'auto' }
const style = { zIndex: 'initial' }
const style = { zIndex: 'unset' }

Create the rule

Now that we know what we want to check for, we can start creating the rule. To start, create an eslint directory in your root folder and add three files. The first will be the package.json, whose contents are minimal. See the complete example on GitHub.

{
  "name": "eslint-plugin-custom",
  "version": "1.0.0",
  "main": "index.js"
}

The index.js will contain all the logic. Many tutorials recommend using the AST explorer, but I find it very complicated, so I just run the test case and console.log the nodes. (Full example):

module.exports = {
  rules: {
    // Rule name:
    'prefer-z-index-constant': {
      meta: {
        type: 'suggestion',
        docs: {
          description: 'Reminder to use Z_INDEX constant',
          // Message that will be shown if you don’t use a valid value:
          category: 'Consider using the Z_INDEX constant.',
        },
      },

      create: function (context) {
        return {
          Identifier(node) {
            // Log the node here to see more info about it
            // console.log(node)

            // We only want to run the check if the key is `zIndex`
            if (node.name === 'zIndex') {
              // Return early in cases that are valid
              if (isValidCode) return

              // Or show a message whenever an issue was found
              return context.report({
                node,
                message: 'Consider using the Z_INDEX constant.',
              })
            }
          },
        }
      },
    },
  },
}

And finally, the spec.js where the cases we outlined earlier will live:

const { RuleTester } = require('eslint')
const rule = require('./')

const ruleTester = new RuleTester({})

ruleTester.run(
  'prefer-z-index-constant',
  rule.rules['prefer-z-index-constant'].create,
  {
    valid: [`var style = { zIndex: 'auto' }`],
    invalid: [
      {
        code: 'var style = { zIndex: 9999 }',
        errors: [{ message: 'Consider using the Z_INDEX constant.' }],
      },
    ],
  }
)

You will need to use a test runner like Jest to run the test.

Install the plugin and add a rule to the ESLint config

To install the plugin, we need to add it as a dev dependency to the package.json:

{
  "devDependencies": {
    "eslint-plugin-custom": "file:eslint"
  }
}

And add the plugin to the ESLint config of the codebase:

module.exports = {
  plugins: ['custom'],
  rules: { 'custom/prefer-z-index-constant': 1 },
}

As the last step, we have to run npm i and, if necessary, reload VSCode.

Handle type definitions

If you use Typescript you might run into the issue that the custom linter will try to lint them. Something I noticed is that the nodes from a type declaration will have the prefix TS, so this little check should fix the problem:

if (node.parent.type.startsWith('TS')) return