624 lines
16 KiB
Markdown
624 lines
16 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
'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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
### Static Module Data Contract
|
|
|
|
Static or reserved module pages must not fabricate operational data. If a module does
|
|
not have a Drizzle-backed query/API and real persisted records, render a clear empty or
|
|
not-connected state instead of generated counters, fake workflows, sample records, or
|
|
mock priority/status rows.
|
|
|
|
`modules/shared/components/ModulePage.tsx` is the shared placeholder for these modules.
|
|
Its props should stay descriptive only:
|
|
|
|
```typescript
|
|
type ModulePageProps = {
|
|
title: string;
|
|
eyebrow: string;
|
|
description: string;
|
|
icon: LucideIcon;
|
|
phase: "待接入" | "二期预留" | "三期预留";
|
|
};
|
|
```
|
|
|
|
```typescript
|
|
// Bad: static pages pretending to have live business data.
|
|
<ModulePage
|
|
title="Care"
|
|
metrics={[{ label: "Today", value: "184", detail: "142 done" }]}
|
|
workflows={["Create plan", "Dispatch task"]}
|
|
/>
|
|
|
|
// Good: no fabricated records until a real data source exists.
|
|
<ModulePage
|
|
title="Care"
|
|
eyebrow="Plans and inspections"
|
|
description="Pending connection to care plans, task dispatch, and execution records."
|
|
icon={ClipboardCheck}
|
|
phase="待接入"
|
|
/>
|
|
```
|
|
|
|
When a module becomes real, replace the placeholder with a Server Component that loads
|
|
data from the server boundary and pass only persisted, permission-filtered data into
|
|
client components.
|
|
|
|
### App Shell Tenant and Account Menu Contract
|
|
|
|
The desktop app shell footer is the tenant/account workspace control, not only a sign-out
|
|
surface.
|
|
|
|
- Show a compact organization switcher above the account card.
|
|
- Display both organization name and non-empty `slug`; do not hide or fabricate missing
|
|
tenant identifiers.
|
|
- Switch organizations by calling `POST /api/auth/organization` and then `router.refresh()`;
|
|
do not store the active organization in localStorage.
|
|
- The account card menu opens a user settings dialog for the current account. Profile edits
|
|
call `PATCH /api/account/profile`.
|
|
- OIDC binding UI must reflect real backend capability. If binding records/callbacks are not
|
|
implemented, show an honest not-connected state instead of fake provider accounts.
|
|
- The sidebar nav selected state belongs in a small client component using `usePathname()`;
|
|
keep the rest of `AppShell` server-rendered.
|
|
- Authenticated workspace links should preserve the active organization slug by using
|
|
`modules/shared/lib/workspace-routing.ts`. Sidebar links, breadcrumbs, permission
|
|
redirects, table search/pagination paths, login redirects, and organization switch
|
|
redirects should call `getWorkspaceHref(activeSlug, workspacePath)` instead of hard-coding
|
|
`/app/...` paths.
|
|
- The legacy unscoped `/app/...` paths remain valid fallback paths for accounts without an
|
|
active organization. When a session has an active organization, redirect into
|
|
`/app/{organizationSlug}/...` so operators can see which workspace they are using.
|
|
- Top-level workspace route names such as `dashboard`, `settings`, `elders`, and `beds` are
|
|
reserved path segments, not tenant identifiers. Keep the frontend reserved list in
|
|
`workspace-routing.ts` aligned with backend organization slug validation.
|
|
|
|
### Business Form Defaults Contract
|
|
|
|
Create forms for persisted business records must not prefill required domain fields with
|
|
assumed values. Defaults like age `75`, gender `male`, care level `self-care`, status
|
|
`active`, or "first available role" can turn into real PostgreSQL records when a user
|
|
submits without noticing.
|
|
|
|
Use explicit empty selection states for required business choices, then validate before
|
|
calling the API:
|
|
|
|
```typescript
|
|
// Bad: saves fabricated domain decisions if the user only fills name/email.
|
|
const emptyInput = {
|
|
gender: "male",
|
|
age: 75,
|
|
careLevel: "self-care",
|
|
status: "active",
|
|
};
|
|
|
|
// Good: the user must provide the domain values that will be persisted.
|
|
const emptyInput = {
|
|
gender: "",
|
|
age: "",
|
|
careLevel: "",
|
|
status: "",
|
|
};
|
|
|
|
const genderOptions = [
|
|
{ value: "", label: "Select gender", disabled: true },
|
|
...realGenderOptions,
|
|
];
|
|
```
|
|
|
|
Edit forms may preload values that already exist on the persisted record. Create,
|
|
invite, approve, and assignment forms should require an explicit selection for role,
|
|
organization, status, and other authorization or workflow fields unless the default is
|
|
server-owned and documented as a product rule.
|
|
|
|
### Use Proper Elements
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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`:
|
|
|
|
```javascript
|
|
// next.config.js
|
|
module.exports = {
|
|
images: {
|
|
remotePatterns: [
|
|
{
|
|
protocol: 'https',
|
|
hostname: 'images.example.com',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
```
|
|
|
|
## Command Palette (cmdk)
|
|
|
|
### Basic Implementation
|
|
|
|
```typescript
|
|
'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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
<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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
<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
|