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)¶
-
Install dependencies
-
Configure Tailwind with extended theme
-
Setup shadcn/ui and install core components
-
Create utility functions (cn, hooks)
Phase 2: Layout Components (Week 2)¶
- Create AppShell with 3-column layout
- Migrate Sidebar to new pattern
- Create ListPane and DetailPane shells
- Implement responsive behavior
Phase 3: Common Components (Week 3)¶
- StatusPill (replace StatusBadge)
- Breadcrumbs (standardize)
- SearchInput with icon
- EmptyState and LoadingState
Phase 4: Forms Module (Weeks 4-5)¶
- EventsList and FormsLists
- FormDetail view
- Form fields and sections
- Conditional logic UI
Phase 5: Existing Page Migration (Weeks 6-8)¶
- Dashboard - migrate to new patterns
- Participants - replace embedded styles
- Reports - extract to CSS modules, use new components
- 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 |