Skip to content

Forms Module Implementation Plan

Overview

This document provides a Tailwind + shadcn/ui implementation plan for the Forms module, including component architecture, file structure, and migration strategy for existing portal components.


1. Tailwind Configuration

tailwind.config.ts

import type { Config } from 'tailwindcss';

const config: Config = {
  darkMode: ['class'],
  content: [
    './src/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
  ],
  theme: {
    extend: {
      colors: {
        // Primary
        primary: {
          DEFAULT: '#2563eb',
          hover: '#1d4ed8',
          light: '#dbeafe',
          ring: 'rgba(37, 99, 235, 0.3)',
        },
        // Surfaces
        surface: {
          page: '#f8fafc',
          card: '#ffffff',
          muted: '#f1f5f9',
          hover: '#f8fafc',
        },
        // Borders
        border: {
          DEFAULT: '#e2e8f0',
          strong: '#cbd5e1',
        },
        // Text
        text: {
          primary: '#0f172a',
          secondary: '#475569',
          muted: '#94a3b8',
        },
        // Status
        status: {
          success: '#22c55e',
          'success-light': '#dcfce7',
          warning: '#f59e0b',
          'warning-light': '#fef3c7',
          danger: '#ef4444',
          'danger-light': '#fee2e2',
        },
        // Sidebar
        sidebar: {
          bg: '#1e293b',
          text: '#94a3b8',
          'text-active': '#ffffff',
          hover: '#334155',
          accent: '#8b5cf6',
        },
        // Chart
        chart: {
          1: '#6366f1',
          2: '#10b981',
          3: '#f59e0b',
          4: '#ef4444',
        },
      },
      fontFamily: {
        sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
      },
      fontSize: {
        xs: ['0.75rem', { lineHeight: '1rem' }],
        sm: ['0.875rem', { lineHeight: '1.25rem' }],
        base: ['1rem', { lineHeight: '1.5rem' }],
        lg: ['1.125rem', { lineHeight: '1.75rem' }],
        xl: ['1.25rem', { lineHeight: '1.75rem' }],
        '2xl': ['1.5rem', { lineHeight: '2rem' }],
        '3xl': ['1.875rem', { lineHeight: '2.25rem' }],
      },
      spacing: {
        // Already uses 4px base, so 1 = 4px, 2 = 8px, etc.
        // Custom additions for specific use cases
        '18': '4.5rem',
        '22': '5.5rem',
      },
      borderRadius: {
        sm: '4px',
        DEFAULT: '6px',
        md: '6px',
        lg: '8px',
        xl: '12px',
      },
      boxShadow: {
        sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
        DEFAULT: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
        md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
        lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
      },
      animation: {
        'fade-in': 'fadeIn 200ms ease-out',
        'slide-in': 'slideIn 200ms ease-out',
        'pulse-subtle': 'pulseSubtle 2s ease-in-out infinite',
      },
      keyframes: {
        fadeIn: {
          '0%': { opacity: '0' },
          '100%': { opacity: '1' },
        },
        slideIn: {
          '0%': { opacity: '0', transform: 'translateY(-4px)' },
          '100%': { opacity: '1', transform: 'translateY(0)' },
        },
        pulseSubtle: {
          '0%, 100%': { opacity: '1' },
          '50%': { opacity: '0.7' },
        },
      },
      transitionDuration: {
        fast: '150ms',
        normal: '200ms',
        slow: '300ms',
      },
      width: {
        sidebar: '260px',
        'sidebar-collapsed': '64px',
        'list-pane': '320px',
      },
      minWidth: {
        'detail-pane': '480px',
      },
      maxWidth: {
        'form-content': '48rem', // max-w-3xl
      },
      zIndex: {
        dropdown: '50',
        sticky: '100',
        overlay: '200',
        modal: '300',
        toast: '400',
      },
    },
  },
  plugins: [
    require('tailwindcss-animate'),
    require('@tailwindcss/forms'),
  ],
};

export default config;

2. File Structure

portal/src/
├── components/
│   ├── ui/                          # shadcn/ui components
│   │   ├── button.tsx
│   │   ├── input.tsx
│   │   ├── select.tsx
│   │   ├── dropdown-menu.tsx
│   │   ├── popover.tsx
│   │   ├── dialog.tsx
│   │   ├── toast.tsx
│   │   ├── skeleton.tsx
│   │   └── index.ts
│   │
│   ├── layout/                      # Layout components
│   │   ├── AppShell/
│   │   │   ├── AppShell.tsx
│   │   │   └── index.ts
│   │   ├── Sidebar/
│   │   │   ├── Sidebar.tsx
│   │   │   ├── SidebarSection.tsx
│   │   │   ├── SidebarLink.tsx
│   │   │   ├── SidebarUserMenu.tsx
│   │   │   └── index.ts
│   │   ├── ListPane/
│   │   │   ├── ListPane.tsx
│   │   │   ├── ListPaneHeader.tsx
│   │   │   ├── ListItem.tsx
│   │   │   └── index.ts
│   │   ├── DetailPane/
│   │   │   ├── DetailPane.tsx
│   │   │   ├── DetailHeader.tsx
│   │   │   └── index.ts
│   │   └── index.ts
│   │
│   ├── common/                      # Shared components
│   │   ├── Breadcrumbs/
│   │   │   ├── Breadcrumbs.tsx
│   │   │   └── index.ts
│   │   ├── SearchInput/
│   │   │   ├── SearchInput.tsx
│   │   │   └── index.ts
│   │   ├── StatusPill/
│   │   │   ├── StatusPill.tsx
│   │   │   └── index.ts
│   │   ├── EmptyState/
│   │   │   ├── EmptyState.tsx
│   │   │   └── index.ts
│   │   ├── LoadingState/
│   │   │   ├── LoadingState.tsx
│   │   │   ├── Skeleton.tsx
│   │   │   └── index.ts
│   │   └── index.ts
│   │
│   └── forms/                       # Forms module components
│       ├── EventsList/
│       │   ├── EventsList.tsx
│       │   ├── EventItem.tsx
│       │   └── index.ts
│       ├── FormsList/
│       │   ├── FormsList.tsx
│       │   ├── FormItem.tsx
│       │   └── index.ts
│       ├── FormDetail/
│       │   ├── FormDetail.tsx
│       │   ├── FormHeader.tsx
│       │   ├── FormSection.tsx
│       │   ├── ConditionalBlock.tsx
│       │   └── index.ts
│       ├── FormFields/
│       │   ├── FieldWrapper.tsx
│       │   ├── TextField.tsx
│       │   ├── NumberField.tsx
│       │   ├── DateField.tsx
│       │   ├── SelectField.tsx
│       │   ├── RadioField.tsx
│       │   ├── CheckboxField.tsx
│       │   └── index.ts
│       └── index.ts
├── pages/
│   └── forms/
│       ├── FormsLayout.tsx          # 3-column layout wrapper
│       ├── FormsIndex.tsx           # /forms
│       ├── EventsPage.tsx           # /forms/:studyId/events
│       ├── FormsListPage.tsx        # /forms/:studyId/events/:eventId
│       └── FormDetailPage.tsx       # /forms/:studyId/events/:eventId/forms/:formId
├── hooks/
│   ├── useFormsNavigation.ts
│   ├── useFormEditor.ts
│   ├── useMediaQuery.ts
│   └── useKeyboardShortcuts.ts
├── stores/                          # Zustand stores
│   └── formsStore.ts
├── lib/
│   ├── utils.ts                     # cn() and other utilities
│   └── constants.ts
└── styles/
    ├── globals.css                  # Tailwind imports + base styles
    └── tokens.css                   # CSS custom properties (optional)

3. Component Tree

<App>
└── <Router>
    └── <FormsLayout>                         # 3-column shell
        ├── <Sidebar>                          # Left column
        │   ├── <SidebarHeader />              # Logo
        │   ├── <SidebarSection title="Global">
        │   │   ├── <SidebarLink to="/dashboard" icon="Home" />
        │   │   ├── <SidebarLink to="/studies" icon="Folder" />
        │   │   └── <SidebarLink to="/forms" icon="FileText" active />
        │   ├── <StudySelector />              # Dropdown
        │   ├── <SidebarSection title="Configure">
        │   │   └── ...
        │   └── <SidebarUserMenu />
        ├── <ListPane>                         # Middle column
        │   ├── <ListPaneHeader>
        │   │   ├── <Breadcrumbs />
        │   │   └── <SearchInput />
        │   └── <FormsList>
        │       ├── <FormItem selected />
        │       ├── <FormItem />
        │       └── ...
        └── <DetailPane>                       # Right column
            ├── <DetailHeader>
            │   ├── <StatusPill status="draft" />
            │   ├── <h1>Form Title</h1>
            │   ├── <LanguageSelector />
            │   └── <OptionsMenu />
            └── <FormDetail>
                ├── <FormSection title="Section 1">
                │   ├── <TextField />
                │   ├── <SelectField />
                │   └── <ConditionalBlock condition="...">
                │       └── <TextField />
                └── <FormSection title="Section 2">
                    └── ...

4. shadcn/ui Components to Install

# Core components
npx shadcn@latest add button
npx shadcn@latest add input
npx shadcn@latest add label
npx shadcn@latest add select
npx shadcn@latest add checkbox
npx shadcn@latest add radio-group
npx shadcn@latest add textarea
npx shadcn@latest add dropdown-menu
npx shadcn@latest add popover
npx shadcn@latest add dialog
npx shadcn@latest add toast
npx shadcn@latest add skeleton
npx shadcn@latest add separator
npx shadcn@latest add scroll-area
npx shadcn@latest add badge
npx shadcn@latest add avatar
npx shadcn@latest add tooltip

5. Key Implementation Patterns

cn() Utility

// lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Component Composition Pattern

// Example: StatusPill with variants
import { cva, type VariantProps } from 'class-variance-authority';

const statusPillVariants = cva(
  'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
  {
    variants: {
      status: {
        draft: 'bg-slate-100 text-slate-600',
        published: 'bg-green-50 text-green-700',
        locked: 'bg-amber-50 text-amber-700',
        archived: 'bg-slate-50 text-slate-500 border border-slate-200',
      },
      size: {
        sm: 'h-5 text-xs',
        md: 'h-6 text-sm',
      },
    },
    defaultVariants: {
      status: 'draft',
      size: 'sm',
    },
  }
);

interface StatusPillProps extends VariantProps<typeof statusPillVariants> {
  label?: string;
}

export function StatusPill({ status, size, label }: StatusPillProps) {
  const displayLabel = label ?? status ?? 'draft';
  return (
    <span
      className={statusPillVariants({ status, size })}
      aria-label={`Status: ${displayLabel}`}
    >
      {displayLabel.charAt(0).toUpperCase() + displayLabel.slice(1)}
    </span>
  );
}

Responsive Hook

// hooks/useMediaQuery.ts
import { useState, useEffect } from 'react';

export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const media = window.matchMedia(query);
    setMatches(media.matches);

    const listener = (e: MediaQueryListEvent) => setMatches(e.matches);
    media.addEventListener('change', listener);
    return () => media.removeEventListener('change', listener);
  }, [query]);

  return matches;
}

// Convenience hooks
export const useIsMobile = () => useMediaQuery('(max-width: 767px)');
export const useIsTablet = () => useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
export const useIsDesktop = () => useMediaQuery('(min-width: 1024px)');

Keyboard Shortcuts

// hooks/useKeyboardShortcuts.ts
import { useEffect } from 'react';

type ShortcutHandler = () => void;
type Shortcuts = Record<string, ShortcutHandler>;

export function useKeyboardShortcuts(shortcuts: Shortcuts) {
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      const key = [
        e.metaKey || e.ctrlKey ? 'mod' : '',
        e.shiftKey ? 'shift' : '',
        e.altKey ? 'alt' : '',
        e.key.toLowerCase(),
      ]
        .filter(Boolean)
        .join('+');

      if (shortcuts[key]) {
        e.preventDefault();
        shortcuts[key]();
      }
    };

    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [shortcuts]);
}

// Usage
useKeyboardShortcuts({
  'mod+s': () => saveForm(),
  'escape': () => closeModal(),
});

6. Migration Strategy

Phase 1: Setup (Week 1)

  1. Install dependencies

    npm install tailwindcss postcss autoprefixer
    npm install @radix-ui/react-dropdown-menu @radix-ui/react-popover
    npm install class-variance-authority clsx tailwind-merge
    npm install lucide-react  # Icons
    

  2. Configure Tailwind with extended theme

  3. Setup shadcn/ui and install core components

  4. Create utility functions (cn, hooks)

Phase 2: Layout Components (Week 2)

  1. Create AppShell with 3-column layout
  2. Migrate Sidebar to new pattern
  3. Create ListPane and DetailPane shells
  4. Implement responsive behavior

Phase 3: Common Components (Week 3)

  1. StatusPill (replace StatusBadge)
  2. Breadcrumbs (standardize)
  3. SearchInput with icon
  4. EmptyState and LoadingState

Phase 4: Forms Module (Weeks 4-5)

  1. EventsList and FormsLists
  2. FormDetail view
  3. Form fields and sections
  4. Conditional logic UI

Phase 5: Existing Page Migration (Weeks 6-8)

  1. Dashboard - migrate to new patterns
  2. Participants - replace embedded styles
  3. Reports - extract to CSS modules, use new components
  4. ParticipantDetail - full refactor

7. Testing Checklist

Visual

  • [ ] All status variants render correctly
  • [ ] Spacing is consistent (8px grid)
  • [ ] Typography hierarchy is clear
  • [ ] Colors match design tokens

Responsive

  • [ ] Mobile: sidebar drawer works
  • [ ] Tablet: icon rail + collapsible list
  • [ ] Desktop: full 3-column layout
  • [ ] No horizontal scroll at any breakpoint

Accessibility

  • [ ] Focus visible on all interactive elements
  • [ ] Keyboard navigation works
  • [ ] Screen reader announces states
  • [ ] Color contrast passes WCAG AA

Performance

  • [ ] No layout shift on load
  • [ ] Skeleton loaders in place
  • [ ] Transitions are smooth (60fps)
  • [ ] Bundle size is reasonable

8. Migration Mapping

Existing → New Components

Old Pattern New Component
.badge-* classes <StatusPill />
.card global class <Card /> from shadcn
.breadcrumb class <Breadcrumbs />
Embedded <style> tags CSS modules or Tailwind classes
getStatusColor() function StatusPill variants
.loading-container <LoadingState />
.empty-state class <EmptyState />
Inline style objects Tailwind utility classes
.data-table class <DataTable /> component
.stat-card in Reports <StatCard /> component

CSS Variable Mapping

Old Variable New Tailwind Class
var(--primary) text-primary / bg-primary
var(--success) text-status-success
var(--danger) text-status-danger
var(--border) border-border
var(--text-secondary) text-text-secondary
var(--bg-secondary) bg-surface-muted