feat: initial commit
This commit is contained in:
615
.agents/skills/kiranism-shadcn-dashboard/SKILL.md
Normal file
615
.agents/skills/kiranism-shadcn-dashboard/SKILL.md
Normal file
@@ -0,0 +1,615 @@
|
||||
---
|
||||
name: kiranism-shadcn-dashboard
|
||||
description: |
|
||||
Guide for building features, pages, tables, forms, themes, and navigation in this Next.js 16 shadcn dashboard template. Use this skill whenever the user wants to add a new page, create a feature module, build a data table, add a form, configure navigation items, add a theme, set up RBAC access control, or work with the dashboard's patterns and conventions. Also triggers when adding routes under /dashboard, working with Clerk auth/orgs/billing, creating mock APIs, or modifying the sidebar. Even if the user doesn't mention "dashboard" explicitly — if they're adding UI, pages, or features to this project, use this skill.
|
||||
---
|
||||
|
||||
# Dashboard Development Guide
|
||||
|
||||
This skill encodes the exact patterns and conventions used in this Next.js 16 + shadcn/ui admin dashboard template.
|
||||
|
||||
## Quick Reference: What Goes Where
|
||||
|
||||
| Task | Location |
|
||||
| --------------------- | --------------------------------------- |
|
||||
| New page | `src/app/dashboard/<name>/page.tsx` |
|
||||
| New feature module | `src/features/<name>/` |
|
||||
| Feature components | `src/features/<name>/components/` |
|
||||
| API types | `src/features/<name>/api/types.ts` |
|
||||
| Service layer | `src/features/<name>/api/service.ts` |
|
||||
| Query options | `src/features/<name>/api/queries.ts` |
|
||||
| Mutation options | `src/features/<name>/api/mutations.ts` |
|
||||
| Zod schemas | `src/features/<name>/schemas/<name>.ts` |
|
||||
| Filter/select options | `src/features/<name>/constants/` |
|
||||
| Nav config | `src/config/nav-config.ts` |
|
||||
| Types | `src/types/index.ts` |
|
||||
| Mock data | `src/constants/mock-api-<name>.ts` |
|
||||
| Search params | `src/lib/searchparams.ts` |
|
||||
| Query client | `src/lib/query-client.ts` |
|
||||
| Theme CSS | `src/styles/themes/<name>.css` |
|
||||
| Theme registry | `src/components/themes/theme.config.ts` |
|
||||
| Custom hook | `src/hooks/` |
|
||||
| Icons registry | `src/components/icons.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Feature (End-to-End)
|
||||
|
||||
When a user asks to add a feature (e.g., "add an orders page"), follow these steps in order. Each step below shows the minimal pattern — see reference files for full templates.
|
||||
|
||||
### Step 1: Mock API (`src/constants/mock-api-<name>.ts`)
|
||||
|
||||
See [references/mock-api-guide.md](references/mock-api-guide.md) for the complete template. Key structure:
|
||||
|
||||
```tsx
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { delay } from './mock-api';
|
||||
|
||||
export type Order = {
|
||||
id: number;
|
||||
customer: string;
|
||||
status: string;
|
||||
total: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export const fakeOrders = {
|
||||
records: [] as Order[],
|
||||
initialize() {
|
||||
/* generate with faker */
|
||||
},
|
||||
async getOrders({ page, limit, search, sort }) {
|
||||
/* filter, sort, paginate, return { items, total_items } */
|
||||
},
|
||||
async getOrderById(id: number) {
|
||||
/* find by id */
|
||||
},
|
||||
async createOrder(data) {
|
||||
/* push to records */
|
||||
},
|
||||
async updateOrder(id, data) {
|
||||
/* merge into record */
|
||||
},
|
||||
async deleteOrder(id) {
|
||||
/* filter out */
|
||||
}
|
||||
};
|
||||
fakeOrders.initialize();
|
||||
```
|
||||
|
||||
Every method should call `await delay(800)` to simulate network latency. Use `matchSorter` for search. Return `{ items, total_items }` from list methods.
|
||||
|
||||
### Step 2: API Layer (`src/features/<name>/api/`)
|
||||
|
||||
Each feature has 4 API files: **types** → **service** → **queries** → **mutations**.
|
||||
|
||||
**Types** (`api/types.ts`) — re-export the entity type from mock API, plus filter/response/payload types:
|
||||
|
||||
```tsx
|
||||
export type { Order } from '@/constants/mock-api-orders';
|
||||
export type OrderFilters = { page?: number; limit?: number; search?: string; sort?: string };
|
||||
export type OrdersResponse = { items: Order[]; total_items: number };
|
||||
export type OrderMutationPayload = { customer: string; status: string; total: number };
|
||||
```
|
||||
|
||||
**Service** (`api/service.ts`) — data access layer. One exported function per operation:
|
||||
|
||||
```tsx
|
||||
import { fakeOrders } from '@/constants/mock-api-orders';
|
||||
import type { OrderFilters, OrdersResponse, OrderMutationPayload } from './types';
|
||||
|
||||
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
|
||||
return fakeOrders.getOrders(filters);
|
||||
}
|
||||
export async function getOrderById(id: number) {
|
||||
return fakeOrders.getOrderById(id);
|
||||
}
|
||||
export async function createOrder(data: OrderMutationPayload) {
|
||||
return fakeOrders.createOrder(data);
|
||||
}
|
||||
export async function updateOrder(id: number, data: OrderMutationPayload) {
|
||||
return fakeOrders.updateOrder(id, data);
|
||||
}
|
||||
export async function deleteOrder(id: number) {
|
||||
return fakeOrders.deleteOrder(id);
|
||||
}
|
||||
```
|
||||
|
||||
**Queries** (`api/queries.ts`) — query key factory + query options:
|
||||
|
||||
```tsx
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
import { getOrders, getOrderById } from './service';
|
||||
import type { Order, OrderFilters } from './types';
|
||||
|
||||
export type { Order };
|
||||
|
||||
export const orderKeys = {
|
||||
all: ['orders'] as const,
|
||||
list: (filters: OrderFilters) => [...orderKeys.all, 'list', filters] as const,
|
||||
detail: (id: number) => [...orderKeys.all, 'detail', id] as const
|
||||
};
|
||||
|
||||
export const ordersQueryOptions = (filters: OrderFilters) =>
|
||||
queryOptions({
|
||||
queryKey: orderKeys.list(filters),
|
||||
queryFn: () => getOrders(filters)
|
||||
});
|
||||
|
||||
export const orderByIdOptions = (id: number) =>
|
||||
queryOptions({
|
||||
queryKey: orderKeys.detail(id),
|
||||
queryFn: () => getOrderById(id)
|
||||
});
|
||||
```
|
||||
|
||||
**Mutations** (`api/mutations.ts`) — use `mutationOptions` + `getQueryClient()` (not custom hooks with `useQueryClient()`):
|
||||
|
||||
```tsx
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { createOrder, updateOrder, deleteOrder } from './service';
|
||||
import { orderKeys } from './queries';
|
||||
import type { OrderMutationPayload } from './types';
|
||||
|
||||
export const createOrderMutation = mutationOptions({
|
||||
mutationFn: (data: OrderMutationPayload) => createOrder(data),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const updateOrderMutation = mutationOptions({
|
||||
mutationFn: ({ id, values }: { id: number; values: OrderMutationPayload }) =>
|
||||
updateOrder(id, values),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteOrderMutation = mutationOptions({
|
||||
mutationFn: (id: number) => deleteOrder(id),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
`mutationOptions` is the right abstraction because it works outside React (event handlers, tests, utilities), composes via spread at the call site, and uses `getQueryClient()` which handles both SSR (fresh per request) and client (singleton) correctly. See [references/query-abstractions.md](references/query-abstractions.md) for the full rationale.
|
||||
|
||||
### Step 3: Zod Schema (`src/features/<name>/schemas/<name>.ts`)
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod';
|
||||
|
||||
export const orderSchema = z.object({
|
||||
customer: z.string().min(2, 'Customer name must be at least 2 characters'),
|
||||
status: z.string().min(1, 'Please select a status'),
|
||||
total: z.number({ message: 'Total is required' })
|
||||
});
|
||||
|
||||
export type OrderFormValues = z.infer<typeof orderSchema>;
|
||||
```
|
||||
|
||||
### Step 4: Feature Components
|
||||
|
||||
Create `src/features/<name>/components/` with:
|
||||
|
||||
**Listing page** (server component — `<name>-listing.tsx`):
|
||||
|
||||
```tsx
|
||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import { ordersQueryOptions } from '../api/queries';
|
||||
import { OrderTable, OrderTableSkeleton } from './orders-table';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function OrderListingPage() {
|
||||
const page = searchParamsCache.get('page');
|
||||
const search = searchParamsCache.get('name');
|
||||
const pageLimit = searchParamsCache.get('perPage');
|
||||
const sort = searchParamsCache.get('sort');
|
||||
|
||||
const filters = {
|
||||
page,
|
||||
limit: pageLimit,
|
||||
...(search && { search }),
|
||||
...(sort && { sort })
|
||||
};
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
void queryClient.prefetchQuery(ordersQueryOptions(filters));
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<Suspense fallback={<OrderTableSkeleton />}>
|
||||
<OrderTable />
|
||||
</Suspense>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Table + skeleton** (client component — `orders-table/index.tsx`):
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
|
||||
import { getSortingStateParser } from '@/lib/parsers';
|
||||
import { useDataTable } from '@/hooks/use-data-table';
|
||||
import { DataTable } from '@/components/ui/table/data-table';
|
||||
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ordersQueryOptions } from '../../api/queries';
|
||||
import { columns } from './columns';
|
||||
|
||||
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
|
||||
|
||||
export function OrderTable() {
|
||||
const [params] = useQueryStates({
|
||||
page: parseAsInteger.withDefault(1),
|
||||
perPage: parseAsInteger.withDefault(10),
|
||||
name: parseAsString,
|
||||
sort: getSortingStateParser(columnIds).withDefault([])
|
||||
});
|
||||
|
||||
const filters = {
|
||||
page: params.page,
|
||||
limit: params.perPage,
|
||||
...(params.name && { search: params.name }),
|
||||
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
|
||||
};
|
||||
|
||||
const { data } = useSuspenseQuery(ordersQueryOptions(filters));
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data.items,
|
||||
columns,
|
||||
pageCount: Math.ceil(data.total_items / params.perPage),
|
||||
shallow: true,
|
||||
debounceMs: 500,
|
||||
initialState: { columnPinning: { right: ['actions'] } }
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable table={table}>
|
||||
<DataTableToolbar table={table} />
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderTableSkeleton() {
|
||||
return (
|
||||
<div className='space-y-4 p-4'>
|
||||
<Skeleton className='h-10 w-full' />
|
||||
<Skeleton className='h-96 w-full' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Column definitions** (`orders-table/columns.tsx`):
|
||||
|
||||
Each column needs `id`, `accessorKey` (or `accessorFn`), `header` with `DataTableColumnHeader`, and optionally `meta` for filtering + `enableColumnFilter: true`.
|
||||
|
||||
```tsx
|
||||
export const columns: ColumnDef<Order>[] = [
|
||||
{
|
||||
id: 'customer',
|
||||
accessorKey: 'customer',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title='Customer' />,
|
||||
meta: { label: 'Customer', placeholder: 'Search...', variant: 'text', icon: Icons.text },
|
||||
enableColumnFilter: true
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
|
||||
cell: ({ cell }) => (
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{cell.getValue<string>()}
|
||||
</Badge>
|
||||
),
|
||||
enableColumnFilter: true,
|
||||
meta: { label: 'Status', variant: 'multiSelect', options: STATUS_OPTIONS }
|
||||
},
|
||||
{ id: 'actions', cell: ({ row }) => <CellAction data={row.original} /> }
|
||||
];
|
||||
```
|
||||
|
||||
Filter `meta.variant` options: `text`, `number`, `range`, `date`, `dateRange`, `select`, `multiSelect`, `boolean`. For multiSelect, provide `options: { value, label, icon? }[]`.
|
||||
|
||||
**Cell actions** (`orders-table/cell-action.tsx`):
|
||||
|
||||
Pattern: `DropdownMenu` with edit/delete items + `AlertModal` for delete confirmation + `useMutation` for the delete API call.
|
||||
|
||||
```tsx
|
||||
import { deleteOrderMutation } from '../../api/mutations';
|
||||
|
||||
export const CellAction: React.FC<{ data: Order }> = ({ data }) => {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const deleteMutation = useMutation({
|
||||
...deleteOrderMutation,
|
||||
onSuccess: () => {
|
||||
toast.success('Deleted');
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<AlertModal
|
||||
isOpen={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={() => deleteMutation.mutate(data.id)}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='h-8 w-8 p-0'>
|
||||
<Icons.ellipsis className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/orders/${data.id}`)}>
|
||||
<Icons.edit className='mr-2 h-4 w-4' /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteOpen(true)}>
|
||||
<Icons.trash className='mr-2 h-4 w-4' /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
For **sheet-based editing** (like Users), replace `router.push` with opening a `<FormSheet>` — see the Forms section below.
|
||||
|
||||
### Step 5: Page Route (`src/app/dashboard/<name>/page.tsx`)
|
||||
|
||||
```tsx
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import OrderListingPage from '@/features/orders/components/order-listing';
|
||||
import { searchParamsCache } from '@/lib/searchparams';
|
||||
import type { SearchParams } from 'nuqs/server';
|
||||
|
||||
export const metadata = { title: 'Dashboard: Orders' };
|
||||
type PageProps = { searchParams: Promise<SearchParams> };
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const searchParams = await props.searchParams;
|
||||
searchParamsCache.parse(searchParams);
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
scrollable={false}
|
||||
pageTitle='Orders'
|
||||
pageDescription='Manage your orders.'
|
||||
pageHeaderAction={/* Add button — Link or SheetTrigger */}
|
||||
>
|
||||
<OrderListingPage />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**PageContainer props**: `scrollable`, `pageTitle`, `pageDescription`, `pageHeaderAction` (React node for the top-right button), `infoContent` (help sidebar), `access` + `accessFallback` (RBAC gating).
|
||||
|
||||
**Detail/Edit page** (`src/app/dashboard/<name>/[id]/page.tsx`):
|
||||
|
||||
```tsx
|
||||
import PageContainer from '@/components/layout/page-container';
|
||||
import OrderViewPage from '@/features/orders/components/order-view-page';
|
||||
|
||||
export const metadata = { title: 'Dashboard: Order Details' };
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const { id } = await props.params;
|
||||
return (
|
||||
<PageContainer scrollable pageTitle='Order Details'>
|
||||
<OrderViewPage orderId={id} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**View page component** (client — handles new vs edit):
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { orderByIdOptions } from '../api/queries';
|
||||
import OrderForm from './order-form';
|
||||
|
||||
export default function OrderViewPage({ orderId }: { orderId: string }) {
|
||||
if (orderId === 'new') return <OrderForm initialData={null} pageTitle='Create Order' />;
|
||||
const { data } = useSuspenseQuery(orderByIdOptions(Number(orderId)));
|
||||
if (!data) notFound();
|
||||
return <OrderForm initialData={data} pageTitle='Edit Order' />;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 6: Search Params (`src/lib/searchparams.ts`)
|
||||
|
||||
Add any new filter keys. Existing params: `page`, `perPage`, `name`, `gender`, `category`, `role`, `sort`.
|
||||
|
||||
### Step 7: Navigation (`src/config/nav-config.ts`)
|
||||
|
||||
```tsx
|
||||
{ title: 'Orders', url: '/dashboard/orders', icon: 'product', items: [] }
|
||||
```
|
||||
|
||||
### Step 8: Icons (`src/components/icons.tsx`)
|
||||
|
||||
To register a new icon, import from `@tabler/icons-react` and add to the `Icons` object:
|
||||
|
||||
```tsx
|
||||
import { IconShoppingCart } from '@tabler/icons-react';
|
||||
export const Icons = { /* ...existing */ cart: IconShoppingCart };
|
||||
```
|
||||
|
||||
Never import `@tabler/icons-react` anywhere else. Always use `Icons.keyName`.
|
||||
|
||||
**Existing icon keys** (partial): `dashboard`, `product`, `kanban`, `chat`, `forms`, `user`, `teams`, `billing`, `settings`, `add`, `edit`, `trash`, `search`, `check`, `close`, `clock`, `ellipsis`, `text`, `calendar`, `upload`, `spinner`, `chevronDown/Left/Right/Up`, `sun`, `moon`, `palette`, `pro`, `workspace`, `notification`.
|
||||
|
||||
---
|
||||
|
||||
## Forms
|
||||
|
||||
Forms use **TanStack Form + Zod** with `useAppForm` + `useFormFields<T>()` and `useMutation` for submission. See [references/forms-guide.md](references/forms-guide.md) for all field types, validation strategies, multi-step forms, and advanced patterns.
|
||||
|
||||
### Page Form (Create/Edit on a dedicated route)
|
||||
|
||||
The full pattern is shown in Steps 1-4 above. The key structure:
|
||||
|
||||
1. **Schema** — Zod schema + inferred type in `schemas/<name>.ts`
|
||||
2. **Form component** — `useAppForm({ defaultValues, validators: { onSubmit: schema }, onSubmit })` + `useFormFields<T>()` for typed fields
|
||||
3. **Mutations** — `useMutation({ ...createOrderMutation, onSuccess: () => { toast(); router.push() } })`, spread shared mutation options from `api/mutations.ts` and layer on UI callbacks
|
||||
4. **View page** — client component that checks `id === 'new'` for create vs `useSuspenseQuery(byIdOptions)` for edit
|
||||
|
||||
### Sheet Form (Inline create/edit in a side panel)
|
||||
|
||||
For features where a separate page is overkill (like Users). The sheet manages open state; the form uses a `form` attribute to connect to the sheet footer's submit button.
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
|
||||
export function OrderFormSheet({
|
||||
order,
|
||||
open,
|
||||
onOpenChange
|
||||
}: {
|
||||
order?: Order;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const isEdit = !!order;
|
||||
const mutation = useMutation({
|
||||
...(isEdit ? updateOrderMutation : createOrderMutation),
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
}
|
||||
});
|
||||
const form = useAppForm({
|
||||
defaultValues: { customer: order?.customer ?? '' /* ... */ } as OrderFormValues,
|
||||
validators: { onSubmit: orderSchema },
|
||||
onSubmit: async ({ value }) => {
|
||||
await mutation.mutateAsync(value);
|
||||
}
|
||||
});
|
||||
const { FormTextField, FormSelectField } = useFormFields<OrderFormValues>();
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='flex flex-col'>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit' : 'New'} Order</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<form.AppForm>
|
||||
<form.Form id='order-sheet-form' className='space-y-4'>
|
||||
<FormTextField name='customer' label='Customer' required />
|
||||
<FormSelectField name='status' label='Status' required options={STATUS_OPTIONS} />
|
||||
</form.Form>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type='submit' form='order-sheet-form' disabled={mutation.isPending}>
|
||||
{mutation.isPending ? 'Saving...' : isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For cell actions, add `const [editOpen, setEditOpen] = useState(false)` and render `<OrderFormSheet order={data} open={editOpen} onOpenChange={setEditOpen} />` with a `<DropdownMenuItem onClick={() => setEditOpen(true)}>`. For the page header "Add" button, create a trigger component that manages `open` state and renders the sheet.
|
||||
|
||||
**Available field components** from `useFormFields<T>()`: `FormTextField`, `FormTextareaField`, `FormSelectField`, `FormCheckboxField`, `FormSwitchField`, `FormRadioGroupField`, `FormSliderField`, `FormFileUploadField`.
|
||||
|
||||
---
|
||||
|
||||
## Data Fetching with React Query
|
||||
|
||||
The pattern is: server prefetch → HydrationBoundary → client useSuspenseQuery.
|
||||
|
||||
1. **Server**: `void queryClient.prefetchQuery(options)` — fire-and-forget during SSR streaming
|
||||
2. **Client**: `useSuspenseQuery(options)` — picks up dehydrated data, suspends until resolved
|
||||
3. **HydrationBoundary + dehydrate**: bridges server cache → client cache
|
||||
4. **Suspense fallback**: skeleton shown while data streams
|
||||
|
||||
**Why `useSuspenseQuery` not `useQuery`:** `useQuery` doesn't integrate with Suspense — it shows loading even when data is prefetched. `useSuspenseQuery` picks up the dehydrated pending query. Once cached (within `staleTime: 60s`), subsequent visits are instant.
|
||||
|
||||
**Mutations** use `mutationOptions` + `getQueryClient()` in `mutations.ts`, composed via spread at the call site:
|
||||
|
||||
```tsx
|
||||
// In mutations.ts — shared config
|
||||
export const createOrderMutation = mutationOptions({
|
||||
mutationFn: (data) => createOrder(data),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
|
||||
}
|
||||
});
|
||||
|
||||
// In component — spread + layer UI callbacks
|
||||
const mutation = useMutation({
|
||||
...createOrderMutation,
|
||||
onSuccess: () => toast.success('Created')
|
||||
});
|
||||
```
|
||||
|
||||
See [references/query-abstractions.md](references/query-abstractions.md) for why `mutationOptions`/`queryOptions` are the right abstraction over custom hooks.
|
||||
|
||||
---
|
||||
|
||||
## Navigation & RBAC
|
||||
|
||||
Configure in `src/config/nav-config.ts`. Items are filtered client-side in `src/hooks/use-nav.ts` using Clerk.
|
||||
|
||||
**Access control properties** on nav items:
|
||||
|
||||
- `requireOrg: boolean` — requires active Clerk organization
|
||||
- `permission: string` — requires specific Clerk permission
|
||||
- `role: string` — requires specific Clerk role
|
||||
- `plan: string` — requires subscription plan (server-side)
|
||||
- `feature: string` — requires feature flag (server-side)
|
||||
|
||||
Items without `access` are visible to everyone. All client-side checks are synchronous — no loading states.
|
||||
|
||||
---
|
||||
|
||||
## Themes
|
||||
|
||||
See [references/theming-guide.md](references/theming-guide.md) for the complete guide. Quick steps:
|
||||
|
||||
1. Create `src/styles/themes/<name>.css` with OKLCH color tokens + `@theme inline` block
|
||||
2. Import in `src/styles/theme.css`
|
||||
3. Register in `THEMES` array in `src/components/themes/theme.config.ts`
|
||||
4. (Optional) Add Google Fonts in `src/components/themes/font.config.ts`
|
||||
|
||||
---
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- **`cn()`** for class merging — never concatenate className strings
|
||||
- **Server components by default** — only add `'use client'` when needed
|
||||
- **React Query** — `void prefetchQuery()` on server + `useSuspenseQuery` on client
|
||||
- **API layer** — `types.ts` → `service.ts` → `queries.ts` → `mutations.ts` per feature; `queryOptions`/`mutationOptions` as base abstractions (not custom hooks); `getQueryClient()` in mutations (not `useQueryClient()`); key factories (`entityKeys.all/list/detail`); components never import mock APIs directly
|
||||
- **nuqs** — `searchParamsCache` on server, `useQueryStates` on client with `shallow: true`
|
||||
- **Icons** — only from `@/components/icons`, never from `@tabler/icons-react` directly
|
||||
- **Forms** — `useAppForm` + `useFormFields<T>()` from `@/components/ui/tanstack-form`
|
||||
- **Page headers** — `PageContainer` props, never import `<Heading>` manually
|
||||
- **Sort parser** — use `getSortingStateParser` from `@/lib/parsers` (same parser as `useDataTable`)
|
||||
- **Formatting** — single quotes, JSX single quotes, no trailing comma, 2-space indent
|
||||
@@ -0,0 +1,420 @@
|
||||
# Charts & Analytics Guide
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview Architecture](#overview-architecture)
|
||||
2. [Parallel Routes Pattern](#parallel-routes-pattern)
|
||||
3. [Chart Components](#chart-components)
|
||||
4. [Stats Cards](#stats-cards)
|
||||
5. [Skeleton Loading](#skeleton-loading)
|
||||
6. [Adding a New Chart Section](#adding-a-new-chart-section)
|
||||
|
||||
---
|
||||
|
||||
## Overview Architecture
|
||||
|
||||
The analytics dashboard at `/dashboard/overview` uses **Next.js parallel routes** to load multiple chart sections independently. Each chart slot streams in as its data becomes ready — no waterfall, no blocking.
|
||||
|
||||
**File structure:**
|
||||
|
||||
```
|
||||
src/app/dashboard/overview/
|
||||
├── layout.tsx # Composes all slots into a grid
|
||||
├── @area_stats/
|
||||
│ ├── page.tsx # Async server component (fetches data)
|
||||
│ ├── loading.tsx # Skeleton shown while streaming
|
||||
│ └── error.tsx # Error boundary if fetch fails
|
||||
├── @bar_stats/
|
||||
│ ├── page.tsx
|
||||
│ ├── loading.tsx
|
||||
│ └── error.tsx
|
||||
├── @pie_stats/
|
||||
│ ├── page.tsx
|
||||
│ ├── loading.tsx
|
||||
│ └── error.tsx
|
||||
└── @sales/
|
||||
├── page.tsx
|
||||
├── loading.tsx
|
||||
└── error.tsx
|
||||
|
||||
src/features/overview/components/
|
||||
├── area-graph.tsx # Client chart component
|
||||
├── area-graph-skeleton.tsx # Matching skeleton
|
||||
├── bar-graph.tsx
|
||||
├── bar-graph-skeleton.tsx
|
||||
├── pie-graph.tsx
|
||||
├── pie-graph-skeleton.tsx
|
||||
├── recent-sales.tsx
|
||||
└── recent-sales-skeleton.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Routes Pattern
|
||||
|
||||
### Layout (`layout.tsx`)
|
||||
|
||||
The layout receives each parallel route as a prop and arranges them in a grid:
|
||||
|
||||
```tsx
|
||||
export default function OverviewLayout({
|
||||
sales,
|
||||
pie_stats,
|
||||
bar_stats,
|
||||
area_stats
|
||||
}: {
|
||||
sales: React.ReactNode;
|
||||
pie_stats: React.ReactNode;
|
||||
bar_stats: React.ReactNode;
|
||||
area_stats: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PageContainer pageTitle='Dashboard' pageDescription='Overview analytics.'>
|
||||
{/* Stats cards row */}
|
||||
<div className='grid gap-4 md:grid-cols-2 lg:grid-cols-4'>
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Total Revenue</CardTitle>
|
||||
<Icons.billing className='h-4 w-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>$45,231.89</div>
|
||||
<p className='text-xs text-muted-foreground'>+20.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* ...more stat cards */}
|
||||
</div>
|
||||
|
||||
{/* Charts grid — each slot loads independently */}
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
|
||||
<div className='col-span-4'>{area_stats}</div>
|
||||
<div className='col-span-3'>{sales}</div>
|
||||
<div className='col-span-4'>{bar_stats}</div>
|
||||
<div className='col-span-3'>{pie_stats}</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Slot Page (`@area_stats/page.tsx`)
|
||||
|
||||
Each slot is an async server component that fetches data then renders the chart:
|
||||
|
||||
```tsx
|
||||
import { delay } from '@/constants/mock-api';
|
||||
import { AreaGraph } from '@/features/overview/components/area-graph';
|
||||
|
||||
export default async function AreaStatsPage() {
|
||||
await delay(2000); // Simulates API fetch
|
||||
return <AreaGraph />;
|
||||
}
|
||||
```
|
||||
|
||||
### Slot Loading (`@area_stats/loading.tsx`)
|
||||
|
||||
```tsx
|
||||
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <AreaGraphSkeleton />;
|
||||
}
|
||||
```
|
||||
|
||||
### Slot Error (`@area_stats/error.tsx`)
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
export default function AreaStatsError({ error }: { error: Error }) {
|
||||
return (
|
||||
<Alert variant='destructive'>
|
||||
<Icons.alertCircle className='h-4 w-4' />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Failed to load area stats: {error.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Each slot can fail independently without affecting others.
|
||||
|
||||
---
|
||||
|
||||
## Chart Components
|
||||
|
||||
All chart components are `'use client'` and use **Recharts** wrapped in shadcn's `ChartContainer`.
|
||||
|
||||
### Chart Config
|
||||
|
||||
Every chart defines a config object mapping data keys to labels and theme colors:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
|
||||
const chartConfig = {
|
||||
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
|
||||
mobile: { label: 'Mobile', color: 'var(--chart-2)' }
|
||||
} satisfies ChartConfig;
|
||||
```
|
||||
|
||||
Theme colors `--chart-1` through `--chart-5` are defined in each theme's CSS file and automatically adapt to light/dark mode.
|
||||
|
||||
### Area Chart Example
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from 'recharts';
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Icons } from '@/components/icons';
|
||||
|
||||
const chartData = [
|
||||
{ month: 'January', desktop: 186, mobile: 80 },
|
||||
{ month: 'February', desktop: 305, mobile: 200 }
|
||||
// ...more months
|
||||
];
|
||||
|
||||
const chartConfig = {
|
||||
desktop: { label: 'Desktop', color: 'var(--chart-1)' },
|
||||
mobile: { label: 'Mobile', color: 'var(--chart-2)' }
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function AreaGraph() {
|
||||
return (
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<CardTitle>Area Chart - Stacked</CardTitle>
|
||||
<Badge variant='outline'>
|
||||
<Icons.trendingUp className='mr-1 h-3 w-3' /> +12.5%
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className='aspect-auto h-[250px] w-full'>
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='month'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.slice(0, 3)}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent indicator='dot' />} />
|
||||
<Area
|
||||
dataKey='mobile'
|
||||
type='natural'
|
||||
fill='var(--color-mobile)'
|
||||
stroke='var(--color-mobile)'
|
||||
stackId='a'
|
||||
/>
|
||||
<Area
|
||||
dataKey='desktop'
|
||||
type='natural'
|
||||
fill='var(--color-desktop)'
|
||||
stroke='var(--color-desktop)'
|
||||
stackId='a'
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Bar Chart Pattern
|
||||
|
||||
Same structure, using `BarChart` + `Bar`:
|
||||
|
||||
```tsx
|
||||
<ChartContainer config={chartConfig}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='month' tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey='desktop' fill='var(--color-desktop)' radius={4} />
|
||||
<Bar dataKey='mobile' fill='var(--color-mobile)' radius={4} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
```
|
||||
|
||||
### Pie/Donut Chart Pattern
|
||||
|
||||
```tsx
|
||||
<ChartContainer config={chartConfig}>
|
||||
<PieChart>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
|
||||
<Pie data={chartData} dataKey='visitors' nameKey='browser' innerRadius={30}>
|
||||
<LabelList dataKey='visitors' className='fill-background' />
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stats Cards
|
||||
|
||||
Stats cards are simple server-rendered `Card` components at the top of the layout — no parallel routes needed since they render instantly:
|
||||
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||
<CardTitle className='text-sm font-medium'>Total Revenue</CardTitle>
|
||||
<Icons.billing className='h-4 w-4 text-muted-foreground' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-2xl font-bold'>$45,231.89</div>
|
||||
<p className='text-xs text-muted-foreground'>+20.1% from last month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
For dynamic stats that need data fetching, wrap in their own Suspense boundary or parallel route slot.
|
||||
|
||||
---
|
||||
|
||||
## Skeleton Loading
|
||||
|
||||
Each chart has a matching skeleton component. Pattern:
|
||||
|
||||
```tsx
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function AreaGraphSkeleton() {
|
||||
return (
|
||||
<Card className='@container/card'>
|
||||
<CardHeader>
|
||||
<Skeleton className='h-5 w-[140px]' />
|
||||
<Skeleton className='h-4 w-[80px]' />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className='h-[250px] w-full rounded-md' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Match the skeleton dimensions to the actual chart for smooth visual transitions.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Chart Section
|
||||
|
||||
To add a new chart (e.g., line chart for user growth):
|
||||
|
||||
### 1. Create the chart component
|
||||
|
||||
`src/features/overview/components/line-graph.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
import { Line, LineChart, CartesianGrid, XAxis } from 'recharts';
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent
|
||||
} from '@/components/ui/chart';
|
||||
|
||||
const chartConfig = {
|
||||
users: { label: 'Users', color: 'var(--chart-3)' }
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const chartData = [
|
||||
/* monthly user data */
|
||||
];
|
||||
|
||||
export function LineGraph() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Growth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className='aspect-auto h-[250px] w-full'>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey='month' tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Line dataKey='users' type='monotone' stroke='var(--color-users)' strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create matching skeleton
|
||||
|
||||
`src/features/overview/components/line-graph-skeleton.tsx`
|
||||
|
||||
### 3. Create parallel route slot
|
||||
|
||||
```
|
||||
src/app/dashboard/overview/@line_stats/
|
||||
├── page.tsx → async, fetches data, returns <LineGraph />
|
||||
├── loading.tsx → returns <LineGraphSkeleton />
|
||||
├── error.tsx → error alert
|
||||
└── default.tsx → return null (fallback when route doesn't match)
|
||||
```
|
||||
|
||||
`default.tsx` is required for parallel routes — return `null` or a fallback:
|
||||
|
||||
```tsx
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add slot to layout
|
||||
|
||||
Update `src/app/dashboard/overview/layout.tsx`:
|
||||
|
||||
```tsx
|
||||
export default function OverviewLayout({
|
||||
sales,
|
||||
pie_stats,
|
||||
bar_stats,
|
||||
area_stats,
|
||||
line_stats // ← add new slot
|
||||
}: {
|
||||
/* ...types */
|
||||
}) {
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
|
||||
{/* existing charts */}
|
||||
<div className='col-span-4'>{line_stats}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Available Recharts Components
|
||||
|
||||
Common chart types to use with `ChartContainer`:
|
||||
|
||||
- `AreaChart` + `Area` — filled area charts (stacked or standalone)
|
||||
- `BarChart` + `Bar` — vertical/horizontal bars
|
||||
- `LineChart` + `Line` — line/trend charts
|
||||
- `PieChart` + `Pie` — pie/donut charts
|
||||
- `RadarChart` + `Radar` — radar/spider charts
|
||||
- `RadialBarChart` + `RadialBar` — radial progress bars
|
||||
|
||||
All support `ChartTooltip`, `ChartLegend`, and theme-aware colors via `var(--chart-N)`.
|
||||
@@ -0,0 +1,304 @@
|
||||
# Forms Guide
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Architecture](#architecture)
|
||||
2. [Field Types](#field-types)
|
||||
3. [Usage Patterns](#usage-patterns)
|
||||
4. [Validation Strategies](#validation-strategies)
|
||||
5. [Sheet/Dialog Forms](#sheetdialog-forms)
|
||||
6. [Multi-Step Forms](#multi-step-forms)
|
||||
7. [Advanced Patterns](#advanced-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The form system is built on **TanStack Form + Zod** with a composable field layer.
|
||||
|
||||
**Key files:**
|
||||
|
||||
- `src/components/ui/tanstack-form.tsx` — exports `useAppForm`, `useFormFields<T>()`, composed fields
|
||||
- `src/components/ui/form-context.tsx` — contexts, `createFormField`, structural components
|
||||
- `src/components/forms/fields/*.tsx` — 8 field type implementations
|
||||
|
||||
**Key exports:**
|
||||
|
||||
```tsx
|
||||
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
|
||||
```
|
||||
|
||||
- `useAppForm(config)` — creates a form instance with `defaultValues`, `validators`, `onSubmit`
|
||||
- `useFormFields<T>()` — returns all 8 typed field components with name autocomplete from `T`
|
||||
- `form.AppForm` — context provider wrapper
|
||||
- `form.Form` — `<form>` element that handles submit
|
||||
- `form.SubmitButton` — auto-disabled when form is invalid or submitting
|
||||
- `form.AppField` — low-level render prop for custom fields
|
||||
|
||||
---
|
||||
|
||||
## Field Types
|
||||
|
||||
All fields accept: `name`, `label`, `description`, `required`, `disabled`, `validators`, `listeners`, `className`.
|
||||
|
||||
| Component | Props | Notes |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| `FormTextField` | `type` (text/email/number/password/tel/url), `placeholder`, `min`, `max`, `step`, `maxLength` | For numbers use `type='number'` |
|
||||
| `FormTextareaField` | `placeholder`, `rows`, `maxLength` | Multiline text |
|
||||
| `FormSelectField` | `options: {value, label}[]`, `placeholder` | Single select dropdown |
|
||||
| `FormCheckboxField` | `options?: {value, label}[]` | Single checkbox or multi-checkbox group |
|
||||
| `FormSwitchField` | — | Toggle switch |
|
||||
| `FormRadioGroupField` | `options: {value, label}[]`, `orientation` | Radio button group |
|
||||
| `FormSliderField` | `min`, `max`, `step` | Range slider |
|
||||
| `FormFileUploadField` | `maxSize`, `maxFiles`, `accept` | Drag-and-drop with preview |
|
||||
|
||||
---
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Pattern 1: `useFormFields<T>()` (Recommended)
|
||||
|
||||
Type-safe field components with name autocomplete:
|
||||
|
||||
```tsx
|
||||
const { FormTextField, FormSelectField } = useFormFields<OrderFormValues>();
|
||||
|
||||
<FormTextField name='customer' label='Customer' required placeholder='Name'
|
||||
validators={{ onBlur: z.string().min(2) }} />
|
||||
|
||||
<FormSelectField name='status' label='Status' required options={STATUS_OPTIONS}
|
||||
validators={{ onBlur: z.string().min(1) }} />
|
||||
```
|
||||
|
||||
### Pattern 2: `form.AppField` render prop
|
||||
|
||||
Full control for custom field rendering:
|
||||
|
||||
```tsx
|
||||
<form.AppField name='framework'>
|
||||
{(field) => (
|
||||
<field.FieldSet>
|
||||
<field.Field>
|
||||
<field.TextField label='Framework' />
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
</form.AppField>
|
||||
```
|
||||
|
||||
### Pattern 3: Direct import (no type safety)
|
||||
|
||||
For quick prototyping:
|
||||
|
||||
```tsx
|
||||
import { FormTextField } from '@/components/ui/tanstack-form';
|
||||
<FormTextField name='name' label='Name' />;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Validation Strategies
|
||||
|
||||
### Field-level (recommended for UX)
|
||||
|
||||
```tsx
|
||||
<FormTextField
|
||||
name='email'
|
||||
label='Email'
|
||||
validators={{
|
||||
onBlur: z.string().email('Invalid email') // Validates when field loses focus
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Form-level (catch-all on submit)
|
||||
|
||||
```tsx
|
||||
const form = useAppForm({
|
||||
validators: { onSubmit: orderSchema }, // Validates entire form on submit
|
||||
onSubmit: async ({ value }) => {
|
||||
/* ... */
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Async validation (server-side checks)
|
||||
|
||||
```tsx
|
||||
<FormTextField
|
||||
name='username'
|
||||
label='Username'
|
||||
validators={{
|
||||
onChangeAsync: async ({ value }) => {
|
||||
const exists = await checkUsername(value);
|
||||
return exists ? 'Username taken' : undefined;
|
||||
}
|
||||
}}
|
||||
asyncDebounceMs={500}
|
||||
/>
|
||||
```
|
||||
|
||||
### Linked field validation
|
||||
|
||||
For dependent fields (e.g., confirm password):
|
||||
|
||||
```tsx
|
||||
<FormTextField
|
||||
name='confirmPassword'
|
||||
label='Confirm Password'
|
||||
validators={{
|
||||
onChangeListenTo: ['password'],
|
||||
onChange: ({ value, fieldApi }) => {
|
||||
const password = fieldApi.form.getFieldValue('password');
|
||||
return value !== password ? 'Passwords must match' : undefined;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sheet/Dialog Forms
|
||||
|
||||
The key pattern for forms inside sheets or dialogs: give the `<form.Form>` an `id`, and use that `id` on the submit button's `form` attribute. This allows the submit button to live outside the form element (e.g., in `SheetFooter`).
|
||||
|
||||
```tsx
|
||||
<form.AppForm>
|
||||
<form.Form id='my-sheet-form' className='space-y-4'>
|
||||
{/* fields */}
|
||||
</form.Form>
|
||||
</form.AppForm>;
|
||||
|
||||
{
|
||||
/* In SheetFooter — button is outside the <form> but still submits it */
|
||||
}
|
||||
<SheetFooter>
|
||||
<Button type='submit' form='my-sheet-form'>
|
||||
Save
|
||||
</Button>
|
||||
</SheetFooter>;
|
||||
```
|
||||
|
||||
On success, call `onOpenChange(false)` to close the sheet and `form.reset()` for create forms.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Step Forms
|
||||
|
||||
Use `withFieldGroup` + `useAppForm` with `StepButton`:
|
||||
|
||||
```tsx
|
||||
// Define field groups for each step
|
||||
const Step1 = withFieldGroup({
|
||||
fields: ['name', 'email'],
|
||||
render: ({ form }) => {
|
||||
const { FormTextField } = useFormFields<FormValues>();
|
||||
return (
|
||||
<>
|
||||
<FormTextField name='name' label='Name' />
|
||||
<FormTextField name='email' label='Email' />
|
||||
<form.StepButton direction='next' label='Next' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const Step2 = withFieldGroup({
|
||||
fields: ['address', 'city'],
|
||||
render: ({ form }) => {
|
||||
const { FormTextField } = useFormFields<FormValues>();
|
||||
return (
|
||||
<>
|
||||
<FormTextField name='address' label='Address' />
|
||||
<FormTextField name='city' label='City' />
|
||||
<form.StepButton direction='prev' label='Back' />
|
||||
<form.SubmitButton label='Submit' />
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Use the `useStepper` hook from `src/hooks/use-stepper.tsx` to manage step state.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Nested objects (dot notation)
|
||||
|
||||
```tsx
|
||||
<FormTextField name='address.street' label='Street' />
|
||||
<FormTextField name='address.city' label='City' />
|
||||
```
|
||||
|
||||
### Dynamic array rows
|
||||
|
||||
```tsx
|
||||
<form.AppField name='items' mode='array'>
|
||||
{(field) => (
|
||||
<>
|
||||
{field.state.value.map((_, i) => (
|
||||
<form.AppField key={i} name={`items[${i}].name`}>
|
||||
{(subField) => <subField.TextField label={`Item ${i + 1}`} />}
|
||||
</form.AppField>
|
||||
))}
|
||||
<Button onClick={() => field.pushValue({ name: '' })}>Add Row</Button>
|
||||
</>
|
||||
)}
|
||||
</form.AppField>
|
||||
```
|
||||
|
||||
### Side effects with listeners
|
||||
|
||||
```tsx
|
||||
<FormSelectField
|
||||
name='country'
|
||||
label='Country'
|
||||
options={countryOptions}
|
||||
listeners={{
|
||||
onChange: ({ value }) => {
|
||||
// Reset city when country changes
|
||||
form.setFieldValue('city', '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Custom field with `form.AppField`
|
||||
|
||||
For fields not covered by the built-in 8 types:
|
||||
|
||||
```tsx
|
||||
<form.AppField name='color'>
|
||||
{(field) => (
|
||||
<field.FieldSet>
|
||||
<Label>Pick a color</Label>
|
||||
<field.Field>
|
||||
<input
|
||||
type='color'
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
</field.Field>
|
||||
<field.FieldError />
|
||||
</field.FieldSet>
|
||||
)}
|
||||
</form.AppField>
|
||||
```
|
||||
|
||||
### Form-level errors
|
||||
|
||||
Display errors that apply to the whole form (e.g., server errors):
|
||||
|
||||
```tsx
|
||||
import { FormErrors } from '@/components/ui/form-context';
|
||||
|
||||
<form.AppForm>
|
||||
<form.Form>
|
||||
<FormErrors /> {/* Renders form-level validation errors */}
|
||||
{/* fields... */}
|
||||
</form.Form>
|
||||
</form.AppForm>;
|
||||
```
|
||||
@@ -0,0 +1,255 @@
|
||||
# Mock API Guide
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Structure](#structure)
|
||||
2. [Full Template](#full-template)
|
||||
3. [Key Patterns](#key-patterns)
|
||||
4. [Integrating with React Query](#integrating-with-react-query)
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
|
||||
Each mock API file lives in `src/constants/mock-api-<name>.ts` and is a self-contained in-memory database. It uses:
|
||||
|
||||
- **faker** for generating sample data
|
||||
- **match-sorter** for fuzzy search across fields
|
||||
- **delay** (from `./mock-api`) to simulate network latency
|
||||
|
||||
The `delay` function is exported from `src/constants/mock-api.ts`:
|
||||
|
||||
```tsx
|
||||
export async function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Template
|
||||
|
||||
```tsx
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { delay } from './mock-api';
|
||||
|
||||
// 1. Define the entity type
|
||||
export type Order = {
|
||||
id: number;
|
||||
customer: string;
|
||||
email: string;
|
||||
status: string;
|
||||
total: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// 2. Create the fake database object
|
||||
export const fakeOrders = {
|
||||
records: [] as Order[],
|
||||
|
||||
// 3. Initialize with faker data
|
||||
initialize() {
|
||||
const statuses = ['pending', 'processing', 'completed', 'cancelled'];
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
this.records.push({
|
||||
id: i,
|
||||
customer: faker.person.fullName(),
|
||||
email: faker.internet.email(),
|
||||
status: faker.helpers.arrayElement(statuses),
|
||||
total: parseFloat(faker.commerce.price({ min: 10, max: 500 })),
|
||||
created_at: faker.date.between({ from: '2023-01-01', to: Date.now() }).toISOString(),
|
||||
updated_at: faker.date.recent().toISOString()
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 4. Get all with optional search (used internally)
|
||||
async getAll({ search }: { search?: string } = {}) {
|
||||
let items = [...this.records];
|
||||
if (search) {
|
||||
items = matchSorter(items, search, {
|
||||
keys: ['customer', 'email']
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
|
||||
// 5. Paginated list with filtering and sorting
|
||||
async getOrders(params: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
statuses?: string;
|
||||
sort?: string;
|
||||
}) {
|
||||
await delay(800);
|
||||
const { page = 1, limit = 10, search, statuses, sort } = params;
|
||||
|
||||
let items = await this.getAll({ search });
|
||||
|
||||
// Filter by comma-separated values
|
||||
if (statuses) {
|
||||
const statusList = statuses.split('.');
|
||||
items = items.filter((item) => statusList.includes(item.status));
|
||||
}
|
||||
|
||||
// Sort by column
|
||||
if (sort) {
|
||||
const parsedSort = JSON.parse(sort) as { id: string; desc: boolean }[];
|
||||
if (parsedSort.length > 0) {
|
||||
const { id, desc } = parsedSort[0];
|
||||
items.sort((a, b) => {
|
||||
const aVal = a[id as keyof Order];
|
||||
const bVal = b[id as keyof Order];
|
||||
if (aVal < bVal) return desc ? 1 : -1;
|
||||
if (aVal > bVal) return desc ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const total_items = items.length;
|
||||
items = items.slice((page - 1) * limit, page * limit);
|
||||
|
||||
return { items, total_items };
|
||||
},
|
||||
|
||||
// 6. Get single record by ID
|
||||
async getOrderById(id: number) {
|
||||
await delay(800);
|
||||
return this.records.find((r) => r.id === id) || null;
|
||||
},
|
||||
|
||||
// 7. Create
|
||||
async createOrder(data: Omit<Order, 'id' | 'created_at' | 'updated_at'>) {
|
||||
await delay(800);
|
||||
const newRecord: Order = {
|
||||
...data,
|
||||
id: this.records.length + 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
this.records.push(newRecord);
|
||||
return newRecord;
|
||||
},
|
||||
|
||||
// 8. Update
|
||||
async updateOrder(id: number, data: Partial<Order>) {
|
||||
await delay(800);
|
||||
const idx = this.records.findIndex((r) => r.id === id);
|
||||
if (idx === -1) return null;
|
||||
this.records[idx] = {
|
||||
...this.records[idx],
|
||||
...data,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
return this.records[idx];
|
||||
},
|
||||
|
||||
// 9. Delete
|
||||
async deleteOrder(id: number) {
|
||||
await delay(800);
|
||||
this.records = this.records.filter((r) => r.id !== id);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 10. Auto-initialize on import
|
||||
fakeOrders.initialize();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Search with match-sorter
|
||||
|
||||
Always specify which fields to search across:
|
||||
|
||||
```tsx
|
||||
matchSorter(items, search, { keys: ['customer', 'email', 'status'] });
|
||||
```
|
||||
|
||||
### Comma-separated filter values
|
||||
|
||||
For multi-select filters (roles, statuses), the URL param uses `.` as delimiter:
|
||||
|
||||
```tsx
|
||||
if (statuses) {
|
||||
const list = statuses.split('.');
|
||||
items = items.filter((item) => list.includes(item.status));
|
||||
}
|
||||
```
|
||||
|
||||
### Computed column sorting
|
||||
|
||||
When a table has a computed column (e.g., combining first_name + last_name into "name"), handle it in the sort logic:
|
||||
|
||||
```tsx
|
||||
if (id === 'name') {
|
||||
const aName = `${a.first_name} ${a.last_name}`;
|
||||
const bName = `${b.first_name} ${b.last_name}`;
|
||||
return desc ? bName.localeCompare(aName) : aName.localeCompare(bName);
|
||||
}
|
||||
```
|
||||
|
||||
### Return shape
|
||||
|
||||
List methods must return `{ items, total_items }` (or `{ products, total }` etc. — match the query option expectations). The total is the count **before** pagination, used for `pageCount` calculation.
|
||||
|
||||
---
|
||||
|
||||
## Integrating with the API Layer
|
||||
|
||||
The mock API is only imported in `service.ts`. Queries and components import from the service and types files:
|
||||
|
||||
```
|
||||
mock-api-orders.ts → api/service.ts → api/queries.ts → components
|
||||
(data source) (data access) (key factory + (useSuspenseQuery
|
||||
queryOptions) + useMutation)
|
||||
```
|
||||
|
||||
**service.ts** imports from the mock API:
|
||||
|
||||
```tsx
|
||||
import { fakeOrders } from '@/constants/mock-api-orders';
|
||||
import type { OrderFilters, OrdersResponse } from './types';
|
||||
|
||||
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
|
||||
return fakeOrders.getOrders(filters);
|
||||
}
|
||||
export async function createOrder(data: OrderMutationPayload) {
|
||||
return fakeOrders.createOrder(data);
|
||||
}
|
||||
```
|
||||
|
||||
**queries.ts** imports from service, uses key factories:
|
||||
|
||||
```tsx
|
||||
import { getOrders } from './service';
|
||||
import type { OrderFilters } from './types';
|
||||
|
||||
export const orderKeys = {
|
||||
all: ['orders'] as const,
|
||||
list: (filters: OrderFilters) => [...orderKeys.all, 'list', filters] as const,
|
||||
detail: (id: number) => [...orderKeys.all, 'detail', id] as const
|
||||
};
|
||||
|
||||
export const ordersQueryOptions = (filters: OrderFilters) =>
|
||||
queryOptions({ queryKey: orderKeys.list(filters), queryFn: () => getOrders(filters) });
|
||||
```
|
||||
|
||||
**Mutations** in components use service functions + key factories:
|
||||
|
||||
```tsx
|
||||
import { createOrder } from '../api/service';
|
||||
import { orderKeys } from '../api/queries';
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data) => createOrder(data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: orderKeys.all })
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
# TanStack Query Abstractions (v5)
|
||||
|
||||
The core insight: **`queryOptions` and `mutationOptions` are the right abstraction — not custom hooks.**
|
||||
|
||||
---
|
||||
|
||||
## Query Abstraction
|
||||
|
||||
### The Pattern
|
||||
|
||||
```ts
|
||||
// queries/invoice.ts
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
export function invoiceOptions(id: number) {
|
||||
return queryOptions({
|
||||
queryKey: ['invoice', id],
|
||||
queryFn: () => fetchInvoice(id)
|
||||
});
|
||||
}
|
||||
|
||||
export function invoiceListOptions(filters: InvoiceFilters) {
|
||||
return queryOptions({
|
||||
queryKey: ['invoices', filters],
|
||||
queryFn: () => fetchInvoices(filters),
|
||||
staleTime: 30_000
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Usage — always compose at the call site
|
||||
|
||||
```ts
|
||||
// basic
|
||||
const { data } = useQuery(invoiceOptions(id));
|
||||
|
||||
// with suspense — same options, different hook
|
||||
const { data } = useSuspenseQuery(invoiceOptions(id));
|
||||
|
||||
// with extra options spread on top — full type inference, no TS pain
|
||||
const { data } = useQuery({
|
||||
...invoiceOptions(id),
|
||||
select: (invoice) => invoice.createdAt, // data infers as string | undefined
|
||||
enabled: !!id
|
||||
});
|
||||
|
||||
// prefetch in a route loader (works outside React — this is why hooks are wrong)
|
||||
await queryClient.prefetchQuery(invoiceOptions(id));
|
||||
|
||||
// read from cache imperatively — queryKey is typed via DataTag symbol
|
||||
const invoice = queryClient.getQueryData(invoiceOptions(id).queryKey);
|
||||
|
||||
// invalidate
|
||||
queryClient.invalidateQueries({ queryKey: invoiceOptions(id).queryKey });
|
||||
```
|
||||
|
||||
### Why NOT a custom hook
|
||||
|
||||
Custom hooks like `useInvoice(id)` have three critical problems:
|
||||
|
||||
1. **Hooks only work in components/hooks** — but queries are now used in route loaders, server prefetching, event handlers, and server components. `queryOptions` is just a plain function — works anywhere.
|
||||
2. **They share logic, not configuration** — what you actually want to share is the `queryKey` + `queryFn` config. Hooks are the wrong primitive for that.
|
||||
3. **They lock you to one hook** — you can't use `useInvoice()` with `useSuspenseQuery`, `useQueries`, or imperative `queryClient` methods.
|
||||
|
||||
### Why NOT `UseQueryOptions` type directly
|
||||
|
||||
```ts
|
||||
// BAD — data becomes unknown
|
||||
function useInvoice(id: number, options?: Partial<UseQueryOptions>) { ... }
|
||||
|
||||
// STILL BAD — select breaks with TS error
|
||||
function useInvoice(id: number, options?: Partial<UseQueryOptions<Invoice>>) { ... }
|
||||
// select: (invoice) => invoice.createdAt
|
||||
// Error: Type 'string' is not assignable to type 'Invoice'
|
||||
```
|
||||
|
||||
`queryOptions` solves this via a `DataTag` symbol on the queryKey — full inference, zero manual generics.
|
||||
|
||||
### Custom hooks are still fine on top
|
||||
|
||||
If a component always uses the same composition, a hook is fine — but build it _on top of_ `queryOptions`:
|
||||
|
||||
```ts
|
||||
// OK — hook built on queryOptions
|
||||
function useInvoice(id: number) {
|
||||
return useQuery(invoiceOptions(id));
|
||||
}
|
||||
|
||||
// OK — hook that adds per-feature defaults
|
||||
function useInvoiceWithSuspense(id: number) {
|
||||
return useSuspenseQuery(invoiceOptions(id));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mutation Abstraction
|
||||
|
||||
### The Pattern
|
||||
|
||||
```ts
|
||||
// mutations/invoice.ts
|
||||
import { mutationOptions } from '@tanstack/react-query';
|
||||
import { getQueryClient } from '@/lib/query-client';
|
||||
|
||||
export const createInvoiceMutation = mutationOptions({
|
||||
mutationFn: (data: CreateInvoiceInput) => createInvoice(data),
|
||||
onSuccess: () => {
|
||||
getQueryClient().invalidateQueries({ queryKey: ['invoices'] });
|
||||
}
|
||||
});
|
||||
|
||||
export const updateInvoiceMutation = mutationOptions({
|
||||
mutationFn: ({ id, ...data }: UpdateInvoiceInput) => updateInvoice(id, data),
|
||||
onSuccess: (updated) => {
|
||||
const qc = getQueryClient();
|
||||
qc.setQueryData(invoiceOptions(updated.id).queryKey, updated);
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
> **Note on queryClient**: Import `getQueryClient()` directly — do NOT pass `queryClient` as a function argument. The `getQueryClient()` pattern handles both SSR (fresh per request) and client (singleton) correctly.
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
// basic
|
||||
const { mutate } = useMutation(createInvoiceMutation);
|
||||
|
||||
// composed — add per-usage callbacks on top
|
||||
const { mutate } = useMutation({
|
||||
...createInvoiceMutation,
|
||||
onError: (err) => toast.error(err.message),
|
||||
onSuccess: (data) => {
|
||||
// this runs AFTER the shared onSuccess above
|
||||
router.push(`/invoices/${data.id}`);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules Summary
|
||||
|
||||
| Rule | Reason |
|
||||
| ------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Use `queryOptions()` not custom hooks as the base abstraction | Works everywhere — loaders, server, imperative calls |
|
||||
| Keep options factories lean — no extra config params | Best abstractions are not configurable |
|
||||
| Compose extra options at the call site via spread | Full TS inference without manual generics |
|
||||
| Import `getQueryClient()` in mutation files | Handles SSR/client correctly without prop drilling |
|
||||
| Co-locate `queryKey` inside `queryOptions` | Typed key reuse in `invalidateQueries`, `setQueryData`, `getQueryData` |
|
||||
| Custom hooks are fine — but built ON TOP of `queryOptions` | Hooks for component convenience, `queryOptions` for sharing config |
|
||||
@@ -0,0 +1,180 @@
|
||||
# Theme Creation Guide
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Create Theme CSS](#1-create-theme-css)
|
||||
2. [Import Theme](#2-import-theme)
|
||||
3. [Register Theme](#3-register-theme)
|
||||
4. [Add Custom Fonts](#4-add-custom-fonts-optional)
|
||||
5. [Set as Default](#5-set-as-default-optional)
|
||||
6. [Required Tokens](#required-tokens)
|
||||
7. [Color Format Reference](#color-format-reference)
|
||||
|
||||
---
|
||||
|
||||
## 1. Create Theme CSS
|
||||
|
||||
Create `src/styles/themes/<name>.css`:
|
||||
|
||||
```css
|
||||
/* Light mode */
|
||||
[data-theme='your-theme'] {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(...);
|
||||
--card-foreground: oklch(...);
|
||||
--popover: oklch(...);
|
||||
--popover-foreground: oklch(...);
|
||||
--primary: oklch(...);
|
||||
--primary-foreground: oklch(...);
|
||||
--secondary: oklch(...);
|
||||
--secondary-foreground: oklch(...);
|
||||
--muted: oklch(...);
|
||||
--muted-foreground: oklch(...);
|
||||
--accent: oklch(...);
|
||||
--accent-foreground: oklch(...);
|
||||
--destructive: oklch(...);
|
||||
--destructive-foreground: oklch(...);
|
||||
--border: oklch(...);
|
||||
--input: oklch(...);
|
||||
--ring: oklch(...);
|
||||
--chart-1: oklch(...);
|
||||
--chart-2: oklch(...);
|
||||
--chart-3: oklch(...);
|
||||
--chart-4: oklch(...);
|
||||
--chart-5: oklch(...);
|
||||
--sidebar: oklch(...);
|
||||
--sidebar-foreground: oklch(...);
|
||||
--sidebar-primary: oklch(...);
|
||||
--sidebar-primary-foreground: oklch(...);
|
||||
--sidebar-accent: oklch(...);
|
||||
--sidebar-accent-foreground: oklch(...);
|
||||
--sidebar-border: oklch(...);
|
||||
--sidebar-ring: oklch(...);
|
||||
--font-sans: 'Font Name', sans-serif;
|
||||
--font-mono: 'Mono Font', monospace;
|
||||
--radius: 0.5rem;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme='your-theme'].dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
/* ... all tokens with dark values */
|
||||
}
|
||||
|
||||
/* Tailwind integration (required) */
|
||||
[data-theme='your-theme'] {
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-serif: var(--font-serif);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Import Theme
|
||||
|
||||
Add to `src/styles/theme.css`:
|
||||
|
||||
```css
|
||||
@import './themes/your-theme.css';
|
||||
```
|
||||
|
||||
## 3. Register Theme
|
||||
|
||||
Add to `THEMES` array in `src/components/themes/theme.config.ts`:
|
||||
|
||||
```typescript
|
||||
{ name: 'Your Theme', value: 'your-theme' }
|
||||
```
|
||||
|
||||
The `value` must exactly match the `data-theme` attribute in your CSS.
|
||||
|
||||
## 4. Add Custom Fonts (Optional)
|
||||
|
||||
Only if using a Google Font not already loaded.
|
||||
|
||||
In `src/components/themes/font.config.ts`:
|
||||
|
||||
```typescript
|
||||
import { Your_Font } from 'next/font/google';
|
||||
|
||||
const fontYourName = Your_Font({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '700'],
|
||||
variable: '--font-your-name'
|
||||
});
|
||||
|
||||
export const fontVariables = cn(
|
||||
// ... existing fonts
|
||||
fontYourName.variable
|
||||
);
|
||||
```
|
||||
|
||||
In your theme CSS, use the font's **display name** (not the CSS variable):
|
||||
|
||||
```css
|
||||
--font-sans: 'Your Font', sans-serif;
|
||||
```
|
||||
|
||||
## 5. Set as Default (Optional)
|
||||
|
||||
In `src/components/themes/theme.config.ts`:
|
||||
|
||||
```typescript
|
||||
export const DEFAULT_THEME = 'your-theme';
|
||||
```
|
||||
|
||||
## Required Tokens
|
||||
|
||||
Minimum required: `--background`, `--foreground`, `--card` & `--card-foreground`, `--popover` & `--popover-foreground`, `--primary` & `--primary-foreground`, `--secondary` & `--secondary-foreground`, `--muted` & `--muted-foreground`, `--accent` & `--accent-foreground`, `--destructive` & `--destructive-foreground`, `--border`, `--input`, `--ring`, `--radius`.
|
||||
|
||||
Optional: `--chart-*`, `--sidebar-*`, `--font-*`, `--shadow-*`, `--tracking-normal`, `--spacing`.
|
||||
|
||||
## Color Format Reference
|
||||
|
||||
OKLCH: `oklch(lightness chroma hue)`
|
||||
|
||||
- Lightness: 0-1 (0=black, 1=white)
|
||||
- Chroma: 0+ (0=gray, higher=saturated)
|
||||
- Hue: 0-360 (0=red, 120=green, 240=blue)
|
||||
|
||||
See `src/styles/themes/claude.css` for a complete example.
|
||||
Reference in New Issue
Block a user