Skip to content

Latest commit

 

History

History
360 lines (283 loc) · 8 KB

File metadata and controls

360 lines (283 loc) · 8 KB

Creating Components

This guide covers best practices for creating React components in the Node.js website project.

Table of Contents

Component Architecture

The website uses a dual-package architecture for React components:

  • @node-core/ui-components: Reusable, framework-agnostic UI components
  • apps/site/components: Website-specific components that use Next.js features

Component Guidelines

Framework-Agnostic Components

Generic UI components should be placed in @node-core/ui-components:

// @node-core/ui-components/src/Common/Button/index.tsx
import type { FC, ButtonHTMLAttributes } from 'react';

import styles from './index.module.css';

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: 'primary' | 'secondary';
  size?: 'small' | 'medium' | 'large';
};

const Button: FC<ButtonProps> = ({
  variant = 'primary',
  size = 'medium',
  children,
  className,
  ...props
}) => (
  <button
    className={`${styles.button} ${styles[variant]} ${styles[size]} ${className || ''}`}
    {...props}
  >
    {children}
  </button>
);

export default Button;

Website-Specific Components

Components that need Next.js features should be in apps/site/components, such as components that require routing.

Component Structure

File Organization

Each component should have its own folder with this structure:

ComponentName/
├── index.tsx                 # Component implementation
├── index.module.css          # Component styles
├── index.stories.tsx         # Storybook stories (ui-components only)
└── __tests__/
    └── index.test.mjs        # Unit tests

Sub-components

If a component has sub-components, organize them hierarchically:

ComponentName/
├── index.tsx
├── index.module.css
├── SubComponent/
│   ├── index.tsx
│   └── index.module.css
└── AnotherSubComponent/
    ├── index.tsx
    └── index.module.css

Component Implementation

Basic Component Template

import type { FC } from 'react';

import styles from './index.module.css';

type MyComponentProps = {
  title: string;
  description?: string;
  isVisible?: boolean;
};

const MyComponent: FC<MyComponentProps> = ({
  title,
  description,
  isVisible = true,
}) => {
  if (!isVisible) return null;

  return (
    <div className={styles.container}>
      <h2 className={styles.title}>{title}</h2>
      {description && <p className={styles.description}>{description}</p>}
    </div>
  );
};

export default MyComponent;

Component with Children

import type { FC, PropsWithChildren } from 'react';

import styles from './index.module.css';

type CardProps = PropsWithChildren<{
  title: string;
  variant?: 'default' | 'highlighted';
}>;

const Card: FC<CardProps> = ({ title, variant = 'default', children }) => (
  <div className={`${styles.card} ${styles[variant]}`}>
    <h3 className={styles.title}>{title}</h3>
    <div className={styles.content}>{children}</div>
  </div>
);

export default Card;

Styling Components

CSS Modules

Use CSS Modules for component styling:

/* index.module.css */
.container {
  @apply flex
    flex-col
    gap-4
    rounded-lg
    bg-white
    p-6
    shadow-sm;
}

.title {
  @apply text-xl
    font-semibold
    text-gray-900;
}

.description {
  @apply leading-relaxed
    text-gray-600;
}

Style Guidelines

  • Use camelCase for CSS class names
  • Apply one Tailwind utility per line for readability
  • Use @apply directive for Tailwind utilities
  • Avoid plain CSS properties when Tailwind alternatives exist
  • Keep styles scoped to the component

Responsive Design

.container {
  @apply grid
    grid-cols-1
    gap-4
    md:grid-cols-2
    lg:grid-cols-3;
}

.title {
  @apply text-lg
    md:text-xl
    lg:text-2xl;
}

TypeScript Best Practices

Prop Types

// Prefix prop types with component name
type ButtonProps = {
  variant: 'primary' | 'secondary' | 'danger';
  size: 'small' | 'medium' | 'large';
  isLoading?: boolean;
  onClick?: () => void;
};

// Extend HTML attributes when appropriate
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
  label: string;
  error?: string;
};

// Use PropsWithChildren for components that accept children
type ContainerProps = PropsWithChildren<{
  maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
  className?: string;
}>;

Import Patterns

// Type-only imports
import type { FC, HTMLAttributes, MouseEvent } from 'react';

// Regular imports
import { useState, useEffect } from 'react';
import { Button } from '@node-core/ui-components';

// Avoid importing React itself
// ❌ import React from 'react';
// ✅ import type { FC } from 'react';

Component Domain Organization

Components should be organized by domain (where they're used):

  1. UI Components (@node-core/ui-components)

When you need Next.js functionality in a UI component, use a wrapper, such as the one shown below:

// Base component (@node-core/ui-components)
const BaseNavLink: FC<BaseLinkProps> = ({ href, isActive, children }) => (
  <a href={href} className={`${styles.link} ${isActive ? styles.active : ''}`}>
    {children}
  </a>
);

// Wrapper component (apps/site/components)
const NavLink: FC<NavLinkProps> = ({ href, children }) => {
  const pathname = usePathname();
  const isActive = pathname === href;

  return (
    <BaseNavLink href={href} isActive={isActive}>
      {children}
    </BaseNavLink>
  );
};
  1. Site Components (apps/site/components)

Testing Components

Unit Tests

// __tests__/index.test.mjs
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../index.js';

describe('Button Component', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    assert(isVisible(screen.getByRole('button', { name: /click me/i }));
  });

  it('applies variant styles correctly', () => {
    render(<Button variant="secondary">Test</Button>);
    const button = screen.getByRole('button');
    assert(button.getAttribute('class').includes('secondary'))
  });
});

Storybook Stories

// index.stories.tsx
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
import Button from '@node-core/ui-components/Common/Button';

type Story = StoryObj<typeof Button>;
type Meta = MetaObj<typeof Button>;

export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'Secondary Button',
  },
};

export const Loading: Story = {
  args: {
    variant: 'primary',
    children: 'Loading...',
    disabled: true,
  },
};

export default {
  component: Button,
  title: 'Common/Button',
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: 'select',
      options: ['small', 'medium', 'large'],
    },
  },
} as Meta;