DEV Community

loading...

Creating a design system with stencil and react

Franco Valdes
Baseball player turned web developer. Tech lead @ union.co. Creator of gymjournalapp.com
・9 min read

I want to begin by saying that this is not a “why you need a design system” post. There are enough people talking about why design systems are important, what the benefits are and why you need to implement them. This post is about the how and more specifically, how I am attempting it at my company.


Don't want to read this and just get the code?

It's ok, I do it too sometimes. Check out the repo from this blog post here.


Tools and Libraries

Before getting into the code, I want to review the tools and libraries I used.

Stencil
Stencil is a toolchain for building reusable, scalable Design Systems. Generate small, blazing fast, and 100% standards based Web Components that run in every browser.

React
A JavaScript library for building user interfaces.

TypeScript - JavaScript that scales.
TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. Any browser. Any host. Any OS. Open source.

Storybook
Storybook is an open source tool for developing UI components in isolation for React, Vue, and Angular. It makes building stunning UIs organized and efficient.

I will talk about Storybook in part two

I really believe that web components are the future and enjoy what the team at Ionic have done. They are the team behind stencil and the hybrid ionic framework that you can use to build awesome web and hybrid mobile apps.

Getting Started

With all that out of the way, lets get started. In your terminal create a new directory named whatever you want your component system to be called. Posting this here so I will use devto as my example component library.

mkdir devto

This new directory will be where everything in relation to this design system will live, including stencil, storybook and any sub packages like the react bridge we will be building.

In this directory run npm init stencil and choose the component starter app and name it core. This should be all you need to get started working with stencil and build out web components. I used sass for my styles, if you want to use sass, you will need to install the @stencil/sass package and update your stencil.config.js

npm install @stencil/sass sass clean-css-cli -D
import { Config } from '@stencil/core';
import { sass } from '@stencil/sass';

export const config: Config = {
  namespace: 'devto',
  plugins: [
    sass()
  ],
  outputTargets: [
    {
      type: 'dist',
      esmLoaderPath: '../loader'
    },
    {
      type: 'docs-readme'
    },
    {
      type: 'dist',
      esmLoaderPath: '../loader',
      copy: [
        { src: '**/*.scss' }
      ]
    },
    {
      type: 'www',
      serviceWorker: null // disable service workers
    }
  ]
};

The next few steps are optional but useful. I set up some generic global styles, some useful mixins and sass functions. Most of them come directly from the ionic framework so I’ll just link you off to that. The idea here is to create an initial theme and some tooling to help you not only maintain some constraints within your components but also allow for some flexibility in your system. Inside the core package make two new directories.

mkdir css theme

Copy the files (below) and do a quick find and replace from ion to devto or whatever you named your system and done. This way everything within your system has a prefix and will not clash with previous css you might be integrating with.

Also in your pacakage.json add the new css/ folder in the files array. Should look like this at this point

{
  "name": "core",
  "version": "0.0.1",
  "description": "Stencil Component Starter",
  "main": "dist/index.js",
  "module": "dist/index.mjs",
  "es2015": "dist/esm/index.mjs",
  "es2017": "dist/esm/index.mjs",
  "types": "dist/types/index.d.ts",
  "collection": "dist/collection/collection-manifest.json",
  "collection:main": "dist/collection/index.js",
  "unpkg": "dist/core/core.js",
  "files": [
    "dist/",
    "loader/",
    "css/"
  ],
  "scripts": {
    "start": "npm run build.css && stencil build --dev --watch --serve",
    "build.all": "npm run clean && npm run build.css && npm run build.stencil && npm run build.stencil -- --docs",
    "build.css": "npm run css.sass && npm run css.minify",
    "build.stencil": "stencil build --docs",
    "clean": "node scripts/clean.js",
    "css.minify": "cleancss -O2 -o ./css/devto.bundle.css ./css/devto.bundle.css",
    "css.sass": "sass src/css:./css",
    "test": "stencil test --spec --e2e",
    "test.watch": "stencil test --spec --e2e --watchAll",
    "generate": "stencil generate"
  },
  "devDependencies": {
    "@stencil/core": "^1.3.3",
    "@stencil/sass": "^1.0.1",
    "clean-css-cli": "^4.3.0",
    "fs-extra": "^8.1.0",
    "sass": "^1.23.0-module.beta.1"
  },
  "license": "MIT",
  "dependencies": {}
}

You might have noticed a reference to some other scripts, not to worry they are pretty simple utilities.

npm install fs-extra -D

$ devto/core/scripts/clean.js

const fs = require('fs-extra');
const path = require('path');


const cleanDirs = [
  'dist',
  'css'
];

cleanDirs.forEach(dir => {
  const cleanDir = path.join(__dirname, '../', dir);
  fs.removeSync(cleanDir);
});


Ok, I think this is a good stopping point to make sure what have done is working. In the core package directory, run npm install && npm start. This should open up a browser with the main component that ships with the boilerplate project. Give yourself a pat in the back, you now have web components, some default theming and a great toolbox for building out design system.

Quick Note:
Before continuing, create a production build of stencil. Do this by running npm run build.all in the core package directory. This is not required but i've ran into some issues in the past on fresh builds.

Introducing React

Now that we have a web component, lets talk about how to convert this web component into a react component. Back up into your top level folder (outside of core) and create a folder for this sub package.

mkdir packages packages/react

We will be using rollup here to help bridge and compile these components. In the new react directory lets install some stuff and get everything set up. First off, run npm init -y. We will need to install the local core package and some dev dependencies.

npm install /path/to/core/package
npm install tslib

at the end your package.json should look something like this. Feel free to just copy this and save yourself some typing.

$ devto/packages/react/package.json

{
  "name": "devto-react",
  "version": "0.0.1",
  "description": "React specific wrapper",
  "keywords": [
    "stenciljs",
    "react",
    "design system"
  ],
  "license": "MIT",
  "scripts": {
    "build": "npm run clean && npm run copy && npm run compile",
    "clean": "rm -rf dist && rm -rf dist-transpiled",
    "compile": "npm run tsc && rollup -c",
    "lint": "tslint --project .",
    "lint.fix": "tslint --project . --fix",
    "tsc": "tsc -p .",
    "copy": "node scripts/copy.js",
  },
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/types/index.d.ts",
  "files": [
    "dist/",
    "css/"
  ],
  "dependencies": {
    "devto-core": "0.0.1",
    "tslib": "*"
  },
  "peerDependencies": {
    "react": "^16.8.6",
    "react-dom": "^16.8.6"
  },
  "devDependencies": {
    "@types/node": "10.12.9",
    "@types/react": "^16.9.1",
    "@types/react-dom": "^16.8.5",
    "fs-extra": "^8.1.0",
    "jest-dom": "^3.4.0",
    "np": "^5.0.1",
    "react": "^16.9.0",
    "react-dom": "^16.9.0",
    "react-testing-library": "^7.0.0",
    "rollup": "^1.18.0",
    "rollup-plugin-node-resolve": "^5.2.0",
    "rollup-plugin-sourcemaps": "^0.4.2",
    "rollup-plugin-virtual": "^1.0.1",
    "tslint": "^5.18.0",
    "tslint-ionic-rules": "0.0.21",
    "tslint-react": "^4.0.0",
    "typescript": "3.5.3"
  }
}

Next, lets create a tsconfig file touch tsconfig.json which should look like this:

$ devto/packages/react/tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "allowUnreachableCode": false,
    "allowSyntheticDefaultImports": true,
    "declaration": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "esModuleInterop": true,
    "lib": ["dom", "es2015"],
    "importHelpers": true,
    "module": "es2015",
    "moduleResolution": "node",
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "outDir": "dist-transpiled",
    "declarationDir": "dist/types",
    "removeComments": false,
    "inlineSources": true,
    "sourceMap": true,
    "jsx": "react",
    "target": "es2017"
  },
  "include": [
    "src/**/*.ts",
    "src/**/*.tsx"
  ],
  "exclude": [
    "node_modules",
    "**/__tests__/**"
  ],
  "compileOnSave": false,
  "buildOnSave": false
}

A rollup.config.js file that looks like this:

$ devto/packages/react/rollup.config.js

import resolve from 'rollup-plugin-node-resolve';
import sourcemaps from 'rollup-plugin-sourcemaps';

export default {
  input: 'dist-transpiled/index.js',
  output: [
    {
      file: 'dist/index.esm.js',
      format: 'es',
      sourcemap: true
    },
    {
      file: 'dist/index.js',
      format: 'commonjs',
      preferConst: true,
      sourcemap: true
    }
  ],
  external: (id) => !/^(\.|\/)/.test(id),
  plugins: [
    resolve(),
    sourcemaps()
  ]
};

a tslint.json that looks like this:

$ devto/packages/react/tslint.json

{
  "extends": ["tslint-ionic-rules/strict", "tslint-react"],
  "linterOptions": {
    "exclude": [
      "**/*.spec.ts",
      "**/*.spec.tsx"
    ]
  },
  "rules": {
    "no-conditional-assignment": false,
    "no-non-null-assertion": false,
    "no-unnecessary-type-assertion": false,
    "no-import-side-effect": false,
    "trailing-comma": false,
    "no-null-keyword": false,
    "no-console": false,
    "no-unbound-method": true,
    "no-floating-promises": false,
    "no-invalid-template-strings": true,
    "ban-export-const-enum": true,
    "only-arrow-functions": true,

    "jsx-key": false,
    "jsx-self-close": false,
    "jsx-curly-spacing": [true, "never"],
    "jsx-boolean-value": [true, "never"],
    "jsx-no-bind": false,
    "jsx-no-lambda": false,
    "jsx-no-multiline-js": false,
    "jsx-wrap-multiline": false
  }
}

and lastly, a quick file copy utility that will help us move over some files from the core package. This way, we only need to import things from one package instead of installing both stencil and react packages in the future.

$ devto/packages/react/scripts/copy.js

const fs = require('fs-extra');
const path = require('path');

function copyCSS() {
  const src = path.join(__dirname, '..', '..', '..', 'core', 'css');
  const dst = path.join(__dirname, '..', 'css');

  fs.removeSync(dst);
  fs.copySync(src, dst);
}

function main() {
  copyCSS();
}

main();

Now its time to start building stuff. Create a src directory in the react pacakage and lets start coding. The main goal of this package is to bridge the gap between our web components built in stencil and the react ecosystem. Luckily, stencil provides some extra libraries built right in to help with loading these components in.

Quick Note
All credit here goes to the ionic team again, this is how they set up their ionic react package. I learn a lot by being able to duplicate code others have written and really understanding what I am writing.

$ devto/packages/react/src/index.ts

import { JSX } from 'devto-core';
import { defineCustomElements } from 'devto-core/loader';

/** We'll talk about this one in a minute **/
import { createReactComponent } from './createComponent';

export const MyComponent = /*@__PURE__*/createReactComponent<JSX.MyComponent, HTMLMyComponentElement>('my-component');

defineCustomElements(window);

The createComponent helper is where the magic will happen. There we will create a react component dymanically from the web component provided in the only argument. Some other utilities used you can just grab directly from the ionic react package or the repo tied to this blog post.

$ devto/packages/react/src/createComponent.tsx

import React from 'react';
import ReactDom from 'react-dom';

import { attachEventProps, createForwardRef, dashToPascalCase, isCoveredByReact } from './utils';

export interface ReactProps {
  className?: string;
}

interface DevToReactInternalProps<ElementType> {
  forwardedRef?: React.Ref<ElementType>;
  children?: React.ReactNode;
  href?: string;
  target?: string;
  style?: string;
  ref?: React.Ref<any>;
  className?: string;
}

export const createReactComponent = <PropType, ElementType>(
  tagName: string,
) => {
  const displayName = dashToPascalCase(tagName);
  const ReactComponent = class extends React.Component<DevToReactInternalProps<ElementType>> {

    constructor(props: DevToReactInternalProps<ElementType>) {
      super(props);
    }

    componentDidMount() {
      this.componentDidUpdate(this.props);
    }

    componentDidUpdate(prevProps: DevToReactInternalProps<ElementType>) {
      const node = ReactDom.findDOMNode(this) as HTMLElement;
      attachEventProps(node, this.props, prevProps);
    }

    render() {
      const { children, forwardedRef, style, className, ref, ...cProps } = this.props;

      const propsToPass = Object.keys(cProps).reduce((acc, name) => {
        if (name.indexOf('on') === 0 && name[2] === name[2].toUpperCase()) {
          const eventName = name.substring(2).toLowerCase();
          if (isCoveredByReact(eventName)) {
            (acc as any)[name] = (cProps as any)[name];
          }
        }
        return acc;
      }, {});

      const newProps: any = {
        ...propsToPass,
        ref: forwardedRef,
        style
      };

      return React.createElement(
        tagName,
        newProps,
        children
      );
    }

    static get displayName() {
      return displayName;
    }
  };
  return createForwardRef<PropType & ReactProps, ElementType>(ReactComponent, displayName);
};

Did it work?

There are two ways of testing if it all worked. For this blog post, I will show you the quick and dirty way, creating a quick react app using CreateReactApp. In your main directory, run

npx create-react-app devto-test-app

cd into your newly created app and install your local react package

npm install ../core/packages/react

and change your App.js to look like this:

import React from 'react';
import logo from './logo.svg';
import './App.css';

import { MyComponent } from 'devto-react';

/* Core CSS required for Ionic components to work properly */
import 'devto-react/css/core.css';

/* Basic CSS for apps built with Ionic */
import 'devto-react/css/normalize.css';
import 'devto-react/css/structure.css';
import 'devto-react/css/typography.css';

/* Optional CSS utils that can be commented out */
import 'devto-react/css/padding.css';
import 'devto-react/css/text-alignment.css';
import 'devto-react/css/text-transformation.css';
import 'devto-react/css/flex-utils.css';
import 'devto-react/css/display.css';

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <MyComponent first="First Name" middle="Middle Name" last="Last Name" />
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
}

export default App;

Thats it, you are importing the MyComponent from your react package, which is important it from stencil.

Go ahead and start your app, npm run start and you will see it all there. Check it out, its a stencil component in your react app!


Screenshot


On the the next one

I will be writing another post about how to integrate Storybook into your design system package. Hopefully I can get that one up next week. Hopefully this post is helpful and can get you up and running building your design system.

Cheers

Discussion (0)