Files
teatea-pension/.trellis/spec/frontend/components.md

12 KiB

Component Development Guidelines

This document covers component development patterns including Server vs Client components, semantic HTML, and UI best practices.

Server vs Client Components

Default to Server Components

Next.js App Router defaults to Server Components. Use them for:

  • Data fetching
  • Accessing backend resources directly
  • Keeping sensitive data on the server
  • Reducing client-side JavaScript
// app/(app)/dashboard/page.tsx (Server Component)
import { DashboardStats } from '@/modules/dashboard/components';

export default async function DashboardPage() {
  // Can fetch data directly
  const stats = await fetchDashboardStats();

  return (
    <main>
      <h1>Dashboard</h1>
      <DashboardStats data={stats} />
    </main>
  );
}

When to Use Client Components

Add 'use client' directive only when you need:

  • Event handlers (onClick, onChange, etc.)
  • useState, useEffect, or other React hooks
  • Browser-only APIs (localStorage, window)
  • Class components with lifecycle methods
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

Composition Pattern

Keep Server Components at the top, push Client Components down:

// Server Component (page.tsx)
import { ProductList } from './ProductList';
import { FilterSidebar } from './FilterSidebar'; // Client

export default async function ProductsPage() {
  const products = await fetchProducts();

  return (
    <div className="flex">
      <FilterSidebar /> {/* Client component for interactivity */}
      <ProductList products={products} /> {/* Can be server or client */}
    </div>
  );
}

Passing Server Data to Client Components

// Server Component
export default async function Page() {
  const initialData = await fetchData();

  return <InteractiveWidget initialData={initialData} />;
}

// Client Component
'use client';

export function InteractiveWidget({ initialData }: { initialData: Data }) {
  const [data, setData] = useState(initialData);
  // Interactive logic...
}

Semantic HTML

Kumo UI Convention

Use components/ui/* as the project-owned adapter layer for Cloudflare Kumo components. Feature modules and route files should import Button, Input, Select, Textarea, Table, Checkbox, Badge, Card, and Dialog from @/components/ui/... instead of importing @cloudflare/kumo directly.

// Good: project adapter keeps Kumo API differences local
import { Select } from "@/components/ui/select";
import { Table } from "@/components/ui/table";

// Avoid: feature code should not hand-style native controls
<select name="roleId" />
<table />

Visible form controls and data tables should use the Kumo-backed adapters. Native hidden inputs are acceptable only inside adapters when needed to preserve FormData semantics.

Select Adapter Positioning Contract

components/ui/select.tsx is intentionally project-owned instead of directly wrapping @cloudflare/kumo/components/select. Kumo Select 2.6 can inherit Base UI alignItemWithTrigger=true, which may render data-side="none" and overlap the trigger in production.

The project Select adapter must:

  • Preserve the local API: options, value, defaultValue, name, placeholder, disabled, required, aria-*, and onValueChange.
  • Render a hidden form value only inside the adapter when name is provided.
  • Use portal/fixed positioning for the popup and keep a positive gap between trigger and panel. Browser validation should assert verticalOverlap === 0 on desktop and mobile.
  • Keep keyboard behavior for ArrowUp/ArrowDown, Home/End, Enter/Space, Escape, and Tab.
// Good: feature code stays on the project adapter.
<Select name="organizationId" options={organizationOptions} />

// Avoid: this can reintroduce production overlay regressions.
import { Select } from "@cloudflare/kumo/components/select";

Dialog Adapter Centering Contract

components/ui/dialog.tsx may use Kumo Dialog primitives for accessibility, but the project adapter owns panel geometry. Kumo Dialog includes fixed left-1/2 top-1/2 and translate classes, and those transforms can interact with project animations or production CSS ordering.

The project Dialog adapter must:

  • Center the panel without relying on translate transforms. Use fixed inset-0 m-auto plus explicit width, max-height, and h-fit.
  • Override Kumo minimum width so mobile dialogs stay inside 100vw.
  • Keep transform: none and translate: 0 0 on .teatea-dialog; use opacity/scale-only motion if animation is needed.
  • Browser validation should assert the panel center is within 2px of the viewport center on desktop and mobile.

Use Proper Elements

// Bad: div for everything
<div onClick={handleClick}>Click me</div>
<div>
  <div>Item 1</div>
  <div>Item 2</div>
</div>

// Good: semantic elements
<button onClick={handleClick}>Click me</button>
<ul>
  <li>Item 1</li>
  <li>Item 2</li>
</ul>

Button vs Div

Always use <button> for clickable actions:

// Bad: Non-semantic, no keyboard support, no accessibility
<div
  className="cursor-pointer"
  onClick={handleClick}
>
  Save
</div>

// Good: Semantic, keyboard accessible, proper focus
<button
  type="button"
  onClick={handleClick}
  className="..."
>
  Save
</button>

Form Elements

// Bad: Missing labels, wrong elements
<div>
  <span>Email</span>
  <input type="text" />
</div>

// Good: Proper form structure
<div>
  <label htmlFor="email">Email</label>
  <input
    id="email"
    type="email"
    aria-describedby="email-error"
  />
  {error && <p id="email-error" role="alert">{error}</p>}
</div>

Navigation

// Bad
<div onClick={() => router.push('/about')}>About</div>

// Good
<Link href="/about">About</Link>

// For programmatic navigation with button appearance
<Link href="/about" className="btn btn-primary">
  About
</Link>

Next.js Image Component

Always Use next/image

// Bad: Raw img tag
<img src="/hero.jpg" alt="Hero" />

// Good: Optimized Image component
import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
  priority // For above-the-fold images
/>

Responsive Images

// Fill container
<div className="relative h-64 w-full">
  <Image
    src="/banner.jpg"
    alt="Banner"
    fill
    className="object-cover"
    sizes="(max-width: 768px) 100vw, 50vw"
  />
</div>

Remote Images

Configure domains in next.config.js:

// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.example.com',
      },
    ],
  },
};

Command Palette (cmdk)

Basic Implementation

'use client';

import { Command } from 'cmdk';
import { useState, useEffect } from 'react';

export function CommandPalette() {
  const [open, setOpen] = useState(false);

  // Toggle with keyboard shortcut
  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener('keydown', down);
    return () => document.removeEventListener('keydown', down);
  }, []);

  return (
    <Command.Dialog
      open={open}
      onOpenChange={setOpen}
      label="Global Command Menu"
    >
      <Command.Input placeholder="Type a command or search..." />
      <Command.List>
        <Command.Empty>No results found.</Command.Empty>

        <Command.Group heading="Navigation">
          <Command.Item onSelect={() => router.push('/dashboard')}>
            Go to Dashboard
          </Command.Item>
          <Command.Item onSelect={() => router.push('/settings')}>
            Go to Settings
          </Command.Item>
        </Command.Group>

        <Command.Group heading="Actions">
          <Command.Item onSelect={handleNewOrder}>
            Create New Order
          </Command.Item>
        </Command.Group>
      </Command.List>
    </Command.Dialog>
  );
}

With Search Results

export function SearchCommandPalette() {
  const [search, setSearch] = useState('');
  const { data: results, isLoading } = useSearch(search);

  return (
    <Command.Dialog open={open} onOpenChange={setOpen}>
      <Command.Input
        value={search}
        onValueChange={setSearch}
        placeholder="Search..."
      />
      <Command.List>
        {isLoading && <Command.Loading>Searching...</Command.Loading>}

        <Command.Empty>No results found.</Command.Empty>

        {results?.map((item) => (
          <Command.Item
            key={item.id}
            value={item.title}
            onSelect={() => handleSelect(item)}
          >
            {item.title}
          </Command.Item>
        ))}
      </Command.List>
    </Command.Dialog>
  );
}

Styling with Tailwind

Component Styling Pattern

// Use className for styling
export function Card({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return (
    <div
      className={cn(
        'rounded-lg border bg-card p-4 shadow-sm',
        className
      )}
    >
      {children}
    </div>
  );
}

Conditional Styles

import { cn } from '@/lib/utils';

export function Button({
  variant = 'primary',
  size = 'md',
  className,
  ...props
}: ButtonProps) {
  return (
    <button
      className={cn(
        'inline-flex items-center justify-center rounded-md font-medium',
        // Variants
        {
          'bg-primary text-primary-foreground': variant === 'primary',
          'bg-secondary text-secondary-foreground': variant === 'secondary',
          'border bg-transparent': variant === 'outline',
        },
        // Sizes
        {
          'h-8 px-3 text-sm': size === 'sm',
          'h-10 px-4': size === 'md',
          'h-12 px-6 text-lg': size === 'lg',
        },
        className
      )}
      {...props}
    />
  );
}

Responsive Design

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  {items.map((item) => (
    <Card key={item.id}>{item.content}</Card>
  ))}
</div>

Accessibility

Focus Management

export function Modal({ open, onClose, children }: ModalProps) {
  const closeButtonRef = useRef<HTMLButtonElement>(null);

  useEffect(() => {
    if (open) {
      closeButtonRef.current?.focus();
    }
  }, [open]);

  return (
    <Dialog open={open} onOpenChange={onClose}>
      <DialogContent>
        {children}
        <button ref={closeButtonRef} onClick={onClose}>
          Close
        </button>
      </DialogContent>
    </Dialog>
  );
}

ARIA Labels

<button
  aria-label="Close dialog"
  aria-expanded={isOpen}
  aria-controls="dropdown-menu"
>
  <CloseIcon />
</button>

<div
  id="dropdown-menu"
  role="menu"
  aria-hidden={!isOpen}
>
  {/* Menu items */}
</div>

Best Practices

  1. Server First: Default to Server Components
  2. Semantic HTML: Use the right element for the job
  3. Optimize Images: Always use next/image
  4. Accessibility: Include ARIA labels and keyboard support
  5. Type Props: Define TypeScript interfaces for all props
  6. Composition: Break large components into smaller pieces

Anti-Patterns

  • Using div for buttons and links
  • Using img instead of next/image
  • Adding 'use client' at the top of every file
  • Inline styles instead of Tailwind classes
  • Missing accessibility attributes
  • Components with too many responsibilities