Criando um Design System reutilizável entre React e React Native com Tamagui

alvarogfn

Alvaro Guimarães

Posted on March 27, 2024

Criando um Design System reutilizável entre React e React Native com Tamagui

TLDR;

Você pode conferir o repositório gerado por esse artigo clicando aqui.

🚀 Introdução

Muitas vezes, no desenvolvimento de aplicações mobile com React Native me veio a cabeça a possibilidade de reutilização de componentes no contexto web/mobile. Recentemente, conheci uma biblioteca chamada Tamagui que permite que os componentes sejam compartilhados tanto no React Web quando no React Native.

Então tive o desafio de criar uma biblioteca de componentes separada que pudesse ser utilizada tanto no React Web quanto no React Native.

📦 Iniciando o projeto com vite

Vamos começar criando um template com vite. De acordo com a documentação do Vite, ele é uma ferramenta de construção que tem como objetivo fornecer uma experiência de desenvolvimento mais rápida e leve para projetos web modernos.

pnpm create vite@latest
Enter fullscreen mode Exit fullscreen mode
╰─○ pnpm create vite@latest
✔ Project name: library
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC
Enter fullscreen mode Exit fullscreen mode

Esse setup vai nos dar uma configuração inicial com React e TypeScript orientado para o desenvolvimento de aplicações web, porém, o que precisamos mesmo é de uma configuração que permita o desenvolvimento de componentes para serem distribuídos para serem utilizados por outras aplicações.

Para isso, vamos ajustar o nosso vite.config.ts para configurar o modo de desenvolvimento de uma biblioteca.

// /vite.config.ts
import { defineConfig } from "vite";

// https://vitejs.dev/config/
export default defineConfig({
  build: {
    lib: { 
      // definindo o ponto inicial da nossa biblioteca de componentes.
      entry: "src/index.ts", 
      // definindo os formatos de distribuição da nossa biblioteca (CommonJS e ESM).
      formats: ["cjs", "es"]  
    },
    rollupOptions: {
      // definindo as dependências externas da nossa biblioteca.
      // Essas dependências não serão incluídas no bundle final.
      external: ["react", "react/jsx-runtime", "react-dom"], 
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

No arquivo especificado em entry, vamos criar o ponto de entrada da nossa biblioteca.

// /src/index.ts
export * from "./components";
Enter fullscreen mode Exit fullscreen mode

e vamos criar um componente de exemplo.

// /src/components/Text/Text.tsx

export const Text = () => {
  return <p>Text</p>;
};
Enter fullscreen mode Exit fullscreen mode

No final, nossa estrutura de arquivos se parecerá com isso:

  /src
    /components
      /text
        text.tsx
        index.ts
      index.ts
    index.ts
  vite.config.ts
Enter fullscreen mode Exit fullscreen mode

Quando o comando pnpm vite build for executado, alguns arquivos serão gerados dentro da pasta dist com o nome da sua biblioteca.

// /dist/library.js

import { jsx as t } from "react/jsx-runtime";
const i = () => /* @__PURE__ */ t("text", { children: "Text!" });
export {
  i as Text
};
Enter fullscreen mode Exit fullscreen mode

Note que não há nenhuma dependência externa incluída no bundle final, pois definimos as dependências como externas no vite.config.ts. Por esse motivo, precisamos informar no package.json que essas dependências devem vir de fora (quem está instalando a biblioteca deve ter elas como dependência). Para isso, vamos mover o React e o ReactDOM para o campo de devDependencies, pois precisamos dessas dependências apenas enquanto desenvolvemos, e adicionar elas na seção de peerDependencies do nosso package.json.

  "peerDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
    // outras dependências
  }
Enter fullscreen mode Exit fullscreen mode

Esse artigo da Naveera explica bem a diferença entre cada tipo de dependência.

Vamos também atualizar a seção de scripts do package.json para incluir o comando de build do vite:

// /package.json
  "scripts": {
    "build": "vite build"
  },
  "devDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "5.4.3",
    "vite": "5.2.3",
    "vite-plugin-dts": "3.7.3"
  },
  "peerDependencies": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  }
Enter fullscreen mode Exit fullscreen mode

⛓️ Integrando com o Typescript

Ao observar a pasta de distribuição (dist) gerada pelo vite ao rodar o comando de build, você pode notar que os nossos tipos do typescript não estão presentes, isso acontece pois o vite transforma o Typescript em Javascript para rodar no contexto dos navegadores, onde os tipos não são necessários. No nosso caso que estamos buildando o projeto para compartilhar com outros desenvolvedores, os tipos acabam sendo essenciais para a prevenção de bugs e melhora na experiência de desenvolvimento.

Vamos ajustar esse detalhe instalando alguns plugins novos para o vite:

  pnpm add -D vite-plugin-dts
Enter fullscreen mode Exit fullscreen mode
// /vite.config.ts
import { defineConfig } from "vite";
import dtsPlugin from "vite-plugin-dts";
import tsconfigPaths from "vite-tsconfig-paths";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    // O plugin dtsPlugin vai gerar arquivos de 
    // tipagem para cada arquivo dentro da pasta src.
    dtsPlugin(),
  ],
  build: {
    lib: { 
      entry: "src/index.ts", 
      // Definindo os formatos de distribuição da nossa biblioteca (CommonJS e ESM).
      formats: ["cjs", "es"] 
    },
    rollupOptions: {
      // Definindo as dependências externas da nossa biblioteca. 
      // Essas dependências não serão incluídas no bundle final.
      external: ["react", "react/jsx-runtime", "react-dom"],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Também vamos fazer um ajuste no tsconfig.json e no package.json para configurar a geração de sourcemaps a apartir dos tipos gerados. Isso vai permitir que o editor forneça o código fonte original ao invés do código compilado quando buscarmos as referências das bibliotecas importadas.

// /tsconfig.json
    // ...
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    // ...
Enter fullscreen mode Exit fullscreen mode
// /package.json
  // ...
  "source": "./src/index.ts",
  "files": [
    "dist",
    "src"
  ],
  // ...
Enter fullscreen mode Exit fullscreen mode

♟ Tree Shaking

Por padrão, o vite tem o comportamento de mesclar todos os arquivos fonte do seu código em um único arquivo .js para ser servido para o navegador. Vocês podem notar isso ao observar os arquivos gerados na pasta de distribuição:

dist/
  components/
    Text/
      text.d.ts
      text.d.ts.map
      index.d.ts
      index.d.ts.map
    index.d.ts
    index.d.ts.map
  index.d.ts
  index.d.ts.map
  library.js
  library.cjs
Enter fullscreen mode Exit fullscreen mode

Todo conteúdo javascript foi mesclado dentro de um único arquivo, enquanto os tipos permaneceram isolados entre sí, porém esse comportamento pode não ser o ideal no contexto do desenvolvimento de bibliotecas. Com todo código fonte dentro de um único arquivo, quando alguém utilizar algum componente ou função gerado pela sua biblioteca, todo o conteúdo javascript será incluído no bundle final, mesmo que você esteja utilizando apenas alguns componentes. Isso aumenta o tamanho final da aplicação e consequentemente o peso do seu aplicativo/site. Nessa seção, vamos configurar o vite para que ele considere cada arquivo do projeto como se fosse um módulo separado, que importa outros módulos, isso vai permitir que bundlers como vite e webpack removam os componentes que não estão sendo utilizados no build final.

Para configurar cada arquivo como um entry point, vamos utilizar a biblioteca glob, que vai nos permitir encontrar cada arquivo dentro da pasta src a partir de uma expressão minimatch.

pnpm add -D glob @types/node
Enter fullscreen mode Exit fullscreen mode

A biblioteca @types/node vai permitir que usemos bibliotecas internas do node com typescript, como node:url e node:path.

Vamos atualizar as configurações do rollup no vite.config.ts para que ele mapeie todos os arquivos dentro da pasta src e os adicione como entry point nas configurações do rollup.

// /vite.config.ts

import { defineConfig } from "vite";
import dtsPlugin from "vite-plugin-dts";
import { dirname, extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { glob } from "glob";

// Caminho absoluto do diretório atual a partir da raiz do sistema de arquivos.
const __dirname = dirname(fileURLToPath(import.meta.url)); // /home/user/pasta/library ou C:\Users\user\pasta\library

const computeAllSrcFiles = (): Record<string, string> => {
  // Encontra todos os arquivos .ts e .tsx dentro da pasta src.
  const files = glob.sync(["src/**/*.{ts,tsx}"]);

  const paths = files.map((file) => [
    // Remove a extensão do arquivo e calcula o caminho relativo a partir da pasta src.
    /* key: */ relative(
      "src",
      file.slice(0, file.length - extname(file).length)
    ),

    // Converte o caminho do arquivo para um caminho absoluto.
    /* value: */ fileURLToPath(new URL(file, import.meta.url)),
  ]);

  return Object.fromEntries(paths);
  // Converte o array de caminhos em um objeto.
};

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [dtsPlugin()],
  build: {
    lib: {
      // Definindo o ponto inicial da nossa biblioteca de componentes para o vite.
      entry: resolve(__dirname, "src/main.ts"),
      // Definindo os formatos de distribuição da nossa biblioteca (CommonJS e ESM).
      formats: ["cjs", "es"],
      // Definindo o nome do arquivo de saída. 
      // `entryName` é o nome do arquivo sem a extensão, 
      // e `format` é o formato de distribuição.
      fileName(format, entryName) {
        if (format === "es") return `${entryName}.js`;
        return `${entryName}.${format}`;
      },
    },
    rollupOptions: {
      // Definindo as dependências externas da nossa biblioteca. 
      external: ["react", "react/jsx-runtime", "react-dom"],
      // A configuração `input` do rollupOptions permite que você forneça um objeto
      // específicando arquivos de saída apontando para arquivos do código fonte.
      input: computeAllSrcFiles(),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Agora, a nossa pasta de distribuição vai conter os seguintes arquivos gerados:

  /dist
    /components
      Button.cjs
      Button.d.ts
      Button.d.ts.map
      Button.js
      Button.cjs
      index.d.ts
      index.d.ts.map
      index.js
    index.cjs
    index.d.ts
    index.d.ts.map
    index.js
Enter fullscreen mode Exit fullscreen mode

💅 Configurando o Storybook

O Storybook é uma ferramenta de construção de componentes e páginas de forma isolada, muito útil para construção de testes e documentação de interfaces e componentes. Ao desenvolver uma biblioteca de componentes, é comum querermos ter a disposição um catalogo dos nossos componentes e a documentação das suas propriedades e comportamentos, agilizando o processo de desenvolvimento e utilização dos componentes.

Podemos configurar o nosso arquivo inicial do Storybook com o comando:

# No momento de escrita desse artigo, a versão do storybook é a 8.0.4
npx storybook@latest init
Enter fullscreen mode Exit fullscreen mode

Isso vai gerar uma pasta chamada .storybook com arquivos chamados main.ts e preview.ts, além de uma pasta de exemplos chamada stories.

  .storybook/
    main.ts
    preview.ts
  src/ 
    stories/
Enter fullscreen mode Exit fullscreen mode

A pasta de stories contém componentes e exemplos de como você pode utilizar o storyboook, então ela pode ser removida sem medo. Vamos excluir a pasta stories, e melhorar o nosso componente de Text para incluir
um arquivo de text.stories.tsx, e um arquivo de tipagens text.types.ts.

// /src/components/text/text.types.ts

import { ComponentPropsWithoutRef, ReactNode } from "react";

export type TextProps = ComponentPropsWithoutRef<"p"> & {
  // A interface ComponentPropsWithoutRef fornece a tipagem base 
  // do elemento <p> sem adicionar um ref.
  children?: ReactNode;
};
Enter fullscreen mode Exit fullscreen mode
// /src/components/text/text.stories.tsx

import type { Meta, StoryObj } from "@storybook/react";
import { Text } from "./text";

export default {
  component: Text,
  title: "Components/Text",
} satisfies Meta<typeof Text>;

type Story = StoryObj<typeof Text>;

export const StoryDefault: Story = {
  // Aqui, estamos configurando o storybook para 
  // renderizar o componente de texto e nomeá-lo como Default.
  name: "Default",
  render: (props) => <Text {...props} />,
};
Enter fullscreen mode Exit fullscreen mode

Se você está em dúvida sobre conteúdo dentro do arquivo de stories, a própria documentação do storybook é um bom começo.

// /src/components/text/text.tsx

import { TextProps } from "./text.types";

export const Text = ({ children, ...props }: TextProps) => (
  <p {...props}>{children}</p>
);
Enter fullscreen mode Exit fullscreen mode

Toda essa estruturação é opcional, e você pode organizar os arquivos e estilos do jeito que bem entender.

Se você iniciar o Storybook com o comando pnpm storybook, vai notar que o nosso componente de texto já foi renderizado na tela.

🧪 Adicionando Vitest com React Testing Library

O vitest é um framework de testes semelhante ao jest, que integra muito bem a ambientes com o vite configurado. Podemos reutilizar os plugins e configurações já inseridas dentro do vite.config.ts, facilitando o processo de configuração dos testes.

Como estamos em um contexto de testes para o front-end, vamos contar também com o apoio da biblioteca React Testing Library que fornece suporte para a renderização de componentes e queries customizadas na DOM.

Vamos começar instalando as dependências necessárias e criando um vitest.config.ts na raiz do projeto:

pnpm add -D vitest @testing-library/jest-dom @testing-library/react jsdom
Enter fullscreen mode Exit fullscreen mode
// /vitest.config.ts

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
    // Nesse arquivo, vamos configurar a integração do vitest
    // com a biblioteca React Testing Library
    setupFiles: ["./src/setup-tests.ts"],
  },
});
Enter fullscreen mode Exit fullscreen mode
// /src/setup-tests.ts
import "@testing-library/jest-dom/vitest";
Enter fullscreen mode Exit fullscreen mode

Também será necessário fazer alguns ajustes no tsconfig.json para que o Typescript reconheça as variáveis globais fornecidas pelo vitest:

// /tsconfig.json
  "compilerOptions": {
    // adicione referencia ao vitest/globals na propriedade types.
    "types": ["vitest/globals"], 
    // ...
Enter fullscreen mode Exit fullscreen mode

Por fim, vamos adicionar um comando na seção de scripts do package.json para rodar os testes com maior facilidade:

// /package.json
  "scripts": {
    "test": "vitest"
  },
Enter fullscreen mode Exit fullscreen mode

Finalmente, é hora de criar um teste para o nosso componente de texto e ver o resultado no terminal:

// /src/components/text/text.test.tsx

import { render, screen } from "@testing-library/react";

import { Text } from "./text";

describe("[Components]: Text", () => {
  it("renders without crash", () => {
    render(<Text>Hello World</Text>)
    const component = screen.getByText("Hello World");
    expect(component).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode
 ✓ src/components/text/text.test.tsx (1)[Components]: Text (1)
     ✓ renders without crash

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  15:29:37
   Duration  383ms
Enter fullscreen mode Exit fullscreen mode

👾 Adicionando Tamagui

O Tamagui é uma ferramenta que possibilita o compartilhamento de código entre diferentes plataformas que suportam o React (web, android, ios), tornando a estilização no ambiente React mais agradável e rápido.

⚠️ Antes de continuarmos: Limitações em relação ao PNPM

Existem algumas limitações em relação ao uso de pnpm ao configurar ambientes que envolvem o react native, e até mesmo o storybook devido a forma com que o gerenciador de pacotes resolve suas dependências. Infelizmente, o React Native não funciona muito bem com a estrutura criada para resolver as dependências a partir de links simbólicos. Por esse motivo, vamos utilizar de uma flag no .npmrc diz ao pnpm que queremos uma estrutura de arquivos semelhante ao npm e yarn na node_modules:

// /.npmrc
# https://pnpm.io/npmrc#node-linker
node-linker=hoisted
Enter fullscreen mode Exit fullscreen mode

Também será necessário remover a declaração de type: "module" dentro do arquivo package.json para que o @tamagui possa funcionar corretamente.

Após essa configuração, é necessário utilizar pnpm install para que ele re-crie a node_modules com a nova estrutura.

Continuando...

Vamos começar instalando as dependências necessárias para configurar o ambiente do tamagui na nossa biblioteca.

pnpm add expo expo-linear-gradient -D react-native @tamagui/core @tamagui/vite-plugin
Enter fullscreen mode Exit fullscreen mode

Por algum motivo, o tamagui tem dependência do expo-linear-gradient internamente, então é necessário instalar ambas as bibliotecas expo e expo-linear-grandient.

Também será necessário fazer alguns ajustes na estrutura das nossas peerDependencies para incluir o @tamagui e o react-native como dependências externas. Como nossa biblioteca funciona em contextos diferentes, algumas dependências não são necessariamente obrigatórias:

// /package.json

 "peerDependencies": {
    "@tamagui/core": "1.91.4",
    "@tamagui/vite-plugin": "1.91.4",
    // O metro-plugin é responsável por fazer o tamagui funcionar no contexto do react-native.
    "@tamagui/metro-plugin": "1.91.4",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-native": "0.73.5"
  },
  // Vamos usar esse campo para definir as peer dependências que não são obrigatórias.
  "peerDependenciesMeta": {
    // Não usaremos react-dom no contexto do react-native.
    "react-dom": {
      "optional": true
    },
    // não usaremos o react-native no contexto do web.
    "react-native": {
      "optional": true
    },
    // Não é obrigatório utilizar o vite como bundler;
    "@tamagui/vite-plugin": {
      "optional": true
    },
    // Não é obrigatório utilizar o metro como bundler;
    "@tamagui/metro-plugin": {
      "optional": true
    }
  },
Enter fullscreen mode Exit fullscreen mode

Vamos configurar também o vite.config.ts para incluir o @tamagui/core e o react-native como uma dependência externa:

// /vite.config.ts

    rollupOptions: {
      // Definindo as dependências externas da nossa biblioteca.
      external: [
        "react",
        "react/jsx-runtime",
        "react-dom",
        "react-native",
        "@tamagui/core",
      ],
Enter fullscreen mode Exit fullscreen mode

Também será necessário criar um arquivo de configuração inicial para o tamagui chamado tamagui.config.ts, irei criar esse arquivo na pasta src/themes.

// /src/themes/tamagui.config.ts
import { createTamagui } from "@tamagui/core";

// Você é livre para definir os tokens e temas do tamagui como quiser.
// no nosso caso, vamos definir apenas duas cores e dois temas.
const config = createTamagui({
  fonts: {},
  shorthands: {},
  themes: {
    // É necessário configurar ao menos um tema na aplicação.
    night: { color: "#005" },
    sun: { color: "#FA0" },
  },
  tokens: {
    color: {
      primary: "#000",
      secondary: "#FFF",
    },
    radius: {},
    size: {},
    space: {},
    zIndex: {},
  },
});

export default config;
Enter fullscreen mode Exit fullscreen mode

Após a definição dos tokens, vamos configurar o Typescript para reconhecer os tipos customizados definidos no tamagui.config.ts criando um arquivo src/types.d.ts e adicionando a seguinte configuração:

// src/types.d.ts

import config from "./themes/tamagui.config";

export type AppConfig = typeof config;

declare module "@tamagui/core" {
  interface TamaguiCustomConfig extends AppConfig {}
}
Enter fullscreen mode Exit fullscreen mode

Nós extraímos o tipo de config e atribuímos ao tipo de TamaguiCustomConfig. Isso vai permitir que o typescript reconheça os tipos constantes definidos no src/themes/tamagui.config.ts.

Para que o sistema de tokens e temas do tamagui funcione, é necessário criar um provider que injete o contexto do tamagui na aplicação. Vamos criar um arquivo chamado theme-provider.tsx na pasta src/themes.

// /src/themes/theme-provider.tsx
import { TamaguiProvider, TamaguiProviderProps } from "@tamagui/core";
import { PropsWithChildren } from "react";
import appConfig from "./tamagui.config";

type ThemeProviderProps = PropsWithChildren<TamaguiProviderProps>;

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return (
    <TamaguiProvider config={appConfig} {...props}>
      {children}
    </TamaguiProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Esse provider deverá ser utilizado pelo storybook, testes e pelo consumidor da biblioteca para que os tokens definidos pelo tamagui.config.ts funcionem.

👾🧪 Tamagui com Vitest

Para que o Tamagui funcione no contexto dos testes com o vitest, é necessário adicionar o plugin responsável pelo processamento dos componentes do tamagui e adicionar o ThemeProvider em cada chamada render().

Vamos instalar o plugin do vite e adicioná-lo no vitest.config.ts:

pnpm add -D @tamagui/vite-plugin
Enter fullscreen mode Exit fullscreen mode
// /vitest.config.ts

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import { createRequire } from "module";

const require = createRequire(import.meta.url);
const { tamaguiPlugin } = require("@tamagui/vite-plugin");

export default defineConfig({
  plugins: [
    react(),
    tamaguiPlugin({
      components: ["@tamagui/core"],
      // O plugin do tamagui foi colocado na seção de plugins do vitest, apontando para nossa configuração personalizada de tokens.
      config: "src/themes/tamagui.config.ts",
    }),
  ],
  test: {
    environment: "jsdom",
    globals: true,
    server: {
      // Como os testes rodam no contexto do node, essa configuração é necessária para remover os imports e exports do ESM.
      deps: {
        inline: ["@tamagui"],
      },
    },
    include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
    setupFiles: ["./src/setup-tests.ts"],
  },
});
Enter fullscreen mode Exit fullscreen mode

Para adicionar o provider em todos os testes de forma prática, podemos criar um render customizado que será utilizado por todos os testes ao invés do render tradicional exportado pela biblioteca @testing-library/react. vamos criar um arquivo no diretório src/__tests__/setup.tsx que conterá o nosso render customizado. Esse render deve ser importado no lugar do render do @testing-library/react para garantir que nossos testes funcionem.

// /src/__tests__/setup.tsx

import {
  queries,
  Queries,
  render as nativeRender,
  RenderOptions,
  RenderResult,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import { TamaguiProvider } from "@tamagui/core";
import { ReactElement } from "react";
import config from "../themes/tamagui.config";

const render = <
  Q extends Queries = typeof queries,
  Container extends DocumentFragment | Element = HTMLElement,
  BaseElement extends DocumentFragment | Element = Container,
>(
  ui: ReactElement,
  renderOptions?: RenderOptions<Q, Container, BaseElement>
): RenderResult<Q, Container, BaseElement> =>
  nativeRender(<TamaguiProvider config={config}>{ui}</TamaguiProvider>, {
    ...renderOptions,
  });

export * from "@testing-library/react";
export { render };
Enter fullscreen mode Exit fullscreen mode

Após esses ajustes, o nosso projeto já está configurado para rodar os testes mesmo com os componentes do Tamagui. finalmente, vamos integrar o nosso componente de texto com o Tamagui:

// /src/components/text/text.styles.ts

import { Text, styled } from "@tamagui/core";

export const StyledText = styled(Text, {
  color: "$black",
});
Enter fullscreen mode Exit fullscreen mode
// /src/components/text/text.types.tsx
import { GetProps } from "@tamagui/core";
import type { StyledText } from "./text.styles";

export type TextProps = GetProps<typeof StyledText>;
Enter fullscreen mode Exit fullscreen mode
// /src/components/text/text.tsx

import { forwardRef } from 'react';
import { TamaguiElement } from '@tamagui/core';
import type { TextProps } from './text.types';
import { StyledText } from '.';

export const Text = (props: TextProps) => (
   <StyledText {...props}>
     {children}
   </StyledText>
)
Enter fullscreen mode Exit fullscreen mode
// /src/components/text/text.test.tsx

import { Text } from "./text";

import { render, screen } from "../../__tests__/setup";

describe("[Components]: Text", () => {
  it("renders without crash", () => {
    render(<Text>Hello World</Text>);
    const component = screen.getByText("Hello World");
    expect(component).toBeDefined();
  });
});
Enter fullscreen mode Exit fullscreen mode

Ao rodar o teste, você verá que a integração já está funcionando:

 src/components/text/text.test.tsx (1)
    [Components]: Text (1)
      renders without crash

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  21:36:14
   Duration  460ms
Enter fullscreen mode Exit fullscreen mode

👾💅 Configurando com Storybook

Para fazer o Tamagui funcionar no contexto do storybook, os passos são semelhantes aos dos testes, você precisa injetar o plugin do tamagui no storybook e adicionar o provider em cada story.

Podemos adicionar o plugin ao storybook da seguinte forma:

// /.storybook/main.ts

import { tamaguiPlugin } from "@tamagui/vite-plugin";
import type { StorybookConfig } from "@storybook/react-vite";
import tsconfigPaths from "vite-tsconfig-paths";

const config: StorybookConfig = {
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
  ],
  docs: {
    autodocs: true,
  },
  env: (config) => ({
    ...config,
    // Definindo o target do tamagui para renderizar para web
    TAMAGUI_TARGET: "web",
  }),
  framework: {
    name: "@storybook/react-vite",
    options: {},
  },
  stories: [
    "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)",
    "../src/**/*.mdx",
    "../docs/**/*.mdx",
  ],
  viteFinal: (config, { configType }) => {
    config.define = {
      // variáveis de ambiente necessárias para que o @Tamagui entenda
      ...config.define,
      "process.env.NODE_ENV":
        configType === "PRODUCTION" ? "production" : "development",
      "process.env.STORYBOOK": true,
    };

    config.plugins!.push(
      tamaguiPlugin({
        // Referencia a partir do caminho absoluto para o tamagui.config.ts
        config: "/src/themes/tamagui.config.ts",
      }),
    );

    return config;
  },
};
export default config;
Enter fullscreen mode Exit fullscreen mode
// /.storybook/preview.tsx

import type { Preview } from "@storybook/react";

import { ThemeProvider } from "../src/themes/theme-provider";

const preview: Preview = {
  decorators: [
    (Story) => (
      // Colocamos o provider para renderizar em todos os stories:
      <ThemeProvider>
        <Story />
      </ThemeProvider>
    ),
  ],
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /date$/i,
      },
    },
  },
};

export default preview;
Enter fullscreen mode Exit fullscreen mode

Após essas configurações, já podemos rodar pnpm storybook e ver o componente funcionando no próprio storybook.

🦋 Publicando com changesets

A biblioteca changesets é desenhada para automatizar a geração de changelogs, publicação e atualização da versão da biblioteca. Isso garante que os usuários que baixarem o seu design system serão capazes de lidar com o gerenciamento de versão a partir de versões semânticas.

🧹 Fazendo uma limpeza antes da publicação

Se você tentou rodar o comando de build da biblioteca antes de chegar nessa etapa, você pode ter notado que existem alguns arquivos na nossa pasta de distribuição relacionados aos testes de unidade e ao próprio storybook. Talvez o comando de build nem tenha funcionado por sí só.

Isso acontece pois definimos que todos os arquivos da pasta /src como um entry-point para um módulo no nosso vite.config.ts, o que está fazendo com que ele também considere os arquivos de teste e documentação ao gerar os arquivos.

Podemos ajustar isso dentro da função computeAllSrcFiles no vite.config.ts, configurando-o para ignorar todos os arquivos que não são código fonte:

// /vite.config.ts

const computeAllSrcFiles = (): Record<string, string> => {
  // Encontra todos os arquivos .ts e .tsx dentro da pasta src.
  const files = glob.sync(["src/**/*.{ts,tsx}"], {
    ignore: [
    "src/**/*.stories.tsx",
    "src/**/__tests/**",
    "src/**/*.test.{ts,tsx}",
    "src/setup-tests.ts",
    "types.d.ts",
    ], // minimatch que queremos que o glob ignore.
  });
Enter fullscreen mode Exit fullscreen mode

Também é necessário configurar o plugin dts para ignorar esses arquivos na geração de arquivos d.ts e d.ts.map:

// vite.config.ts

// ...

export default defineConfig({
  plugins: [
    dtsPlugin({
      exclude: [
        "node_modules",     
        "src/**/*.stories.tsx",
        "src/**/__tests/**",
        "src/**/*.test.{ts,tsx}",
        "src/setup-tests.ts",
        "types.d.ts"
      ],
      include: ["src"],
    }),
  ],
  // ...
Enter fullscreen mode Exit fullscreen mode

Você também pode notar que alguns arquivos como o button.types.js foram gerados sem conteúdo javascript nenhum. Isso é porque esses arquivos só contém incluem declarações de tipo no seu código fonte, o que não é possível transpilar para javascript.

Podemos remover esses arquivos vazios gerados criando um plugin customizado para o vite:

// vite.config.ts
const removeEmptyFiles = (): PluginOption => ({
  generateBundle(_, bundle) {
    for (const name in bundle) {
      const file = bundle[name];
      if (file.type !== "chunk") return;

      if (file.code.trim() === "") delete bundle[name];
      if (file.code.trim() === '"use strict";') delete bundle[name];
    }
  },
  name: "remove-empty-files",
});

export default defineConfig({
  plugins: [
    dtsPlugin({
      exclude: [
        "node_modules",     
        "src/**/*.stories.tsx",
        "src/**/__tests/**",
        "src/**/*.test.{ts,tsx}",
        "src/setup-tests.ts",
        "types.d.ts"
      ],
      include: ["src"],
    }),
    removeEmptyFiles(),
  ],
  // ...
Enter fullscreen mode Exit fullscreen mode

Essa função vai verificar se algum chunk de dado está vazio ou se apenas contém a declaração de "use strict;", se verdadeiro, esse conteúdo será removido do bundle final.

Ao final dessa etapa, o nosso vite.config.ts estará assim:

// /vite.config.ts

import { PluginOption, defineConfig } from "vite";
import dtsPlugin from "vite-plugin-dts";
import tsconfigPaths from "vite-tsconfig-paths";
import { dirname, extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { glob } from "glob";

// Caminho absoluto do diretório atual a partir da raiz do sistema de arquivos.
const __dirname = dirname(fileURLToPath(import.meta.url)); // /home/user/pasta/library ou C:\Users\user\pasta\library

const computeAllSrcFiles = (): Record<string, string> => {
  // Encontra todos os arquivos .ts e .tsx dentro da pasta src.
  const files = glob.sync(["src/**/*.{ts,tsx}"], {
    ignore: ["src/**/*.stories.tsx", "src/**/*.test.tsx", "src/setup-tests.ts"],
  });

  const paths = files.map((file) => [
    // Remove a extensão do arquivo e calcula o caminho relativo a partir da pasta src.
    /* key: */ relative(
      "src",
      file.slice(0, file.length - extname(file).length)
    ),

    // Converte o caminho do arquivo para um caminho absoluto.
    /* value: */ fileURLToPath(new URL(file, import.meta.url)),
  ]);

  return Object.fromEntries(paths);
  // Converte o array de caminhos em um objeto.
};

const removeEmptyFiles = (): PluginOption => ({
  generateBundle(_, bundle) {
    for (const name in bundle) {
      const file = bundle[name];
      if (file.type !== "chunk") return;

      if (file.code.trim() === "") delete bundle[name];
      if (file.code.trim() === '"use strict";') delete bundle[name];
    }
  },
  name: "remove-empty-files",
});

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    dtsPlugin({
      exclude: [
        "node_modules",
        "src/**/*.stories.tsx",
        "src/**/*.test.tsx",
        "src/setup-tests.ts",
      ],
      include: ["src"],
    }),
    removeEmptyFiles(),
  ],
  build: {
    lib: {
      // Definindo o ponto inicial da nossa biblioteca de componentes.
      entry: resolve(__dirname, "src/main.ts"),
      // Definindo os formatos de distribuição da nossa biblioteca (CommonJS e ESM).
      formats: ["cjs", "es"],
      // Definindo o nome do arquivo de saída.
      // EntryName é o nome do arquivo sem a extensão,
      // e format é o formato de distribuição.
      fileName(format, entryName) {
        if (format === "es") return `${entryName}.js`;
        return `${entryName}.${format}`;
      },
    },
    rollupOptions: {
      // Definindo as dependências externas da nossa biblioteca. (Que não serão incluídas no bundle)
      external: [
        "react",
        "react/jsx-runtime",
        "react-dom",
        "@tamagui/core",
        "@tamagui/vite-plugin",
      ],
      input: computeAllSrcFiles(),
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Continuando...

Para instalar a biblioteca changesets, rode esse comando no terminal:

pnpm install -D @changesets/cli && npx changeset init
Enter fullscreen mode Exit fullscreen mode

Após a instalação da biblioteca, alguns arquivos foram gerados na pasta .changeset, e alguns comandos devem ser adicionados no package.json da nossa aplicação:

// package.json
   scripts: {
    // ...
    // O comando add da biblioteca changeset
    // cria uma nova entrada de versão na pasta 
    // .changeset com os escopos major, minor ou patch
    // representando o versionamento semântico
    "changeset": "changeset add", 
    // O comando de publish é responsável por publicar
    // a biblioteca em artefatos como o `npm`, `azure`, 'aws`...
    "publish":  "changeset publish",
    // O comando version é usado para consumir
    // todas as entradas criadas pelo comando de changeset
    // e calcular a versão resultante. Esse comando também
    // gera um arquivo de CHANGELOG contendo cada versão
    //  publicada pela biblioteca. 
    "version":  "changeset version"
    // ...
Enter fullscreen mode Exit fullscreen mode

Também será necessário ajustar o nosso package.json para apontar para os arquivos corretos na nossa pasta de distribuição:

// /package.json
{
    // O nome a ser publicado da sua biblioteca,
    // caso seja em um registro público, o nome deve ser único.
    "name":  "library",
    // Indica que a biblioteca pode ser publicada em registros públicos:
    "private":  false, 
    // A biblioteca changeset aumentará a versão automaticamente sempre
    // que o comando changeset version for usado:
    "version":  "0.0.0",
    // Apontamos para o código fonte original:
    "source":  "./src/index.ts",
    // Apontamos para o código transpilado no formado Common Javascript:
    "main":  "dist/index.cjs",
    // Apontamos para o código transpilado no formado ESM:
    "module":  "dist/index.js",
    // Incluímos todas as pastas que devem ser adicionadas ao publicar 
    // a biblioteca:
    "files":  [
        "dist",
        "src",
        "package.json",
        "CHANGELOG",
        "README.md"
    ],
    // ...
},
Enter fullscreen mode Exit fullscreen mode
// .changeset/config
{
    "$schema":  "https://unpkg.com/@changesets/config@3.0.0/schema.json",
    "changelog":  "@changesets/cli/changelog",
    "commit":  false,
    "fixed":  [],
    "linked":  [],
    "access":  "public", // Para publicar publicamente, defina como `public`
    "baseBranch":  "main", // Defina com o nome da sua branch principal
    "updateInternalDependencies":  "patch",
    "ignore":  []
}
Enter fullscreen mode Exit fullscreen mode

Após esses ajustes, você pode gerar um changeset através dos comando no terminal:

╰─± pnpm changeset    
🦋  What kind of change is this for library? (current version is 0.0.0) · minor
🦋  Please enter a summary for this change (this will be in the changelogs).
🦋    (submit empty line to open external editor)
🦋  Summary · Primeira versão da minha biblioteca
🦋  
🦋  === Summary of changesets ===
🦋  minor:  library
🦋  
🦋  Is this your desired changeset? (Y/n) · true
🦋  Changeset added! - you can now commit it
🦋  
🦋  If you want to modify or expand on the changeset summary, you can find it here
🦋  info /home/user/documents/library/.changeset/pink-spoons-laugh.md
Enter fullscreen mode Exit fullscreen mode

Esse comando vai gerar um arquivo markdown de nome aleatório dentro da pasta .changeset chamado de changeset, você pode modifica-lo do jeito que preferir antes de gerar de fato uma nova versão, e você também pode acumular quantos changesets precisar antes de lançar novas versões.

Para gerar uma nova versão da biblioteca, você deve executar o comando version, esse comando vai calcular quantos changesets existem dentro da pasta .changeset e fazer a atualização da chave de version dentro do package.json, além de documentar cada um desses arquivos dentro do arquivo CHANGELOG.md

pnpm run version
🦋  All files have been updated. Review them and commit at your leisure
Enter fullscreen mode Exit fullscreen mode
// / CHANGELOG.md

# library

## 0.1.0

### Minor Changes

- Primeira versão da minha biblioteca
Enter fullscreen mode Exit fullscreen mode

Esse arquivo de changelog contém o conteúdo textual de todos os changesets gerados dentro da pasta .changeset.

// /package.json
"name":  "library",
"private":  false,
"version":  "0.1.0",
Enter fullscreen mode Exit fullscreen mode

A versão da nossa biblioteca também foi alterada no package.json, de acordo com o escopo semântico definido em cada changeset (major, minor ou patch).

Finalmente, para publicar a biblioteca, você deve rodar o comando pnpm run publish

pnpm run publish
> changeset publish

🦋  info npm info library
🦋  warn Received 404 for npm info "library"
🦋  info library is being published because our local version (0.1.0) has not been published on npm
🦋  info Publishing "library" at "0.1.0"
Enter fullscreen mode Exit fullscreen mode

Isso vai fazer com que os conteúdos especificados dentro do campo files no nosso package.json sejam publicados no registro do npm. Você deve autenticar com o npm e seguir os passos definidos no terminal para que sua biblioteca seja publicada. :D

Obrigado por ler !!!!

Você pode ver todo o código gerado durante a codificação dessa biblioteca no meu repositório do Github:

https://github.com/alvarogfn/tamagui-design-system-vite-example

Espero que esse artigo tenha sido útil para você em algum nível, e agradeço a todas as sugestões de melhoria!

💖 💪 🙅 🚩
alvarogfn
Alvaro Guimarães

Posted on March 27, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related