Skip to content

Latest commit

 

History

History
576 lines (414 loc) · 13.5 KB

set-up-css-modules-v2-addons.md

File metadata and controls

576 lines (414 loc) · 13.5 KB

Set up CSS modules (v2 addons)

We will use Rollup and PostCSS to implement CSS modules.

  1. Install dependencies
  2. Configure Rollup
  3. Style your first component

Note

If you get lost, you can check how my-v2-addon is set up.

Install dependencies

A "standard" v2 addon, created with @embroider/addon-blueprint or migrated to with ember-codemod-v1-to-v2, will have these dependencies already.

  • rollup
  • @rollup/plugin-babel

For PostCSS, here is what you likely need at minimum.

  • postcss
  • rollup-plugin-postcss

Finally, some packages to improve your developer experience (DX).

All in all, here are the commands for installation:

pnpm install --dev postcss rollup-plugin-postcss type-css-modules
pnpm install embroider-css-modules

1. Add to dependencies, not devDependencies.

2. Needed only if you have a TypeScript project.

Configure Rollup

In this step, you will update one file: rollup.config.mjs. Your current file should look similar to this starter code.

Starter code for rollup.config.mjs

For simplicity, comments have been hidden.

import { Addon } from '@embroider/addon-dev/rollup';
import { babel } from '@rollup/plugin-babel';
import copy from 'rollup-plugin-copy';

const addon = new Addon({
  srcDir: 'src',
  destDir: 'dist',
});

export default {
  output: addon.output(),

  plugins: [
    addon.publicEntrypoints(['**/*.js', 'index.js', 'template-registry.js']),

    addon.appReexports([
      'components/**/*.js',
      'helpers/**/*.js',
      'modifiers/**/*.js',
      'services/**/*.js',
    ]),

    addon.dependencies(),

    babel({
      babelHelpers: 'bundled',
      extensions: ['.gjs', '.gts', '.js', '.ts'],
    }),

    addon.hbs(),

    addon.gjs(),

    addon.keepAssets(['**/*.css']),

    addon.clean(),

    copy({
      targets: [
        { src: '../README.md', dest: '.' },
        { src: '../LICENSE.md', dest: '.' },
      ],
    }),
  ],
};

Update rollup.config.mjs

Add rollup-plugin-postcss before babel() (order matters). Then, remove the glob pattern **/*.css from addon.keepAssets().

rollup.config.mjs
import { Addon } from '@embroider/addon-dev/rollup';
import { babel } from '@rollup/plugin-babel';
import copy from 'rollup-plugin-copy';
+ import postcss from 'rollup-plugin-postcss';

const addon = new Addon({
  srcDir: 'src',
  destDir: 'dist',
});

export default {
  output: addon.output(),

  plugins: [
    addon.publicEntrypoints(['**/*.js', 'index.js', 'template-registry.js']),

    addon.appReexports([
      'components/**/*.js',
      'helpers/**/*.js',
      'modifiers/**/*.js',
      'services/**/*.js',
    ]),

    addon.dependencies(),

+     postcss({
+       autoModules: false,
+       modules: {
+         generateScopedName: 'your-v2-addon__[sha512:hash:base64:5]',
+       },
+     }),
+
    babel({
      babelHelpers: 'bundled',
      extensions: ['.gjs', '.gts', '.js', '.ts'],
    }),

    addon.hbs(),

    addon.gjs(),

-     addon.keepAssets(['**/*.css']),
+     addon.keepAssets([]),

    addon.clean(),

    copy({
      targets: [
        { src: '../README.md', dest: '.' },
        { src: '../LICENSE.md', dest: '.' },
      ],
    }),
  ],
};

Configure hashing

Let's take a closer look at generateScopedName, for which you have a few options.

postcss({
  autoModules: false,
  modules: {
    generateScopedName: 'your-v2-addon__[sha512:hash:base64:5]',
  },
})

The setup prepends the hash with the package name (e.g. your-v2-addon). This way, you can identify a style's source in the consuming app. Hash collisions in the consuming app become unlikely, too.

// Styles for src/components/navigation-menu
const styles = {
  'list': 'your-v2-addon__coN6v',
  'link': 'your-v2-addon__ugjOS',
};

You may instead prefer predictable names to debug code easily:

postcss({
  autoModules: false,
  modules: {
    generateScopedName: 'your-v2-addon__[path][name]__[local]',
  },
})

// Styles for src/components/navigation-menu
const styles = {
  'list': 'your-v2-addon__src-components-navigation-menu__list',
  'link': 'your-v2-addon__src-components-navigation-menu__link',
};

Lastly, if you want the simplest option:

postcss({
  modules: true,
})

// Styles for src/components/navigation-menu
const styles = {
  'list': 'navigation-menu_list__gqvTV',
  'link': 'navigation-menu_link__-1OWJJ',
};

Note

I recommend the first option: Set generateScopedName to <package-name>__[sha512:hash:base64:5].

Style your first component

You can style your addon now. Let's create a Glimmer component called <NavigationMenu> to test CSS modules.

your-v2-addon
├── src
│   └── components
│       ├── navigation-menu.css
│       ├── navigation-menu.hbs
│       └── navigation-menu.ts
...

Glimmer component

The goal is to render and style a <nav>-element that contains links.

src/components/navigation-menu.hbs
<nav aria-label={{@name}}>
  <ul>
    {{#each @menuItems as |menuItem|}}
      <li>
        <LinkTo @route={{menuItem.route}}>
          {{menuItem.label}}
        </LinkTo>
      </li>
    {{/each}}
  </ul>
</nav>
src/components/navigation-menu.css
.list {
  align-items: center;
  display: flex;
}

.link {
  display: inline-block;
  font-size: 0.875rem;
  padding: 0.875rem 1rem;
  text-decoration: none;
  white-space: nowrap;
}

.link:global(.active) {
  background-color: #15202d;
}

.link:hover {
  background-color: #26313d;
  transition: background-color 0.17s;
}

Next, in the backing class, import the stylesheet and name it styles. Store styles as a class property so that the template has access.

src/components/navigation-menu.ts

Note, we write the file extension .css explicitly.

import Component from '@glimmer/component';

import styles from './navigation-menu.css';

type MenuItem = {
  label: string;
  route: string;
};

interface NavigationMenuSignature {
  Args: {
    menuItems: MenuItem[];
    name?: string;
  };
}

export default class NavigationMenuComponent extends Component<NavigationMenuSignature> {
  styles = styles;
}

Finally, style the links. ✨

src/components/navigation-menu.hbs
<nav aria-label={{@name}}>
  <ul class={{this.styles.list}}>
    {{#each @menuItems as |menuItem|}}
      <li>
        <LinkTo @route={{menuItem.route}} class={{this.styles.link}}>
          {{menuItem.label}}
        </LinkTo>
      </li>
    {{/each}}
  </ul>
</nav>

Note

Use the {{local}} helper to apply multiple styles.

<template> tag

Since we pass styles to the template as a class property, it's not possible to style template-only components. (Note, template-only components have the import path @ember/component/template-only.)

We can address this issue by using <template> tag. Replace navigation-menu.{hbs,ts} with navigation-menu.gts:

src/components/navigation-menu.gts
import type { TOC } from '@ember/component/template-only';
import { LinkTo } from '@ember/routing';

import styles from './navigation-menu.css';

type MenuItem = {
  label: string;
  route: string;
};

interface NavigationMenuSignature {
  Args: {
    menuItems: MenuItem[];
    name?: string;
  };
}

const NavigationMenuComponent: TOC<NavigationMenuSignature> =
  <template>
    <nav aria-label={{@name}}>
      <ul class={{styles.list}}>
        {{#each @menuItems as |menuItem|}}
          <li>
            <LinkTo @route={{menuItem.route}} class={{styles.link}}>
              {{menuItem.label}}
            </LinkTo>
          </li>
        {{/each}}
      </ul>
    </nav>
  </template>

export default NavigationMenuComponent;

CSS declaration files

To help TypeScript understand what it means to import a CSS file,

import styles from './navigation-menu.css';

and what styles looks like, you will need to provide the declaration file navigation-menu.css.d.ts.

Lucky for you, type-css-modules can create this file. Write a pre-script as shown below:

/* package.json */
{
  "scripts": {
    "lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"",
    "prelint:types": "type-css-modules --src src",
    "lint:types": "tsc --noEmit" // or "glint"
  }
}

Now, when you run lint, the prelint:types script will create the CSS declaration files, then lint:types will type-check the files in your project.

pnpm lint

At any time, you can run prelint:types to only create the CSS declaration files.

pnpm prelint:types

Do the file location and name matter?

A component's template and backing class must have the same name (the related technical terms are resolve and resolution):

  • navigation-menu.{hbs,ts} with the flat component structure
  • navigation-menu/index.{hbs,ts} with the nested component structure1

1. Currently, the nested layout doesn't work in v2 addons (see issue #1596).

In contrast, the component's stylesheet can have a different name and even live in a different folder. This is because we explicitly import the CSS file in the backing class.

Still, for everyone's sanity, I recommend colocating the stylesheet and providing the same name.

# Flat component structure
your-v2-addon
├── src
│   └── components
│       ├── navigation-menu.css
│       ├── navigation-menu.css.d.ts
│       ├── navigation-menu.hbs
│       └── navigation-menu.ts
...
# Nested component structure
your-v2-addon
├── src
│   └── components
│       └── navigation-menu
│           ├── index.css
│           ├── index.css.d.ts
│           ├── index.hbs
│           └── index.ts
...

Can I use the file extension *.module.css?

Yes! You can use *.module.css to indicate the stylesheets that are for CSS modules. type-css-modules will create declaration files with the extension *.module.css.d.ts.

- import styles from './navigation-menu.css';
+ import styles from './navigation-menu.module.css';

Write tests

In general, I recommend not writing an hasClass() assertion to test styles.

The presence (or absence) of a class doesn't guarantee that what your user sees is correct and will be in the future. An hasStyle() assertion is somewhat better (the assertion is stronger), but may fail due to rounding errors. In general, prefer writing visual regression tests. This helps you hide implementation details.

That said, if you must write an hasClass assertion, you can provide a test helper.

Addon: src/test-support/components/navigation-menu.ts
import styles from '../../components/navigation-menu.css';

type LocalClassName = keyof typeof styles;

export function getClassForNavigationMenu(
  localClassName: LocalClassName,
): string {
  return styles[localClassName];
}
Addon: src/test-support.ts

For convenience, re-export the test helper(s).

export * from './test-support/components/navigation-menu.ts';
Test app: tests/integration/components/navigation-menu-test.ts

For simplicity, other import statements have been hidden.

import { getClassForNavigationMenu } from 'your-v2-addon/test-support';

module('Integration | Component | navigation-menu', function (hooks) {
  setupRenderingTest(hooks);

  test('it renders', async function (assert) {
    await render(hbs`
      <NavigationMenu
        @menuItems={{array
          (hash label="Home" route="index")
        }}
      />
    `);

    assert.dom('ul').hasClass(getClassForNavigationMenu('list'));

    assert.dom('a').hasClass(getClassForNavigationMenu('link'));
  });
});

Note

In rollup.config.mjs, don't forget to add test-support.js to addon.publicEntrypoints().

addon.publicEntrypoints([
  '**/*.js',
  'index.js',
  'template-registry.js',
  'test-support.js',
]),