@kode4/react-foundation 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN-SYSTEM.md +190 -0
- package/INSTRUCTIONS.md +694 -0
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/errors.d.ts +24 -0
- package/forms/Form/Form.d.ts +30 -0
- package/forms/Form/index.d.ts +1 -0
- package/forms/index.d.ts +1 -0
- package/hooks/index.d.ts +1 -0
- package/hooks/useMediaQuery.d.ts +8 -0
- package/index.d.ts +24 -0
- package/index.js +1679 -0
- package/index.js.map +1 -0
- package/internal/env.d.ts +1 -0
- package/internal/registry.d.ts +3 -0
- package/internal/type-utils.d.ts +6 -0
- package/layout/AppLayout/AppLayout.d.ts +18 -0
- package/layout/AppLayout/index.d.ts +1 -0
- package/layout/CenteredLayout/CenteredLayout.d.ts +12 -0
- package/layout/CenteredLayout/index.d.ts +1 -0
- package/layout/Columns/Columns.d.ts +35 -0
- package/layout/Columns/index.d.ts +1 -0
- package/layout/Container/Container.d.ts +5 -0
- package/layout/Container/index.d.ts +1 -0
- package/layout/DashboardGrid/DashboardGrid.d.ts +37 -0
- package/layout/DashboardGrid/index.d.ts +1 -0
- package/layout/FillHeight/FillHeight.d.ts +16 -0
- package/layout/FillHeight/index.d.ts +1 -0
- package/layout/Footer/Footer.d.ts +5 -0
- package/layout/Footer/index.d.ts +1 -0
- package/layout/Header/Header.d.ts +13 -0
- package/layout/Header/index.d.ts +1 -0
- package/layout/SideBar/SideBar.d.ts +6 -0
- package/layout/SideBar/index.d.ts +1 -0
- package/layout/SiteLayout/SiteLayout.d.ts +27 -0
- package/layout/SiteLayout/index.d.ts +1 -0
- package/layout/SkipLink/SkipLink.d.ts +9 -0
- package/layout/SkipLink/index.d.ts +1 -0
- package/layout/TopBar/TopBar.d.ts +32 -0
- package/layout/TopBar/index.d.ts +1 -0
- package/layout/index.d.ts +12 -0
- package/package.json +52 -0
- package/query/index.d.ts +1 -0
- package/query/sort.d.ts +19 -0
- package/router/DefaultErrorBoundary.d.ts +5 -0
- package/router/TypedLink/TypedLink.d.ts +22 -0
- package/router/TypedLink/index.d.ts +1 -0
- package/router/adapter.d.ts +27 -0
- package/router/chain.d.ts +17 -0
- package/router/context.d.ts +24 -0
- package/router/createAppRouter.d.ts +15 -0
- package/router/defineRoute.d.ts +11 -0
- package/router/index.d.ts +11 -0
- package/router/menu.d.ts +51 -0
- package/router/pathTo.d.ts +21 -0
- package/router/types.d.ts +72 -0
- package/router/useRoute.d.ts +22 -0
- package/style.css +3 -0
- package/theme/ModeToggle/ModeToggle.d.ts +6 -0
- package/theme/ModeToggle/index.d.ts +1 -0
- package/theme/ThemeProvider/ThemeProvider.d.ts +25 -0
- package/theme/ThemeProvider/index.d.ts +1 -0
- package/theme/Toaster/Toaster.d.ts +9 -0
- package/theme/Toaster/index.d.ts +2 -0
- package/theme/Toaster/toast.d.ts +38 -0
- package/theme/index.d.ts +3 -0
- package/ui/Accordion/Accordion.d.ts +7 -0
- package/ui/Accordion/index.d.ts +1 -0
- package/ui/Alert/Alert.d.ts +11 -0
- package/ui/Alert/index.d.ts +1 -0
- package/ui/AlertDialog/AlertDialog.d.ts +19 -0
- package/ui/AlertDialog/index.d.ts +1 -0
- package/ui/Avatar/Avatar.d.ts +7 -0
- package/ui/Avatar/index.d.ts +1 -0
- package/ui/AwaitErrorBlock/AwaitErrorBlock.d.ts +11 -0
- package/ui/AwaitErrorBlock/index.d.ts +1 -0
- package/ui/Badge/Badge.d.ts +10 -0
- package/ui/Badge/index.d.ts +1 -0
- package/ui/Button/Button.d.ts +13 -0
- package/ui/Button/index.d.ts +1 -0
- package/ui/Card/Card.d.ts +11 -0
- package/ui/Card/index.d.ts +1 -0
- package/ui/Checkbox/Checkbox.d.ts +5 -0
- package/ui/Checkbox/index.d.ts +1 -0
- package/ui/Command/Command.d.ts +19 -0
- package/ui/Command/index.d.ts +1 -0
- package/ui/Dialog/Dialog.d.ts +17 -0
- package/ui/Dialog/index.d.ts +1 -0
- package/ui/DropdownMenu/DropdownMenu.d.ts +28 -0
- package/ui/DropdownMenu/index.d.ts +1 -0
- package/ui/ErrorBlock/ErrorBlock.d.ts +21 -0
- package/ui/ErrorBlock/index.d.ts +1 -0
- package/ui/Input/Input.d.ts +4 -0
- package/ui/Input/index.d.ts +1 -0
- package/ui/Label/Label.d.ts +5 -0
- package/ui/Label/index.d.ts +1 -0
- package/ui/Pagination/Pagination.d.ts +17 -0
- package/ui/Pagination/index.d.ts +1 -0
- package/ui/Popover/Popover.d.ts +7 -0
- package/ui/Popover/index.d.ts +1 -0
- package/ui/Progress/Progress.d.ts +16 -0
- package/ui/Progress/index.d.ts +1 -0
- package/ui/RadioGroup/RadioGroup.d.ts +7 -0
- package/ui/RadioGroup/index.d.ts +1 -0
- package/ui/Select/Select.d.ts +16 -0
- package/ui/Select/index.d.ts +1 -0
- package/ui/Separator/Separator.d.ts +5 -0
- package/ui/Separator/index.d.ts +1 -0
- package/ui/Sheet/Sheet.d.ts +17 -0
- package/ui/Sheet/index.d.ts +1 -0
- package/ui/Skeleton/Skeleton.d.ts +4 -0
- package/ui/Skeleton/index.d.ts +1 -0
- package/ui/Spinner/Spinner.d.ts +35 -0
- package/ui/Spinner/index.d.ts +1 -0
- package/ui/Switch/Switch.d.ts +5 -0
- package/ui/Switch/index.d.ts +1 -0
- package/ui/Table/Table.d.ts +11 -0
- package/ui/Table/index.d.ts +1 -0
- package/ui/Tabs/Tabs.d.ts +8 -0
- package/ui/Tabs/index.d.ts +1 -0
- package/ui/Textarea/Textarea.d.ts +4 -0
- package/ui/Textarea/index.d.ts +1 -0
- package/ui/Tooltip/Tooltip.d.ts +7 -0
- package/ui/Tooltip/index.d.ts +1 -0
- package/ui/index.d.ts +29 -0
- package/utils.d.ts +8 -0
package/INSTRUCTIONS.md
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
# Instructions — building apps with `@kode4/react-foundation`
|
|
2
|
+
|
|
3
|
+
This guide is written for someone (human or AI agent) **consuming** the library
|
|
4
|
+
to build an application. It captures the patterns, mental model, and idioms the
|
|
5
|
+
library expects, with copy-pasteable examples. If you follow these patterns the
|
|
6
|
+
app will be idiomatic, type-safe, and themeable.
|
|
7
|
+
|
|
8
|
+
> **Companion docs.** `DESIGN-SYSTEM.md` is the theming/token contract.
|
|
9
|
+
> `roadmap.md` lists planned components. `CLAUDE.md` is the primer for working
|
|
10
|
+
> *inside* the library repo (different audience — internal conventions). This
|
|
11
|
+
> file is the *consumer* contract and must be kept in sync as the library grows.
|
|
12
|
+
|
|
13
|
+
> **Import paths.** A published consumer imports from the package name and loads
|
|
14
|
+
> the stylesheet once:
|
|
15
|
+
>
|
|
16
|
+
> ```ts
|
|
17
|
+
> import { Button, defineRoute, useRoute, ThemeProvider } from '@kode4/react-foundation';
|
|
18
|
+
> import '@kode4/react-foundation/styles.css'; // once, near the app root
|
|
19
|
+
> ```
|
|
20
|
+
>
|
|
21
|
+
> Inside *this* repo the demo consumes the same barrel via the local alias
|
|
22
|
+
> `@/lib` (and the barrel side-effect-imports the CSS, so no separate style
|
|
23
|
+
> import is needed locally). Every example below uses `@kode4/react-foundation`;
|
|
24
|
+
> substitute `@/lib` when working in-repo.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 1. Mental model (read this first)
|
|
29
|
+
|
|
30
|
+
The library is three things behind one barrel:
|
|
31
|
+
|
|
32
|
+
1. **A typed router** — a thin layer over React Router DOM v7's data router. You
|
|
33
|
+
declare each route once with `defineRoute` (path, Zod schemas for
|
|
34
|
+
params/search, a loader, the component, menu/tabs metadata). From those
|
|
35
|
+
declarations the library derives the router config and a registry that powers
|
|
36
|
+
typed links and menus. **Routes are the single source of truth.**
|
|
37
|
+
2. **A themed UI component library** — 30+ components (shadcn/ui as the design
|
|
38
|
+
blueprint) built on Radix primitives, styled entirely through CSS variables.
|
|
39
|
+
3. **A design system** — semantic design tokens with light/dark mode; everything
|
|
40
|
+
themeable by re-declaring CSS variables, no rebuild.
|
|
41
|
+
|
|
42
|
+
Two rules that drive everything:
|
|
43
|
+
|
|
44
|
+
- **Read route state only through `useRoute(routeDef)`** — never reach into React
|
|
45
|
+
Router hooks for params/search you've declared. `useRoute` returns them parsed
|
|
46
|
+
and typed.
|
|
47
|
+
- **Never hand-write URLs** — use `TypedLink` / `pathTo`, which enforce params
|
|
48
|
+
and search against the route's schemas.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 2. App bootstrap
|
|
53
|
+
|
|
54
|
+
Compose the providers in this order (outermost → innermost). `ThemeProvider`
|
|
55
|
+
must wrap everything so the mode class is on `<html>` before anything paints;
|
|
56
|
+
`Toaster` is a sibling of the router.
|
|
57
|
+
|
|
58
|
+
```tsx
|
|
59
|
+
import { QueryClientProvider } from '@tanstack/react-query';
|
|
60
|
+
import { Suspense } from 'react';
|
|
61
|
+
import { RouterProvider } from 'react-router-dom';
|
|
62
|
+
|
|
63
|
+
import { SpinnerBlock, ThemeProvider, Toaster } from '@kode4/react-foundation';
|
|
64
|
+
import '@kode4/react-foundation/styles.css';
|
|
65
|
+
|
|
66
|
+
import { router } from './app/router';
|
|
67
|
+
import { queryClient } from './query/client';
|
|
68
|
+
|
|
69
|
+
export function App() {
|
|
70
|
+
return (
|
|
71
|
+
<ThemeProvider>
|
|
72
|
+
<QueryClientProvider client={queryClient}>
|
|
73
|
+
<Suspense fallback={<SpinnerBlock label="Loading…" />}>
|
|
74
|
+
<RouterProvider router={router} />
|
|
75
|
+
</Suspense>
|
|
76
|
+
<Toaster />
|
|
77
|
+
</QueryClientProvider>
|
|
78
|
+
</ThemeProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Fonts.** The design system's `--font-sans` token points at `'Open Sans
|
|
84
|
+
Variable'` first, then system fallbacks. Load the font (self-hosted via
|
|
85
|
+
`@fontsource-variable/open-sans` keeps it GDPR-clean and offline) or override
|
|
86
|
+
`--font-sans` in your app CSS. See `DESIGN-SYSTEM.md` → Typography.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 3. The typed router
|
|
91
|
+
|
|
92
|
+
### 3.1 Declaring a route
|
|
93
|
+
|
|
94
|
+
`defineRoute` is the unit of declaration. Co-locate it with its page component.
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
import { z } from 'zod';
|
|
98
|
+
import { defineRoute, useRoute } from '@kode4/react-foundation';
|
|
99
|
+
import { fetchUser } from './api';
|
|
100
|
+
|
|
101
|
+
const userParamsSchema = z.object({
|
|
102
|
+
id: z.coerce.number().int().positive(), // URL params are STRINGS — coerce!
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const userDetailRoute = defineRoute({
|
|
106
|
+
path: 'users/:id',
|
|
107
|
+
component: UserDetailPage,
|
|
108
|
+
params: userParamsSchema,
|
|
109
|
+
loader: async ({ params }) => ({ user: await fetchUser(params.id) }),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
export function UserDetailPage() {
|
|
113
|
+
const { params, data } = useRoute(userDetailRoute); // params.id is `number`
|
|
114
|
+
return <h1>{data.user.name} (#{params.id})</h1>;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`defineRoute` accepts: `path`, `component`, `params` (Zod), `search` (Zod),
|
|
119
|
+
`loader`, `menu` (`{ label, icon }`), `tabs`, and `children` (nested routes).
|
|
120
|
+
Everything is inferred from the schemas + loader — no manual generics.
|
|
121
|
+
|
|
122
|
+
### 3.2 Params vs. search
|
|
123
|
+
|
|
124
|
+
- **Path params** (`:id`) are always strings in the URL — wrap numeric ones in
|
|
125
|
+
`z.coerce.number()`. Validation runs once at the loader boundary; invalid input
|
|
126
|
+
yields a `400 Response`.
|
|
127
|
+
- **Search params** are typically `optional()` or `.default(...)`:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const search = z.object({ q: z.string().optional(), tab: z.enum(TABS).default('overview') });
|
|
131
|
+
// route: defineRoute({ ..., search })
|
|
132
|
+
// read: const { search } = useRoute(route); // search.tab is the enum
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- **Writing search state**: use React Router's `useSearchParams` setter with
|
|
136
|
+
`{ replace: true }` — never `window.history`.
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
const [, setSearchParams] = useSearchParams();
|
|
140
|
+
setSearchParams((prev) => { prev.set('q', value); return prev; }, { replace: true });
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 3.3 Loaders, guards, and middleware chains
|
|
144
|
+
|
|
145
|
+
A loader is `({ params, search, context, request }) => data`. `context` carries
|
|
146
|
+
`queryClient` plus whatever your app augments in (e.g. `auth` — see 3.6).
|
|
147
|
+
|
|
148
|
+
Simple loader: a plain async function (see 3.1). For cross-cutting concerns
|
|
149
|
+
(auth, roles) compose typed middleware with `chain()`; each step's return value
|
|
150
|
+
**merges** into the accumulated, inferred `data`:
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { chain, defineRoute, useRoute } from '@kode4/react-foundation';
|
|
154
|
+
import { requireAuth, requireRole } from './auth/guards';
|
|
155
|
+
|
|
156
|
+
export const adminRoute = defineRoute({
|
|
157
|
+
path: 'admin',
|
|
158
|
+
component: AdminPage,
|
|
159
|
+
loader: chain()
|
|
160
|
+
.use(requireAuth) // contributes { user }
|
|
161
|
+
.use(requireRole('admin')) // requires { user }, contributes nothing
|
|
162
|
+
.use(() => ({ metrics: getMetrics() }))
|
|
163
|
+
.build(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
function AdminPage() {
|
|
167
|
+
const { data } = useRoute(adminRoute); // data: { user, metrics } — fully typed
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Write guards as `LoaderMiddleware<Contributes, Requires>`:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
import { redirect } from 'react-router-dom';
|
|
175
|
+
import type { LoaderMiddleware } from '@kode4/react-foundation';
|
|
176
|
+
|
|
177
|
+
export const requireAuth: LoaderMiddleware<{ user: User }> = ({ context, request }) => {
|
|
178
|
+
const user = context.auth.current;
|
|
179
|
+
if (!user) {
|
|
180
|
+
const url = new URL(request.url);
|
|
181
|
+
throw redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`);
|
|
182
|
+
}
|
|
183
|
+
return { user };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Factory that REQUIRES a prior step's { user }:
|
|
187
|
+
export function requireRole(role: Role): LoaderMiddleware<void, { user: User }> {
|
|
188
|
+
return ({ data }) => {
|
|
189
|
+
if (data.user.role !== role) throw new Response('Forbidden', { status: 403 });
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### 3.4 Linking — never hand-write URLs
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
import { TypedLink, pathTo } from '@kode4/react-foundation';
|
|
198
|
+
|
|
199
|
+
<TypedLink route={userDetailRoute} params={{ id: 7 }}>Open user 7</TypedLink>
|
|
200
|
+
<TypedLink route={projectDetailRoute} params={{ id }} search={{ tab: 'tasks' }}>Tasks</TypedLink>
|
|
201
|
+
|
|
202
|
+
const href = pathTo(userDetailRoute, { params: { id: 7 } }); // for non-link uses
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`params`/`search` are checked against the route's schemas at the type level.
|
|
206
|
+
`pathTo` drops search args that fail their schema (with a dev-mode warning).
|
|
207
|
+
|
|
208
|
+
### 3.5 Tabs
|
|
209
|
+
|
|
210
|
+
Tabs are just a search param plus `tabs` metadata on the route. The metadata
|
|
211
|
+
drives rendering; the search param holds the active tab:
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
const TABS = ['overview', 'tasks', 'team'] as const;
|
|
215
|
+
export const projectDetailRoute = defineRoute({
|
|
216
|
+
path: 'projects/:id',
|
|
217
|
+
params: z.object({ id: z.coerce.number() }),
|
|
218
|
+
search: z.object({ tab: z.enum(TABS).default('overview') }),
|
|
219
|
+
tabs: TABS.map((id) => ({ id, label: id[0].toUpperCase() + id.slice(1) })),
|
|
220
|
+
component: ProjectDetailPage,
|
|
221
|
+
});
|
|
222
|
+
// In the page: const { search } = useRoute(route); link tabs with <TypedLink ... search={{ tab }} />
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### 3.6 Assembling the router + injecting app services
|
|
226
|
+
|
|
227
|
+
Build the router from your top-level routes. Pass `queryClient` and any app
|
|
228
|
+
services (here `auth`) as context; you can also override `hydrateFallback` /
|
|
229
|
+
`errorBoundary` here.
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { createAppRouter } from '@kode4/react-foundation';
|
|
233
|
+
|
|
234
|
+
export const router = createAppRouter([shellRoute, loginRoute], {
|
|
235
|
+
queryClient,
|
|
236
|
+
auth: authState,
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Tell the library's typed `context` about your services by **augmenting the
|
|
241
|
+
declaring module** (not the barrel):
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
// app/context.ts — import this once at startup (e.g. from main.tsx)
|
|
245
|
+
import type { AuthState } from './auth';
|
|
246
|
+
|
|
247
|
+
declare module '@kode4/react-foundation/router/context' {
|
|
248
|
+
interface AppContext {
|
|
249
|
+
auth: AuthState; // now context.auth is typed in every loader
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
> Pitfalls: a route not included in the array passed to `createAppRouter` makes
|
|
255
|
+
> `pathTo()` throw. Don't call `pathTo()`/`resolveMenu()` at module load — the
|
|
256
|
+
> registry fills when the router config is built.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## 4. Menus
|
|
261
|
+
|
|
262
|
+
The menu is a hand-authored `MenuNode[]` tree — the single source of truth for
|
|
263
|
+
navigation. Top-level nodes render in the `TopBar`; their `children` render in
|
|
264
|
+
the `SideBar` when that top-level item is active.
|
|
265
|
+
|
|
266
|
+
```tsx
|
|
267
|
+
import type { MenuNode } from '@kode4/react-foundation';
|
|
268
|
+
|
|
269
|
+
export const menuTree: MenuNode[] = [
|
|
270
|
+
{ route: homeRoute },
|
|
271
|
+
{ route: usersRoute, label: 'Manage', children: [
|
|
272
|
+
{ route: usersRoute },
|
|
273
|
+
{ route: settingsRoute, children: [{ route: profileRoute }, { route: securityRoute }] },
|
|
274
|
+
]},
|
|
275
|
+
{ group: 'Reports', children: [
|
|
276
|
+
{ content: <small>Section note (arbitrary node)</small> },
|
|
277
|
+
'separator',
|
|
278
|
+
{ route: reportRoute },
|
|
279
|
+
]},
|
|
280
|
+
];
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Node kinds: `{ route, label?, icon?, children? }`, `{ group, children }`,
|
|
284
|
+
`'separator'`, and `{ content }` (arbitrary JSX). Labels default to the route's
|
|
285
|
+
`menu.label`. Resolve a tree to renderable items with `resolveMenu(menuTree)`.
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## 5. App chrome & layout
|
|
290
|
+
|
|
291
|
+
There is no monolithic `<Shell>` — you **compose** the chrome from primitives, so
|
|
292
|
+
you control the structure. The canonical pattern:
|
|
293
|
+
|
|
294
|
+
```tsx
|
|
295
|
+
import {
|
|
296
|
+
AppLayout, TopBar, SideBar, SkipLink, ModeToggle,
|
|
297
|
+
resolveMenu, findActiveTopItem,
|
|
298
|
+
} from '@kode4/react-foundation';
|
|
299
|
+
|
|
300
|
+
function Shell() {
|
|
301
|
+
const { pathname } = useLocation();
|
|
302
|
+
const topMenu = useMemo(() => resolveMenu(menuTree), []);
|
|
303
|
+
const sideItems = useMemo(
|
|
304
|
+
() => findActiveTopItem(topMenu, pathname)?.children ?? [],
|
|
305
|
+
[topMenu, pathname],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<>
|
|
310
|
+
<SkipLink />
|
|
311
|
+
<AppLayout
|
|
312
|
+
header={
|
|
313
|
+
<TopBar
|
|
314
|
+
items={topMenu}
|
|
315
|
+
brand={<Link to="/">My App</Link>}
|
|
316
|
+
mobileMenu={topMenu} // full tree shown in the mobile drawer
|
|
317
|
+
end={<ModeToggle />}
|
|
318
|
+
/>
|
|
319
|
+
}>
|
|
320
|
+
<div className="app-body">
|
|
321
|
+
<SideBar items={sideItems} />
|
|
322
|
+
<main><Outlet /></main>
|
|
323
|
+
</div>
|
|
324
|
+
</AppLayout>
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
// Use this component as the `component` of a parent route whose `children` are
|
|
329
|
+
// the in-shell pages.
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Responsive navigation is automatic (mobile is a first-class requirement)
|
|
333
|
+
|
|
334
|
+
`TopBar` is **content-aware**: it measures whether its inline nav fits and
|
|
335
|
+
collapses into a hamburger + left-side drawer when it doesn't — so phones and
|
|
336
|
+
iPads in portrait get a usable drawer out of the box, with no per-app work.
|
|
337
|
+
|
|
338
|
+
- `mobileMenu` — the menu shown in the drawer; pass the full nested tree so
|
|
339
|
+
mobile users reach every page (the drawer renders a `SideBar` internally).
|
|
340
|
+
- `mobileQuery` — a *hard floor* (default `'(max-width: 768px)'`): at/below it the
|
|
341
|
+
nav always collapses; above it, collapse happens automatically on overflow.
|
|
342
|
+
Pass `null` to rely purely on overflow detection.
|
|
343
|
+
- On mobile, hide your in-content `SideBar` (the drawer already carries the full
|
|
344
|
+
nav) — e.g. `@media (max-width: 768px) { .app-body > .k4-sidebar { display: none } }`.
|
|
345
|
+
|
|
346
|
+
**Every component is expected to work on touch.** Avoid hover-only affordances as
|
|
347
|
+
the sole interaction.
|
|
348
|
+
|
|
349
|
+
### Layout frames & primitives
|
|
350
|
+
|
|
351
|
+
- **Frames**: `AppLayout` (full-height app shell with a sticky `header` slot),
|
|
352
|
+
`SiteLayout` (constrained marketing-style column), `CenteredLayout` (centered
|
|
353
|
+
card, e.g. login).
|
|
354
|
+
- **Primitives**: `Container`, `Columns` (left/right asides + content; supports
|
|
355
|
+
independent scroll regions via a `scroll` mode), `FillHeight`, `Header`,
|
|
356
|
+
`Footer`, `TopBar`, `SideBar`, `SkipLink`.
|
|
357
|
+
- **DashboardGrid** — an equal-column grid with a base row height, for dashboards.
|
|
358
|
+
Drop blocks (typically `Card`s) in via `DashboardGridItem`; each defaults to one
|
|
359
|
+
cell and can span columns/rows. Spans are clamped to the column count (never
|
|
360
|
+
overflow), and the grid collapses to a single column on phones.
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
<DashboardGrid columns={4}> {/* columns + rowHeight have sensible defaults */}
|
|
364
|
+
<DashboardGridItem colSpan={2}><Card>…</Card></DashboardGridItem> {/* wide */}
|
|
365
|
+
<DashboardGridItem><Card>…</Card></DashboardGridItem> {/* 1×1 default */}
|
|
366
|
+
<DashboardGridItem colSpan={2} rowSpan={2}><Card>…</Card></DashboardGridItem> {/* big */}
|
|
367
|
+
</DashboardGrid>
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## 6. UI components
|
|
373
|
+
|
|
374
|
+
All components follow the shadcn/ui blueprint, are built on Radix primitives, and
|
|
375
|
+
expose their props type. Two conventions matter:
|
|
376
|
+
|
|
377
|
+
### 6.1 `asChild`
|
|
378
|
+
|
|
379
|
+
Trigger-style components accept Radix's `asChild` to merge their behavior onto
|
|
380
|
+
your own element (commonly a `Button`):
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
<DialogTrigger asChild><Button appearance="outline">Open</Button></DialogTrigger>
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
### 6.2 The two-axis variant model (Button, Alert, Toast, Badge)
|
|
387
|
+
|
|
388
|
+
Color and fill are **orthogonal**:
|
|
389
|
+
|
|
390
|
+
- **`variant`** = color: `default` (neutral), `primary`, `secondary`,
|
|
391
|
+
`destructive`, `success` — plus `ghost` and `link` on `Button`.
|
|
392
|
+
- **`appearance`** = `filled` (default) or `outline`. `outline` is an
|
|
393
|
+
*appearance*, not a variant, and composes with every color.
|
|
394
|
+
- **`size`** (Button): `default`, `sm`, `lg`, `icon`.
|
|
395
|
+
|
|
396
|
+
```tsx
|
|
397
|
+
<Button>Save</Button> {/* neutral, filled */}
|
|
398
|
+
<Button variant="primary">Save</Button>
|
|
399
|
+
<Button variant="destructive" appearance="outline">Delete</Button>
|
|
400
|
+
<Button variant="ghost" size="icon" aria-label="More"><MoreHorizontalIcon /></Button>
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### 6.3 Confirm dialogs — `AlertDialog`
|
|
404
|
+
|
|
405
|
+
`AlertDialogAction` / `AlertDialogCancel` accept the same `variant` / `appearance`
|
|
406
|
+
/ `size` props as `Button`, so no `asChild` plumbing is needed. They auto-close.
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
<AlertDialog>
|
|
410
|
+
<AlertDialogTrigger asChild><Button variant="destructive" appearance="outline">Delete</Button></AlertDialogTrigger>
|
|
411
|
+
<AlertDialogContent>
|
|
412
|
+
<AlertDialogHeader>
|
|
413
|
+
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
414
|
+
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
|
415
|
+
</AlertDialogHeader>
|
|
416
|
+
<AlertDialogFooter>
|
|
417
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
418
|
+
<AlertDialogAction variant="destructive">Delete</AlertDialogAction>
|
|
419
|
+
</AlertDialogFooter>
|
|
420
|
+
</AlertDialogContent>
|
|
421
|
+
</AlertDialog>
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### 6.4 Pagination
|
|
425
|
+
|
|
426
|
+
Links styled like buttons; drive them with `href` (URL-based) or `onClick`
|
|
427
|
+
(client state). Mark the current page with `isActive`.
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
<Pagination>
|
|
431
|
+
<PaginationContent>
|
|
432
|
+
<PaginationItem><PaginationPrevious href="#" onClick={…} /></PaginationItem>
|
|
433
|
+
<PaginationItem><PaginationLink href="#" isActive>1</PaginationLink></PaginationItem>
|
|
434
|
+
<PaginationItem><PaginationEllipsis /></PaginationItem>
|
|
435
|
+
<PaginationItem><PaginationNext href="#" onClick={…} /></PaginationItem>
|
|
436
|
+
</PaginationContent>
|
|
437
|
+
</Pagination>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### 6.5 Component catalog
|
|
441
|
+
|
|
442
|
+
- **Inputs & forms**: `Input`, `Textarea`, `Label`, `Checkbox`, `Switch`,
|
|
443
|
+
`RadioGroup`, `Select`, `Form` (+ `FormField`/`FormItem`/`FormLabel`/
|
|
444
|
+
`FormControl`/`FormDescription`/`FormMessage`).
|
|
445
|
+
- **Actions & disclosure**: `Button`, `DropdownMenu`, `Dialog`, `AlertDialog`,
|
|
446
|
+
`Sheet`, `Popover`, `Tooltip`, `Command` (palette), `Accordion`, `Tabs`.
|
|
447
|
+
- **Display**: `Card`, `Table`, `Badge`, `Avatar`, `Separator`, `Skeleton`,
|
|
448
|
+
`Progress`, `Pagination`, `Alert`.
|
|
449
|
+
- **Feedback / async**: `Spinner` (+ `SpinnerBlock`, `DefaultHydrateFallback`),
|
|
450
|
+
`ErrorBlock`, `AwaitErrorBlock`, toasts (`Toaster` + `toast`).
|
|
451
|
+
- **Hooks**: `useMediaQuery('(max-width: 768px)')` for responsive logic that
|
|
452
|
+
can't be pure CSS.
|
|
453
|
+
|
|
454
|
+
---
|
|
455
|
+
|
|
456
|
+
## 7. Theming
|
|
457
|
+
|
|
458
|
+
Everything is themed through CSS variables — no rebuild. You re-declare tokens in
|
|
459
|
+
a stylesheet loaded **after** the library's.
|
|
460
|
+
|
|
461
|
+
- **Colors** use the unprefixed shadcn names (`--primary`, `--secondary`,
|
|
462
|
+
`--background`, `--foreground`, `--border`, `--destructive`, `--success`, …),
|
|
463
|
+
each often with a `-foreground` partner. Light values in `:root`, dark
|
|
464
|
+
overrides in `.dark`.
|
|
465
|
+
- **Dimensions** use the `--k4-` prefix where lib-specific. `--spacing` is the
|
|
466
|
+
global density lever (scales all padding/margin/gap/control sizes);
|
|
467
|
+
`--radius` controls corner rounding.
|
|
468
|
+
- **Mode toggle**: drop `<ModeToggle />` (typically in `TopBar`'s `end` slot);
|
|
469
|
+
read/set the mode with `useTheme()`. `ThemeProvider` manages the `.dark` class.
|
|
470
|
+
|
|
471
|
+
```css
|
|
472
|
+
/* your app theme, loaded after the library */
|
|
473
|
+
:root { --primary: oklch(0.45 0.16 258); --radius: 0.4rem; --spacing: 0.25rem; }
|
|
474
|
+
.dark { --primary: oklch(0.68 0.14 258); }
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
> **Don't reuse a system token name for an app-specific meaning.** Declaring e.g.
|
|
478
|
+
> `--accent` for your own purpose silently re-themes every component consuming
|
|
479
|
+
> that token. Name app variables distinctly (e.g. `--brand`).
|
|
480
|
+
|
|
481
|
+
The full contract — every token, the `-foreground` convention, custom fonts — is
|
|
482
|
+
in **`DESIGN-SYSTEM.md`**. A live token board ships in the demo at `/ui/tokens`.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
486
|
+
## 8. Data fetching (TanStack Query)
|
|
487
|
+
|
|
488
|
+
The library doesn't impose a data layer, but it has a **standard blueprint** for
|
|
489
|
+
TanStack Query (v5). Follow it for every server resource — it gives one
|
|
490
|
+
definition per query that's reusable across components, route loaders, and the
|
|
491
|
+
query client, plus reliable cache invalidation.
|
|
492
|
+
|
|
493
|
+
Per entity (e.g. `article`), create four pieces, conventionally in the feature's
|
|
494
|
+
`<entity>-service.ts` (the fetchers themselves live in an `api.ts`):
|
|
495
|
+
|
|
496
|
+
### 8.1 A hierarchical query-key factory
|
|
497
|
+
|
|
498
|
+
One source of truth for keys. The hierarchy is what makes **targeted
|
|
499
|
+
invalidation** possible: invalidating `lists()` refreshes every list variant at
|
|
500
|
+
once.
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
import type { SortOrderInput } from '@kode4/react-foundation';
|
|
504
|
+
|
|
505
|
+
export const articleKeys = {
|
|
506
|
+
all: ['articles'] as const,
|
|
507
|
+
lists: () => [...articleKeys.all, 'list'] as const,
|
|
508
|
+
list: (sort?: SortOrderInput) => [...articleKeys.lists(), sort] as const,
|
|
509
|
+
items: () => [...articleKeys.all, 'item'] as const,
|
|
510
|
+
item: (id?: number) => [...articleKeys.items(), id] as const,
|
|
511
|
+
};
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### 8.2 Query definitions with `queryOptions`
|
|
515
|
+
|
|
516
|
+
`queryOptions` colocates key + fn + options into a reusable object. The same
|
|
517
|
+
definition feeds `useQuery`, `useSuspenseQuery`, `ensureQueryData`,
|
|
518
|
+
`getQueryData`, etc. — never re-spell the key at each call site.
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
import { queryOptions, skipToken } from '@tanstack/react-query';
|
|
522
|
+
import { fetchArticle, fetchArticleList } from './api';
|
|
523
|
+
|
|
524
|
+
export const articleQueries = {
|
|
525
|
+
list: (sort?: SortOrderInput) =>
|
|
526
|
+
queryOptions({ queryKey: articleKeys.list(sort), queryFn: () => fetchArticleList(sort) }),
|
|
527
|
+
|
|
528
|
+
// skipToken disables the query while keeping the data type non-undefined —
|
|
529
|
+
// prefer it over `enabled: false` for conditional fetches.
|
|
530
|
+
item: (id?: number) =>
|
|
531
|
+
queryOptions({
|
|
532
|
+
queryKey: articleKeys.item(id),
|
|
533
|
+
queryFn: id == null ? skipToken : () => fetchArticle(id),
|
|
534
|
+
retry: false, // a 404 on a detail fetch shouldn't be retried
|
|
535
|
+
}),
|
|
536
|
+
};
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Read them in components:
|
|
540
|
+
|
|
541
|
+
```tsx
|
|
542
|
+
const { data } = useQuery(articleQueries.list(sort)); // or useSuspenseQuery(...)
|
|
543
|
+
const { data } = useQuery(articleQueries.item(selectedId)); // pauses until id is set
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### 8.3 Mutations + colocated invalidation
|
|
547
|
+
|
|
548
|
+
Define mutations with `mutationOptions`, then a `use<Entity>Mutations()` hook
|
|
549
|
+
that wires `onSuccess` invalidation **through the key factory** (never hardcode a
|
|
550
|
+
key — that silently drifts when the shape changes):
|
|
551
|
+
|
|
552
|
+
```ts
|
|
553
|
+
import { mutationOptions, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
554
|
+
|
|
555
|
+
export const articleMutations = {
|
|
556
|
+
save: () => mutationOptions({ mutationKey: [...articleKeys.all, 'save'], mutationFn: saveArticle }),
|
|
557
|
+
remove: () => mutationOptions({ mutationKey: [...articleKeys.all, 'remove'], mutationFn: deleteArticle }),
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
export function useArticleMutations() {
|
|
561
|
+
const qc = useQueryClient();
|
|
562
|
+
|
|
563
|
+
const saveMutation = useMutation({
|
|
564
|
+
...articleMutations.save(),
|
|
565
|
+
onSuccess: async (savedId) => {
|
|
566
|
+
await Promise.all([
|
|
567
|
+
qc.invalidateQueries({ queryKey: articleKeys.lists() }),
|
|
568
|
+
savedId != null ? qc.invalidateQueries({ queryKey: articleKeys.item(savedId) }) : undefined,
|
|
569
|
+
]);
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const deleteMutation = useMutation({
|
|
574
|
+
...articleMutations.remove(),
|
|
575
|
+
onSuccess: async (_data, id) => {
|
|
576
|
+
await Promise.all([
|
|
577
|
+
qc.invalidateQueries({ queryKey: articleKeys.lists() }),
|
|
578
|
+
qc.invalidateQueries({ queryKey: articleKeys.item(id) }),
|
|
579
|
+
]);
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
return { saveMutation, deleteMutation };
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### 8.4 The loader integration (this library's payoff)
|
|
588
|
+
|
|
589
|
+
Because query definitions are reusable, a route loader can **prefetch** the exact
|
|
590
|
+
same definition the component reads — so the page renders synchronously, with no
|
|
591
|
+
loading flash, and stays in the shared cache:
|
|
592
|
+
|
|
593
|
+
```tsx
|
|
594
|
+
export const articlesRoute = defineRoute({
|
|
595
|
+
path: 'articles',
|
|
596
|
+
component: ArticlesPage,
|
|
597
|
+
loader: ({ context }) => context.queryClient.ensureQueryData(articleQueries.list()),
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
function ArticlesPage() {
|
|
601
|
+
const { data } = useSuspenseQuery(articleQueries.list()); // already warmed by the loader
|
|
602
|
+
}
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
### 8.5 Rules
|
|
606
|
+
|
|
607
|
+
- **One key, one factory.** Every key — in queries, mutations, and invalidations —
|
|
608
|
+
comes from the `<entity>Keys` factory. A query that spells its own key lands in
|
|
609
|
+
a different cache bucket and **won't be invalidated** by the mutations.
|
|
610
|
+
- **Reuse, don't re-spell.** Pass `articleQueries.x()` to `useQuery`,
|
|
611
|
+
`useSuspenseQuery`, `ensureQueryData`, etc. Don't inline `{ queryKey, queryFn }`.
|
|
612
|
+
- **`skipToken`** for conditional fetches; **`retry: false`** for detail fetches
|
|
613
|
+
that 404 meaningfully.
|
|
614
|
+
- **Sorting**: reuse `SortOrder` / `SortDirection` / `SortOrderInput` from
|
|
615
|
+
`@kode4/react-foundation` for any sortable list, and include the sort in the
|
|
616
|
+
list key (`articleKeys.list(sort)`) so each sort variant caches independently.
|
|
617
|
+
|
|
618
|
+
---
|
|
619
|
+
|
|
620
|
+
## 9. Forms
|
|
621
|
+
|
|
622
|
+
`Form` wraps react-hook-form; pair it with a Zod resolver. `FormField` wires ids
|
|
623
|
+
and ARIA automatically.
|
|
624
|
+
|
|
625
|
+
```tsx
|
|
626
|
+
const schema = z.object({ username: z.string().min(3, 'At least 3 characters.') });
|
|
627
|
+
const form = useForm<z.infer<typeof schema>>({ resolver: zodResolver(schema), defaultValues: { username: '' } });
|
|
628
|
+
|
|
629
|
+
<Form {...form}>
|
|
630
|
+
<form onSubmit={form.handleSubmit((v) => toast.success(`Hi ${v.username}`))}>
|
|
631
|
+
<FormField control={form.control} name="username" render={({ field }) => (
|
|
632
|
+
<FormItem>
|
|
633
|
+
<FormLabel>Username</FormLabel>
|
|
634
|
+
<FormControl><Input placeholder="ada" {...field} /></FormControl>
|
|
635
|
+
<FormDescription>Your public name.</FormDescription>
|
|
636
|
+
<FormMessage />
|
|
637
|
+
</FormItem>
|
|
638
|
+
)} />
|
|
639
|
+
<Button type="submit">Submit</Button>
|
|
640
|
+
</form>
|
|
641
|
+
</Form>
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## 10. Toasts
|
|
647
|
+
|
|
648
|
+
Render `<Toaster />` once near the root, then call `toast` anywhere. Toast styling
|
|
649
|
+
mirrors the Alert variant system (including `primary`/`secondary`).
|
|
650
|
+
|
|
651
|
+
```tsx
|
|
652
|
+
import { toast } from '@kode4/react-foundation';
|
|
653
|
+
toast.success('Saved');
|
|
654
|
+
toast.error('Something went wrong');
|
|
655
|
+
toast.primary('Heads up');
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
---
|
|
659
|
+
|
|
660
|
+
## 11. Errors & async boundaries
|
|
661
|
+
|
|
662
|
+
- Every route gets `DefaultErrorBoundary` unless it declares its own — thrown
|
|
663
|
+
`Response`s (e.g. a guard's `403`) and Zod `400`s render sensibly.
|
|
664
|
+
- `ErrorBlock` is the standalone error display; `AwaitErrorBlock` is for
|
|
665
|
+
`<Await>`/deferred boundaries.
|
|
666
|
+
- `SpinnerBlock` / `DefaultHydrateFallback` are the standard loading fallbacks.
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## 12. Styling your own app
|
|
671
|
+
|
|
672
|
+
The library's internal rule "no Tailwind utility classes in markup" applies to
|
|
673
|
+
the *library source only*. **In your consuming app you may style however you
|
|
674
|
+
like** — Tailwind, CSS modules, plain CSS. Two guidelines:
|
|
675
|
+
|
|
676
|
+
1. **Theme through tokens**, not by overriding internal `k4-*` classes (those are
|
|
677
|
+
implementation detail and may change).
|
|
678
|
+
2. To make your own elements follow light/dark mode, style them against the same
|
|
679
|
+
semantic tokens (`var(--background)`, `var(--foreground)`, `var(--border)`, …).
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
|
|
683
|
+
## 13. Quick pitfall checklist
|
|
684
|
+
|
|
685
|
+
1. Forgot `z.coerce` on a numeric path param → it's a string.
|
|
686
|
+
2. Route missing from the `createAppRouter` array → `pathTo()` throws.
|
|
687
|
+
3. Called `pathTo()`/`resolveMenu()` at module load → registry not filled yet.
|
|
688
|
+
4. Augmented the barrel instead of `@kode4/react-foundation/router/context`.
|
|
689
|
+
5. Hand-wrote a URL instead of `TypedLink`/`pathTo`.
|
|
690
|
+
6. Reused a system token name (`--accent`, `--primary`) for an app-specific
|
|
691
|
+
value → re-themed every component. Use a distinct name.
|
|
692
|
+
7. Wrote search state via `window.history` instead of `useSearchParams`'s setter
|
|
693
|
+
with `{ replace: true }`.
|
|
694
|
+
8. Read params/search from React Router hooks instead of `useRoute(routeDef)`.
|