Skip to content

Latest commit

 

History

History
578 lines (410 loc) · 14.8 KB

set-up-css-modules-apps-built-with-webpack.md

File metadata and controls

578 lines (410 loc) · 14.8 KB

Set up CSS modules (apps built with Webpack)

We will use Webpack and PostCSS to implement CSS modules.

  1. Install dependencies
  2. Configure Webpack
  3. Style your first component
  4. Style your first route

Note

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

Install dependencies

You will need these dependencies to build an Embroider app with Webpack.

  • @embroider/compat
  • @embroider/core
  • @embroider/webpack
  • webpack

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

  • autoprefixer
  • postcss
  • postcss-loader

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

All in all, here's a one-line command for installation:

pnpm install --dev \
  @embroider/compat @embroider/core @embroider/webpack webpack \
  autoprefixer postcss postcss-loader \
  embroider-css-modules type-css-modules

1. Needed only if you have a TypeScript project.

Configure Webpack

In this step, you will update two files: ember-cli-build.js and postcss.config.js.

If you have a new Ember app, you can copy-paste the starter code for ember-cli-build.js. The code defines a variable called options, which you will update later.

Starter code for ember-cli-build.js

You may remove the ember-cli-babel option if your project doesn't support TypeScript.

'use strict';

const { Webpack } = require('@embroider/webpack');
const EmberApp = require('ember-cli/lib/broccoli/ember-app');

function isProduction() {
  return EmberApp.env() === 'production';
}

module.exports = function (defaults) {
  const app = new EmberApp(defaults, {
    // Add options here
    'ember-cli-babel': {
      enableTypeScriptTransform: true,
    },
  });

  const options = {
    skipBabel: [
      {
        package: 'qunit',
      },
    ],
  };

  return require('@embroider/compat').compatBuild(app, Webpack, options);
};

Note

Even if you already have an Embroider app, please do compare your ember-cli-build.js to the starter code so that we are on the same page.

Update ember-cli-build.js

You'll need to set these Webpack options: cssLoaderOptions, publicAssetURL, and webpackConfig. You can do so by adding a key named packagerOptions to options.

options variable
const options = {
  packagerOptions: {
    cssLoaderOptions: {
      modules: {
        localIdentName: isProduction()
          ? '[sha512:hash:base64:5]'
          : '[path][name]__[local]',
        mode: (resourcePath) => {
          const hostAppLocation = 'node_modules/.embroider/rewritten-app';

          return resourcePath.includes(hostAppLocation) ? 'local' : 'global';
        },
      },
      sourceMap: !isProduction(),
    },
    publicAssetURL: '/',
    webpackConfig: {
      module: {
        rules: [
          {
            test: /(node_modules\/\.embroider\/rewritten-app\/)(.*\.css)$/i,
            use: [
              {
                loader: 'postcss-loader',
                options: {
                  sourceMap: !isProduction(),
                  postcssOptions: {
                    config: './postcss.config.js',
                  },
                },
              },
            ],
          },
          /*
            Uncomment this rule to load asset files, e.g. fonts, icons, etc.
            See https://webpack.js.org/guides/asset-modules/ for more information.
          */
          // {
          //   test: /(node_modules\/\.embroider\/rewritten-app\/)(.*\.(ttf|woff))$/,
          //   type: 'asset/resource',
          // },
        ],
      },
    },
  },
  skipBabel: [
    {
      package: 'qunit',
    },
  ],
};

The most important part is cssLoaderOptions.modules.mode. It helps Webpack decide if a CSS file comes from your app (local) or "outside" (global).

function mode(resourcePath) {
  const hostAppLocation = 'node_modules/.embroider/rewritten-app';

  return resourcePath.includes(hostAppLocation) ? 'local' : 'global';
}

Important

If your app lives in a monorepo, please include the relative path from the workspace root to the app. This way, Webpack can distinguish CSS files from your app (local) from those from an addon in the monorepo (global).

// If your app is located at `docs-app`
const hostAppLocation = 'docs-app/node_modules/.embroider/rewritten-app';

Set up PostCSS

Webpack supports PostCSS. Create the file postcss.config.js, then list the PostCSS plugins that you need (e.g. autoprefixer).

# From the project root
touch postcss.config.js
const env = process.env.EMBER_ENV ?? 'development';
const plugins = [require('autoprefixer')];

if (env === 'production') {
  // plugins.push(...);
}

module.exports = {
  plugins,
};
Use eslint-plugin-n?

In .eslintrc.js, find the override rule for Node files. Add postcss.config.js to the list of files.

'use strict';

module.exports = {
  overrides: [
    // Node files
    {
      files: [
        './postcss.config.js',
        // ...
      ],
      extends: ['plugin:n/recommended'],
    },
  ],
};

Move app.css code

To ensure the load order with Webpack, you will now import app/styles/app.css (which defines global styles, @import, @font-face, etc.) in app/app.ts.

Unfortunately, we can't import CSS files located in app/styles, so you'll need to move app.css somewhere else. To do so, let's create the folder app/assets.

mkdir app/assets
cp app/styles/app.css app/assets/app.css

Important

Ember expects app/styles/app.css to exist. Instead of deleting the file, leave it empty. You can copy-paste this default code from Ember CLI:

/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */

Finally, import app.css in app/app.ts.

app/app.ts
+ import './assets/app.css';
+ 
import Application from '@ember/application';
import loadInitializers from 'ember-load-initializers';
import Resolver from 'ember-resolver';

import config from './config/environment';

export default class App extends Application {
  modulePrefix = config.modulePrefix;
  podModulePrefix = config.podModulePrefix;
  Resolver = Resolver;
}

loadInitializers(App, config.modulePrefix);

Style your first component

You can style your app now. Let's create a Glimmer component to test CSS modules.

ember g component hello -gc

While Ember CLI can create the template and the backing class, you will need to manually create the stylesheet.

# From the project root
touch app/components/hello.css

Glimmer component

The goal is to display Hello world! in a <div>-container. In the stylesheet, define the class selector .container.

app/components/hello.css
.container {
  color: magenta;
  font-family: monospace;
  font-size: 1.5rem;
  font-weight: 500;
  padding: 1rem;
}

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

app/components/hello.ts

Note, we write the file extension .css explicitly.

import Component from '@glimmer/component';

import styles from './hello.css';

export default class HelloComponent extends Component {
  styles = styles;
}

Display the message and style the container.

app/components/hello.hbs
<div class={{this.styles.container}}>
  Hello world!
</div>

Finally, render the component. Et voilà! ✨

app/templates/index.hbs
<Hello />

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 hello.{hbs,ts} with hello.gts:

app/components/hello.gts
import styles from './hello.css';

<template>
  <div class={{styles.container}}>
    Hello world!
  </div>
</template>

CSS declaration files

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

import styles from './hello.css';

and what styles looks like, you will need to provide the declaration file hello.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 app",
    "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):

  • hello.{hbs,ts} with the flat component structure
  • hello/index.{hbs,ts} with the nested component structure

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-ember-app
├── app
│   └── components
│       ├── hello.css
│       ├── hello.css.d.ts
│       ├── hello.hbs
│       └── hello.ts
...
# Nested component structure
your-ember-app
├── app
│   └── components
│       └── hello
│           ├── 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 './hello.css';
+ import styles from './hello.module.css';

Note

The files app/assets/app.css and app/styles/app.css keep the extension *.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 get the global class name by importing the stylesheet.

tests/integration/components/hello-test.ts

For simplicity, other import statements have been hidden.

import styles from 'your-ember-app/components/hello.css';

module('Integration | Component | hello', function (hooks) {
  setupRenderingTest(hooks);

  test('it renders', async function (assert) {
    await render(hbs`
      <Hello />
    `);

    assert.dom('div').hasClass(styles.container);
  });
});

Style your first route

To style a route, apply the ideas that you learned for components.

  1. Import a stylesheet in the backing class (the controller) and name it styles.
  2. Pass styles to the template as a class property.
  3. Write this.styles in the template.

<template> tag

If you want to avoid controllers, you can use ember-route-template (experimental) and pass styles to the template directly.

app/templates/index.gts
import Route from 'ember-route-template';

import Hello from '../components/hello';
import styles from './index.css';

export default Route(
  <template>
    <div class={{styles.container}}>
      <Hello />
    </div>
  </template>,
);

Do the file location and name matter?

A route's template and backing class must have the same name:

  • app/controllers/index.ts
  • app/templates/index.hbs

In contrast, the route's stylesheet can have a different name and be placed in any folder (besides app/styles). Again, this is because we explicitly import the CSS file in the backing class.

For proximity, I recommend colocating the stylesheet and the controller. Do provide the same name.

your-ember-app
├── app
│   ├── controllers
│   │   ├── index.css
│   │   ├── index.css.d.ts
│   │   └── index.ts
│   │
│   └── templates
│       └── index.hbs
...

With ember-route-template, you may colocate the stylesheet and the route template.

your-ember-app
├── app
│   └── templates
│       ├── index.css
│       ├── index.css.d.ts
│       └── index.gts
...