najm-kit 2.11.13 → 2.11.14
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/CHANGELOG.md +404 -376
- package/README.md +1195 -1065
- package/dist/{NajmUIProvider-DTyRrukg.d.ts → NajmUIProvider-BwL5HFIV.d.ts} +1 -1
- package/dist/adapters/app.d.ts +2 -2
- package/dist/adapters/next.d.ts +2 -2
- package/dist/chunk-XGIYOEWD.mjs +28 -0
- package/dist/{design-config-D_x1GCu3.d.ts → design-config-CL9vqqQ9.d.ts} +1 -1
- package/dist/{design-types-Rkpt8Pg1.d.ts → design-types-DffH_9XC.d.ts} +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.mjs +2 -25
- package/dist/server/index.d.ts +160 -3
- package/dist/server/index.mjs +129 -0
- package/dist/server/react.d.ts +2 -2
- package/dist/theme.css +5 -5
- package/dist/{uiBootstrap-Ci6K5j5t.d.ts → uiBootstrap-C66AFCo_.d.ts} +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,1065 +1,1195 @@
|
|
|
1
|
-
# najm-kit
|
|
2
|
-
|
|
3
|
-
Reusable React component library for Najm applications. Provides themed UI primitives, hooks, and form components.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
bun add najm-kit tailwindcss @tailwindcss/postcss
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
Peer dependencies: `react >=18`, `react-dom >=18`. Requires **Tailwind CSS v4** in the host app.
|
|
12
|
-
|
|
13
|
-
Optional peer dependencies: `recharts`, `@tanstack/react-table`, `react-hook-form`, `@tanstack/react-query`.
|
|
14
|
-
|
|
15
|
-
## Styling — the entire setup
|
|
16
|
-
|
|
17
|
-
najm-kit is a Tailwind v4, shadcn-compatible library. PostCSS config (`postcss.config.mjs`):
|
|
18
|
-
|
|
19
|
-
```js
|
|
20
|
-
export default { plugins: { "@tailwindcss/postcss": {} } };
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Your global stylesheet — **two imports, that's it**:
|
|
24
|
-
|
|
25
|
-
```css
|
|
26
|
-
@import "tailwindcss";
|
|
27
|
-
@import "najm-kit/theme.css";
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
This gives you every najm-kit component styled, dark mode wired (the `.dark` class),
|
|
31
|
-
and a full token-backed palette you can use in your own markup too
|
|
32
|
-
(`bg-background`, `bg-card`, `bg-primary`, `text-muted-foreground`, `border-border`, …).
|
|
33
|
-
|
|
34
|
-
### Theming
|
|
35
|
-
|
|
36
|
-
najm-kit uses the **standard shadcn token names** (no prefix), so you rebrand by
|
|
37
|
-
overriding CSS variables — or paste a theme straight from
|
|
38
|
-
[tweakcn](https://tweakcn.com) / the shadcn registry:
|
|
39
|
-
|
|
40
|
-
```css
|
|
41
|
-
:root { --primary: oklch(0.55 0.2 290); --radius: 0.75rem; }
|
|
42
|
-
.dark { --primary: oklch(0.70 0.18 290); }
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Add your own extra colors alongside najm-kit's:
|
|
46
|
-
|
|
47
|
-
```css
|
|
48
|
-
@theme { --color-success: oklch(0.7 0.18 150); } /* → bg-success, text-success */
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Dark mode: toggle the `dark` class on `<html>` (or any wrapper):
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
document.documentElement.classList.toggle("dark");
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
## Theme Provider (optional)
|
|
58
|
-
|
|
59
|
-
For scoped theming without writing CSS — useful for embedded surfaces. The provider
|
|
60
|
-
is opt-in: with no props it injects nothing and your `:root`/`.dark` CSS owns theming.
|
|
61
|
-
|
|
62
|
-
```tsx
|
|
63
|
-
import { NajmThemeProvider } from 'najm-kit';
|
|
64
|
-
|
|
65
|
-
// preset:
|
|
66
|
-
<NajmThemeProvider preset="dark-blue">{children}</NajmThemeProvider>
|
|
67
|
-
|
|
68
|
-
// or mode + accent:
|
|
69
|
-
<NajmThemeProvider mode="dark" accent="emerald">{children}</NajmThemeProvider>
|
|
70
|
-
|
|
71
|
-
// shadcn-style global radius scale:
|
|
72
|
-
<NajmThemeProvider radius="0.75rem">{children}</NajmThemeProvider>
|
|
73
|
-
|
|
74
|
-
// exact same radius for cards, tables, buttons, inputs, dialogs, etc.:
|
|
75
|
-
<NajmThemeProvider radius="0.75rem">
|
|
76
|
-
{children}
|
|
77
|
-
</NajmThemeProvider>
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
`rounded-full` and `rounded-none` remain explicit, so avatars, pills, switches,
|
|
81
|
-
and square variants keep their intended shape.
|
|
82
|
-
|
|
83
|
-
### JSON theme settings
|
|
84
|
-
|
|
85
|
-
Store one theme object in a JSON file, local storage, or your settings API:
|
|
86
|
-
|
|
87
|
-
```json
|
|
88
|
-
{
|
|
89
|
-
"mode": "dark",
|
|
90
|
-
"accent": "violet",
|
|
91
|
-
"radius": "0.75rem",
|
|
92
|
-
"appearance": { "borderWidth": "1px" },
|
|
93
|
-
"tokens": {
|
|
94
|
-
"primary": "oklch(0.62 0.2 290)",
|
|
95
|
-
"primary-foreground": "oklch(1 0 0)",
|
|
96
|
-
"sidebar": "oklch(0.18 0.02 290)",
|
|
97
|
-
"chart-1": "oklch(0.70 0.20 40)"
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
Load and apply it from the same settings state used by your theme editor:
|
|
103
|
-
|
|
104
|
-
```tsx
|
|
105
|
-
import rawTheme from './theme.json';
|
|
106
|
-
import { NajmThemeProvider, parseNajmThemeConfig } from 'najm-kit';
|
|
107
|
-
|
|
108
|
-
const initialTheme = parseNajmThemeConfig(rawTheme);
|
|
109
|
-
|
|
110
|
-
function App() {
|
|
111
|
-
const [theme, setTheme] = useState(initialTheme);
|
|
112
|
-
|
|
113
|
-
return (
|
|
114
|
-
<NajmThemeProvider config={theme}>
|
|
115
|
-
<SettingsPage value={theme} onChange={setTheme} />
|
|
116
|
-
{children}
|
|
117
|
-
</NajmThemeProvider>
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Changing the state updates the complete theme immediately. Use
|
|
123
|
-
`stringifyNajmThemeConfig(theme)` when persisting it, and parse settings loaded
|
|
124
|
-
from an API or local storage with `parseNajmThemeConfig` before applying them.
|
|
125
|
-
|
|
126
|
-
## Components
|
|
127
|
-
|
|
128
|
-
Import from `najm-kit`:
|
|
129
|
-
|
|
130
|
-
```tsx
|
|
131
|
-
import { NButton, buttonVariants } from 'najm-kit';
|
|
132
|
-
import { Input } from 'najm-kit';
|
|
133
|
-
import { Card, CardHeader, CardTitle, CardContent } from 'najm-kit';
|
|
134
|
-
import { Dialog, DialogContent, DialogTrigger } from 'najm-kit';
|
|
135
|
-
import { DataTable } from 'najm-kit';
|
|
136
|
-
import { Form, FormInput, useNForm } from 'najm-kit';
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
### Available Primitives
|
|
140
|
-
|
|
141
|
-
| Category | Components |
|
|
142
|
-
|----------|-----------|
|
|
143
|
-
| Actions | NButton, IconButton, toggleVariants |
|
|
144
|
-
| Forms | Input, Textarea, Label, Select, Checkbox, RadioGroup, Switch, DateInput, FileInput, ImageInput, AvatarInput |
|
|
145
|
-
| Feedback | Alert, Badge, Progress, Spinner, Toast, NLoadingState, NErrorState, NEmptyState, NForbiddenState, NNotFoundState |
|
|
146
|
-
| Layout | Card, Sheet, Dialog, Popover, DropdownMenu, Tabs |
|
|
147
|
-
| Data | Table (NTable), StatCard, DetailList, CredentialsCard |
|
|
148
|
-
| Overlays | Command palette, Tooltip, Toast |
|
|
149
|
-
|
|
150
|
-
## Images and avatars
|
|
151
|
-
|
|
152
|
-
Three components, one fallback rule. Each tries its sources in order, tries a
|
|
153
|
-
source at most once, and discards what it knows about a failure the moment the
|
|
154
|
-
sources change.
|
|
155
|
-
|
|
156
|
-
### `NImage` — plain `<img>`
|
|
157
|
-
|
|
158
|
-
For a logo or an icon whose box the caller's CSS already owns. No layout is
|
|
159
|
-
invented, and `onError` is forwarded rather than swallowed.
|
|
160
|
-
|
|
161
|
-
```tsx
|
|
162
|
-
import { NImage } from 'najm-kit';
|
|
163
|
-
|
|
164
|
-
<NImage src={logo} fallback="/brand/logo.svg" alt="Acme" className="h-8 w-auto" />
|
|
165
|
-
```
|
|
166
|
-
|
|
167
|
-
### `NAvatar` — person or record
|
|
168
|
-
|
|
169
|
-
The image is a native `<img>` loaded directly by the browser, so a same-origin
|
|
170
|
-
protected route works with the session the page already has, and the package
|
|
171
|
-
needs no knowledge of which routes are protected.
|
|
172
|
-
|
|
173
|
-
```tsx
|
|
174
|
-
import { NAvatar } from 'najm-kit';
|
|
175
|
-
|
|
176
|
-
<NAvatar
|
|
177
|
-
src={member.image}
|
|
178
|
-
fallbackSrc={stockPortrait}
|
|
179
|
-
version={member.imageRevision}
|
|
180
|
-
title={member.name}
|
|
181
|
-
subtitle={member.role}
|
|
182
|
-
size="lg"
|
|
183
|
-
/>
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
- The primary source is tried first, then `fallbackSrc`, then the initials.
|
|
187
|
-
- `version` (or `srcVersion`) is appended as `?v=…` to every remote source, so a
|
|
188
|
-
re-upload is not served from cache. `data:` and `blob:` sources are left alone.
|
|
189
|
-
- Initials stay visible until an image paints and come back if every source
|
|
190
|
-
fails — a transparent PNG never shows letters through itself.
|
|
191
|
-
- `imageProps` reaches the element for `loading`, `sizes`, `crossOrigin`,
|
|
192
|
-
`referrerPolicy`, and the load/error handlers. It defaults to `loading="lazy"`,
|
|
193
|
-
and supplied handlers are composed with the fallback chain rather than
|
|
194
|
-
replacing it.
|
|
195
|
-
|
|
196
|
-
### `NNextImage` — optimized, from `najm-kit/next`
|
|
197
|
-
|
|
198
|
-
Same fallback contract with Next's optimizer, layout reservation, `fill`, and
|
|
199
|
-
`sizes`. It lives only in the `najm-kit/next` entry, because the root package
|
|
200
|
-
stays installable without Next.
|
|
201
|
-
|
|
202
|
-
```tsx
|
|
203
|
-
import { NNextImage } from 'najm-kit/next';
|
|
204
|
-
|
|
205
|
-
// A public asset: let the optimizer resize and re-encode it.
|
|
206
|
-
<NNextImage src="/covers/spring.png" alt="Spring" width={64} height={64} />
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
For an asset the browser must fetch directly — one behind an authenticated route,
|
|
210
|
-
typically — the *application* says so:
|
|
211
|
-
|
|
212
|
-
```tsx
|
|
213
|
-
<NNextImage
|
|
214
|
-
src={record.image}
|
|
215
|
-
alt={record.name}
|
|
216
|
-
fill
|
|
217
|
-
sizes="64px"
|
|
218
|
-
unoptimized
|
|
219
|
-
/>
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
`unoptimized` is passed at the call site rather than inferred from the URL:
|
|
223
|
-
which routes are protected is the application's fact, not something a package
|
|
224
|
-
can read off a path. It changes delivery mechanics only — session validation,
|
|
225
|
-
permissions, privacy projection, and what bytes come back all remain the
|
|
226
|
-
backend's.
|
|
227
|
-
|
|
228
|
-
## Status badges
|
|
229
|
-
|
|
230
|
-
`<NBadge status="…" />` already maps a broad lifecycle vocabulary onto the
|
|
231
|
-
semantic colors, so it is correct without configuration:
|
|
232
|
-
|
|
233
|
-
```tsx
|
|
234
|
-
import { NBadge } from 'najm-kit';
|
|
235
|
-
|
|
236
|
-
<NBadge status="out_for_delivery" /> // warning, "Out For Delivery"
|
|
237
|
-
<NBadge status="nebulous" /> // neutral, "Nebulous"
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
What an application usually adds on top is the same three things at every call
|
|
241
|
-
site: its look, its shape, and its own translated label. Declare them once:
|
|
242
|
-
|
|
243
|
-
```tsx
|
|
244
|
-
<NajmAppProvider
|
|
245
|
-
badgeDefaults={{
|
|
246
|
-
look: 'soft',
|
|
247
|
-
shape: 'pill',
|
|
248
|
-
statusLabelKeys: {
|
|
249
|
-
active: 'status.active',
|
|
250
|
-
out_for_delivery: 'status.outForDelivery',
|
|
251
|
-
},
|
|
252
|
-
}}
|
|
253
|
-
>
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
`badgeDefaults` lives on `NajmUIProvider` and is inherited by
|
|
257
|
-
`NajmNextUIProvider` and `NajmAppProvider`, so there is one place to set it.
|
|
258
|
-
The keys are the application's catalog keys, resolved through the same `t` the
|
|
259
|
-
provider already has — this package ships no status catalog. A language change
|
|
260
|
-
recomputes every label without a remount.
|
|
261
|
-
|
|
262
|
-
Resolution, most specific first:
|
|
263
|
-
|
|
264
|
-
1. An explicit prop beats every provider default.
|
|
265
|
-
2. `label` beats string children; string children beat the provider's label.
|
|
266
|
-
3. A `statusLabels` literal beats a `statusLabelKeys` catalog lookup.
|
|
267
|
-
4. An unmapped status is humanized (`pending_review` → `Pending Review`).
|
|
268
|
-
5. A per-instance `statusMap`/`iconMap` merges over the provider's, so
|
|
269
|
-
overriding one status costs one status.
|
|
270
|
-
6. Provider status defaults apply **only** when `status` is set —
|
|
271
|
-
`<NBadge>Beta</NBadge>` keeps the ordinary content-badge look.
|
|
272
|
-
|
|
273
|
-
Statuses are matched through one rule, exported as `normalizeStatusToken`, so
|
|
274
|
-
`Out-For-Delivery `, `out for delivery`, and `out_for_delivery` are the same
|
|
275
|
-
key for colors, icons, and labels alike. Badge text is presentation: it renames
|
|
276
|
-
nothing in the backend and validates no lifecycle transition.
|
|
277
|
-
|
|
278
|
-
## Feedback states
|
|
279
|
-
|
|
280
|
-
Five public state components cover the reusable cases every application
|
|
281
|
-
otherwise repeats: `NLoadingState`, `NErrorState`, `NEmptyState`,
|
|
282
|
-
`NForbiddenState`, and `NNotFoundState`. They share one layout frame and one
|
|
283
|
-
provider-defaults channel, so an application configures its copy once and
|
|
284
|
-
every consumer below inherits it.
|
|
285
|
-
|
|
286
|
-
### Surfaces
|
|
287
|
-
|
|
288
|
-
Three layouts, one prop. `surface` selects the frame:
|
|
289
|
-
|
|
290
|
-
| `surface` | Use it for | What it does |
|
|
291
|
-
| --- | --- | --- |
|
|
292
|
-
| `"inline"` (default) | A small slot inside an existing component | Legacy sizing, no landmark |
|
|
293
|
-
| `"panel"` | A table body, card body, dialog, or sheet | Centered with a minimum height, no page gutter, no landmark |
|
|
294
|
-
| `"page"` | A real route-level state | Uses page spacing from the design config; renders through a non-`<main>` root |
|
|
295
|
-
|
|
296
|
-
`NLoadingState.fullScreen` keeps its fixed viewport overlay regardless of
|
|
297
|
-
surface — it always wins.
|
|
298
|
-
|
|
299
|
-
```tsx
|
|
300
|
-
import { NLoadingState, NErrorState, NEmptyState } from 'najm-kit';
|
|
301
|
-
|
|
302
|
-
// Inline (default): drop into a card or section.
|
|
303
|
-
<NLoadingState label="Loading orders..." />
|
|
304
|
-
|
|
305
|
-
// Panel: table body or dialog content.
|
|
306
|
-
<NEmptyState surface="panel" title="No orders yet" icon={Inbox} />
|
|
307
|
-
|
|
308
|
-
// Page: route-level empty state. Never introduces a second <main>.
|
|
309
|
-
<NErrorState
|
|
310
|
-
surface="page"
|
|
311
|
-
title="Dashboard unavailable"
|
|
312
|
-
message="We are working on it."
|
|
313
|
-
onRetry={() => refetch()}
|
|
314
|
-
/>
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
### Provider defaults
|
|
318
|
-
|
|
319
|
-
Pass one `feedbackDefaults` map to `NajmUIProvider` (or to `NajmAppProvider`
|
|
320
|
-
through it) and every feedback state beneath uses it. There is one place for
|
|
321
|
-
loading, empty, error, retry, forbidden, and not-found labels, and a single
|
|
322
|
-
language change recomputes them all without remounting the tree.
|
|
323
|
-
|
|
324
|
-
```tsx
|
|
325
|
-
import { NajmAppProvider } from 'najm-kit/app';
|
|
326
|
-
|
|
327
|
-
<NajmAppProvider
|
|
328
|
-
feedbackDefaults={{
|
|
329
|
-
labels: {
|
|
330
|
-
loadingLabel: 'Chargement…',
|
|
331
|
-
emptyTitle: 'Aucune donnée',
|
|
332
|
-
errorTitle: 'Une erreur est survenue',
|
|
333
|
-
retryLabel: 'Réessayer',
|
|
334
|
-
forbiddenTitle: 'Accès refusé',
|
|
335
|
-
forbiddenDescription: 'Vous n\'avez pas la permission.',
|
|
336
|
-
notFoundTitle: 'Page introuvable',
|
|
337
|
-
notFoundDescription: 'La page demandée n\'existe pas.',
|
|
338
|
-
},
|
|
339
|
-
labelKeys: {
|
|
340
|
-
emptyTitle: 'common.empty',
|
|
341
|
-
errorTitle: 'common.error',
|
|
342
|
-
},
|
|
343
|
-
}}
|
|
344
|
-
>
|
|
345
|
-
<App />
|
|
346
|
-
</NajmAppProvider>
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
Resolution order, most specific first:
|
|
350
|
-
|
|
351
|
-
1. An explicit component prop.
|
|
352
|
-
2. A literal in `feedbackDefaults.labels`.
|
|
353
|
-
3. A translated `feedbackDefaults.labelKeys` value resolved through the
|
|
354
|
-
provider's existing structural `t` function.
|
|
355
|
-
4. `` `<prefix>.<field>` `` resolved through the same `t`, where `prefix`
|
|
356
|
-
defaults to `common.feedback`.
|
|
357
|
-
5. The current packaged English fallback, when that field has one.
|
|
358
|
-
|
|
359
|
-
#### The prefix convention
|
|
360
|
-
|
|
361
|
-
Step 4 is the reason most applications need no `feedbackDefaults` at all. Name
|
|
362
|
-
the nine catalog entries after the fields — `common.feedback.emptyTitle`,
|
|
363
|
-
`common.feedback.retryLabel`, and so on — and a provider that already has a
|
|
364
|
-
translator resolves every feedback state with no mapping object to write or
|
|
365
|
-
memoize:
|
|
366
|
-
|
|
367
|
-
```tsx
|
|
368
|
-
<NajmAppProvider translations={translations} initialLanguage="fr">
|
|
369
|
-
<App />
|
|
370
|
-
</NajmAppProvider>
|
|
371
|
-
```
|
|
372
|
-
|
|
373
|
-
Use `prefix` to point at a different branch, and `FeedbackKey<Prefix>` to type
|
|
374
|
-
a translator against exactly those nine keys:
|
|
375
|
-
|
|
376
|
-
```tsx
|
|
377
|
-
import type { FeedbackKey } from 'najm-kit';
|
|
378
|
-
|
|
379
|
-
<NajmAppProvider feedbackDefaults={{ prefix: 'app.states' }}>
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
Unlike `buildToolbarLabels` and `buildPaginationLabels`, a translator result
|
|
383
|
-
equal to the key it was handed is treated as *missing* here rather than
|
|
384
|
-
rendered. The prefix is a convention an application may never have adopted, so
|
|
385
|
-
an unanswered key falls through to packaged English instead of painting
|
|
386
|
-
`common.feedback.emptyTitle` across an empty state. The same rule applies to an
|
|
387
|
-
explicit `labelKeys` entry, which makes a typo in the mapping degrade to English
|
|
388
|
-
rather than to visible key text.
|
|
389
|
-
|
|
390
|
-
Generic `NErrorState.message` and `NEmptyState.description` deliberately have
|
|
391
|
-
no packaged fallback — the no-provider render must look the same as it did
|
|
392
|
-
before this contract shipped. A configured `errorMessage` opts the generic
|
|
393
|
-
error state into rendering a body; absent that opt-in, the existing
|
|
394
|
-
no-body render is preserved.
|
|
395
|
-
|
|
396
|
-
### Forbidden and not-found
|
|
397
|
-
|
|
398
|
-
Two first-class preset states for the routes every application grows:
|
|
399
|
-
|
|
400
|
-
```tsx
|
|
401
|
-
import { NForbiddenState, NNotFoundState } from 'najm-kit';
|
|
402
|
-
|
|
403
|
-
// Forbidden: provider copy + ShieldOff icon + page surface by default.
|
|
404
|
-
<NForbiddenState
|
|
405
|
-
action={<Link href="/dashboard">Back to dashboard</Link>}
|
|
406
|
-
/>
|
|
407
|
-
|
|
408
|
-
// Not found: provider copy + Compass icon + page surface by default.
|
|
409
|
-
<NNotFoundState
|
|
410
|
-
action={<Link href="/dashboard">Back to dashboard</Link>}
|
|
411
|
-
/>
|
|
412
|
-
```
|
|
413
|
-
|
|
414
|
-
Both are presentation only. They do not know the dashboard URL, render a
|
|
415
|
-
Next `Link`, redirect, or write route metadata — those belong to the
|
|
416
|
-
application's `not-found.tsx` / `forbidden/page.tsx` files.
|
|
417
|
-
|
|
418
|
-
### Root and `najm-kit/app` imports
|
|
419
|
-
|
|
420
|
-
Both entries export the same five state components. Pick the one that matches
|
|
421
|
-
your boundary:
|
|
422
|
-
|
|
423
|
-
```tsx
|
|
424
|
-
// Client feature code: import from the root barrel.
|
|
425
|
-
import { NEmptyState } from 'najm-kit';
|
|
426
|
-
|
|
427
|
-
// Next Server Component route: import from najm-kit/app, which is the
|
|
428
|
-
// Client Component boundary. A route file can render a state component
|
|
429
|
-
// without authoring a local "use client" wrapper.
|
|
430
|
-
import { NNotFoundState } from 'najm-kit/app';
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
## Global form development tools
|
|
434
|
-
|
|
435
|
-
Enable schema-driven test values once on the full application provider. Every
|
|
436
|
-
`NForm` and `WizardForm` below it then fills from its Zod schema when F8 is
|
|
437
|
-
pressed; applications do not need a second provider or a form-fill helper.
|
|
438
|
-
|
|
439
|
-
```tsx
|
|
440
|
-
import { NajmAppProvider } from "najm-kit/app";
|
|
441
|
-
|
|
442
|
-
<NajmAppProvider formDevTools>
|
|
443
|
-
<App />
|
|
444
|
-
</NajmAppProvider>;
|
|
445
|
-
```
|
|
446
|
-
|
|
447
|
-
Pass a boolean to control it from application settings:
|
|
448
|
-
|
|
449
|
-
```tsx
|
|
450
|
-
<NajmAppProvider formDevTools={formFillEnabled}>
|
|
451
|
-
<App />
|
|
452
|
-
</NajmAppProvider>
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
Forms with live relation options can override only those fields. The provider
|
|
456
|
-
still owns enablement and Najm Kit still owns schema traversal and generation.
|
|
457
|
-
|
|
458
|
-
```tsx
|
|
459
|
-
<NForm
|
|
460
|
-
schema={orderSchema}
|
|
461
|
-
devTools={{ overrides: { customerId: customerOptions } }}
|
|
462
|
-
onSubmit={saveOrder}
|
|
463
|
-
>
|
|
464
|
-
{/* fields */}
|
|
465
|
-
</NForm>
|
|
466
|
-
```
|
|
467
|
-
|
|
468
|
-
## ImageInput and AvatarInput
|
|
469
|
-
|
|
470
|
-
`ImageInput` and `AvatarInput` ship with a resilient preview contract so
|
|
471
|
-
consumers do not need to wrap them with application-specific preview
|
|
472
|
-
components.
|
|
473
|
-
|
|
474
|
-
Source precedence:
|
|
475
|
-
|
|
476
|
-
- When `value` is a non-empty string URL, candidates are tried in order:
|
|
477
|
-
1. `value` is the primary preview source.
|
|
478
|
-
2. If the primary source fails, `fallbackImage` is tried when supplied.
|
|
479
|
-
3. `defaultImage` is the last-resort fallback.
|
|
480
|
-
- When `value` is `null` or empty, only `defaultImage` is tracked. The
|
|
481
|
-
`fallbackImage` is intentionally not used in the empty state — a null
|
|
482
|
-
`value` is the consumer's empty-state signal, and only the configured
|
|
483
|
-
default participates in the failed-default → unavailable transition.
|
|
484
|
-
If `defaultImage` itself fails, `onPreviewError({ source: "default" })`
|
|
485
|
-
fires and the unavailable state is rendered.
|
|
486
|
-
|
|
487
|
-
Candidate URLs are deduplicated so the same failing URL is never retried
|
|
488
|
-
through multiple stages. When every candidate fails, the broken `<img>` is
|
|
489
|
-
unmounted and `unavailableContent` (or a neutral default) is rendered in its
|
|
490
|
-
place. A `data-image-input-state="empty" | "preview" | "fallback" | "unavailable"`
|
|
491
|
-
marker is exposed for styling, testing, and consumer diagnostics.
|
|
492
|
-
|
|
493
|
-
Candidate URLs are deduplicated so the same failing URL is never retried
|
|
494
|
-
through multiple stages. When every candidate fails, the broken `<img>` is
|
|
495
|
-
unmounted and `unavailableContent` (or a neutral default) is rendered in its
|
|
496
|
-
place. A `data-image-input-state="empty" | "preview" | "fallback" | "unavailable"`
|
|
497
|
-
marker is exposed for styling, testing, and consumer diagnostics.
|
|
498
|
-
|
|
499
|
-
```tsx
|
|
500
|
-
import { ImageInput } from "najm-kit";
|
|
501
|
-
|
|
502
|
-
<ImageInput
|
|
503
|
-
value="https://cdn.example.com/avatar.png"
|
|
504
|
-
onChange={setAvatar}
|
|
505
|
-
previewAlt="Workspace logo"
|
|
506
|
-
fallbackImage="/assets/logo-default.png"
|
|
507
|
-
fallbackAlt="Default workspace logo"
|
|
508
|
-
unavailableContent={<span>Logo unavailable</span>}
|
|
509
|
-
imageClassName="object-contain"
|
|
510
|
-
imageVersion={cacheBustVersion}
|
|
511
|
-
replaceAriaLabel="Replace workspace logo"
|
|
512
|
-
clearAriaLabel="Remove workspace logo"
|
|
513
|
-
onPreviewError={(err) => log(err)}
|
|
514
|
-
/>
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
Key behaviors:
|
|
518
|
-
|
|
519
|
-
- The replace and clear controls are real `<button>` elements, are reachable
|
|
520
|
-
with the keyboard (`Enter` and `Space` activate them once), and stay
|
|
521
|
-
visible on touch and coarse-pointer devices. Only on `(hover: hover) and
|
|
522
|
-
(pointer: fine)` desktops do the controls fall back to a hover/focus
|
|
523
|
-
reveal. `focus-visible` always restores visibility.
|
|
524
|
-
- Positioning uses logical properties (`end-*`) so the clear button works
|
|
525
|
-
correctly in RTL layouts.
|
|
526
|
-
- `imageVersion` is appended safely to relative, absolute, queried, and
|
|
527
|
-
fragmented URLs. `data:`, `blob:`, `javascript:`, and `file:` URLs are
|
|
528
|
-
left unchanged.
|
|
529
|
-
- File selection is race-safe: stale `FileReader` completions cannot replace
|
|
530
|
-
a newer value, and object URLs created by the component are tracked so
|
|
531
|
-
consumer-owned blob URLs are never revoked.
|
|
532
|
-
|
|
533
|
-
`AvatarInput` forwards every preview and accessibility prop unchanged while
|
|
534
|
-
preserving its circular, size, fill, and camera-icon defaults.
|
|
535
|
-
|
|
536
|
-
## Credentials handover
|
|
537
|
-
|
|
538
|
-
`NCredentialsCard` renders the recurring "show a freshly generated secret
|
|
539
|
-
once, let the operator hand it over, never show it again" surface. It owns
|
|
540
|
-
the frame, the description-list semantics, the copy flow, the failure
|
|
541
|
-
handling, and the accessible feedback. Every domain label — title,
|
|
542
|
-
description, field labels, action labels, and any translated toast — stays
|
|
543
|
-
with the application.
|
|
544
|
-
|
|
545
|
-
```tsx
|
|
546
|
-
import { NCredentialsCard, NButton } from "najm-kit";
|
|
547
|
-
import { KeyRound, Phone } from "lucide-react";
|
|
548
|
-
|
|
549
|
-
<NCredentialsCard
|
|
550
|
-
title={t("staff.access.created")}
|
|
551
|
-
description={t("staff.access.oneTimeHint")}
|
|
552
|
-
fields={[
|
|
553
|
-
{ label: t("common.phone"), value: credentials.phone, icon: Phone },
|
|
554
|
-
{ label: t("staff.access.initialPassword"), value: credentials.password, icon: KeyRound },
|
|
555
|
-
]}
|
|
556
|
-
copyLabel={t("common.copyDetails")}
|
|
557
|
-
copiedLabel={t("common.copied")}
|
|
558
|
-
copyErrorLabel={t("common.copyError")}
|
|
559
|
-
actions={<NButton onClick={() => pop()}>{t("common.done")}</NButton>}
|
|
560
|
-
/>
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
Behaviour worth knowing:
|
|
564
|
-
|
|
565
|
-
- `fields` renders as a `<dl>` of `<dt>`/`<dd>` pairs. Values default to
|
|
566
|
-
monospaced and mid-string wrapping so secrets stay readable on every
|
|
567
|
-
width.
|
|
568
|
-
- The header icon defaults to a check mark and is always rendered when a
|
|
569
|
-
header is shown. Pass `icon={SomeLucideIcon}` to replace it; pass any
|
|
570
|
-
supported `NIconSource` to swap in a logo, image, or remote URL.
|
|
571
|
-
- The Copy button resolves text through `copyText` when supplied, otherwise
|
|
572
|
-
joins `${label}: ${value}` with `\n` in field order. The button is
|
|
573
|
-
disabled while the clipboard write is pending. Success swaps the label to
|
|
574
|
-
`copiedLabel` and a check icon; failure swaps to `copyErrorLabel` and a
|
|
575
|
-
warning icon. Either state reverts to idle after roughly two seconds.
|
|
576
|
-
- The copy button renders before any consumer `actions` so a Done-style
|
|
577
|
-
dismiss stays the last tab stop and never gets pressed before the secret
|
|
578
|
-
is actually copied.
|
|
579
|
-
- Missing `navigator.clipboard`, rejected `writeText`, and synchronously
|
|
580
|
-
thrown `copyText` / `writeText` all land in the error state and call
|
|
581
|
-
`onCopyError` instead of rethrowing. State updates and revert timers are
|
|
582
|
-
guarded, so unmounting during a pending copy, or starting a second copy
|
|
583
|
-
while the first success state is still showing, never fire stale setters.
|
|
584
|
-
- Status is announced through a polite `aria-live` region. The visible swap
|
|
585
|
-
is the primary feedback — no toast is emitted by the component.
|
|
586
|
-
- Packaged English fallbacks exist for `copyLabel`, `copiedLabel`, and
|
|
587
|
-
`copyErrorLabel` only. Title, description, and every field label are the
|
|
588
|
-
application's text; a consumer that omits them gets no text, not English.
|
|
589
|
-
- Spacing uses logical properties only, so a `dir="rtl"` tree needs no
|
|
590
|
-
override. Each value also carries `dir="auto"`, isolating it from the
|
|
591
|
-
surrounding paragraph direction: a phone number or password inherited into an
|
|
592
|
-
RTL tree otherwise *paints* reordered (`+1 555 0100` as `0100 555 1+`) even
|
|
593
|
-
though the DOM and the copied text are correct. A value whose first strong
|
|
594
|
-
character is Arabic still renders right-to-left.
|
|
595
|
-
|
|
596
|
-
When you only want consumer buttons and no built-in copy, pass
|
|
597
|
-
`hideCopyAction`. Pass `copyText` to format the copied text differently
|
|
598
|
-
(one CSV line per field, a JSON blob, a single concatenated value, …).
|
|
599
|
-
|
|
600
|
-
## Formatting
|
|
601
|
-
|
|
602
|
-
Pure formatters are available from the server-safe `najm-kit/format` entry.
|
|
603
|
-
Money values are integer minor units and use the currency's own exponent (for
|
|
604
|
-
example MAD has two decimals, JPY zero, and KWD three).
|
|
605
|
-
|
|
606
|
-
```ts
|
|
607
|
-
import { formatCurrency, formatDate, slugify } from 'najm-kit/format';
|
|
608
|
-
|
|
609
|
-
formatCurrency(12_500, { locale: 'fr-MA', currency: 'MAD' });
|
|
610
|
-
formatDate('2026-08-08T20:00:00Z', {
|
|
611
|
-
locale: 'fr-MA',
|
|
612
|
-
timeZone: 'Africa/Casablanca',
|
|
613
|
-
});
|
|
614
|
-
slugify('Najm Format & Pagination');
|
|
615
|
-
```
|
|
616
|
-
|
|
617
|
-
Client code can use the active locale, time zone, currency, and placeholder
|
|
618
|
-
through `useNajmFormat`. `NajmAppProvider` mounts the format provider for you:
|
|
619
|
-
|
|
620
|
-
```tsx
|
|
621
|
-
import { NajmAppProvider } from 'najm-kit/app';
|
|
622
|
-
import { useNajmFormat } from 'najm-kit';
|
|
623
|
-
|
|
624
|
-
<NajmAppProvider
|
|
625
|
-
translations={translations}
|
|
626
|
-
currency="MAD"
|
|
627
|
-
locales={{ en: 'en-MA', fr: 'fr-MA' }}
|
|
628
|
-
>
|
|
629
|
-
<App />
|
|
630
|
-
</NajmAppProvider>
|
|
631
|
-
|
|
632
|
-
function Total({ value }: { value: number }) {
|
|
633
|
-
return <span>{useNajmFormat().money(value)}</span>;
|
|
634
|
-
}
|
|
635
|
-
```
|
|
636
|
-
|
|
637
|
-
## Offset pagination and queries
|
|
638
|
-
|
|
639
|
-
`najm-kit/pagination` is server-safe and framework-independent. It accepts
|
|
640
|
-
endpoints that return either `{ rows, total }` or a bare row array. When no
|
|
641
|
-
total exists it probes for one extra row; when a total exists continuation is
|
|
642
|
-
calculated without another request.
|
|
643
|
-
|
|
644
|
-
```ts
|
|
645
|
-
import {
|
|
646
|
-
createOffsetPagination,
|
|
647
|
-
fetchOffsetPage,
|
|
648
|
-
} from 'najm-kit/pagination';
|
|
649
|
-
|
|
650
|
-
const pagination = createOffsetPagination(pageIndex, pageSize);
|
|
651
|
-
const page = await fetchOffsetPage(
|
|
652
|
-
({ limit, offset }) => api.orders.list({ limit, offset }),
|
|
653
|
-
pagination,
|
|
654
|
-
);
|
|
655
|
-
```
|
|
656
|
-
|
|
657
|
-
React Query consumers install the optional `@tanstack/react-query` peer and use
|
|
658
|
-
the isolated `najm-kit/query` entry. `useResponsiveOffsetList` resolves numbered
|
|
659
|
-
desktop paging versus card continuation and exposes props that plug directly
|
|
660
|
-
into `NTable` and `createCardPagination`.
|
|
661
|
-
|
|
662
|
-
```tsx
|
|
663
|
-
import { NTable, createCardPagination } from 'najm-kit';
|
|
664
|
-
import { useResponsiveOffsetList } from 'najm-kit/query';
|
|
665
|
-
|
|
666
|
-
const list = useResponsiveOffsetList({
|
|
667
|
-
queryKey: ['orders'],
|
|
668
|
-
fetchPage: ({ limit, offset }) => api.orders.list({ limit, offset }),
|
|
669
|
-
strategy: 'paged',
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
<NTable
|
|
673
|
-
data={list.data}
|
|
674
|
-
columns={columns}
|
|
675
|
-
manualPagination
|
|
676
|
-
pageCount={list.pageCount}
|
|
677
|
-
pagination={list.pagination}
|
|
678
|
-
onPaginationChange={list.onPaginationChange}
|
|
679
|
-
cardPagination={createCardPagination(list, labels)}
|
|
680
|
-
/>
|
|
681
|
-
```
|
|
682
|
-
|
|
683
|
-
## Hooks
|
|
684
|
-
|
|
685
|
-
```tsx
|
|
686
|
-
import { useKeyboard } from 'najm-kit';
|
|
687
|
-
import { useDelayedLoading } from 'najm-kit';
|
|
688
|
-
import { useClickOutside } from 'najm-kit';
|
|
689
|
-
import { useDebouncedValue } from 'najm-kit';
|
|
690
|
-
import { useInfiniteScroll } from 'najm-kit';
|
|
691
|
-
import { useSelection } from 'najm-kit';
|
|
692
|
-
```
|
|
693
|
-
|
|
694
|
-
## Production Notes
|
|
695
|
-
|
|
696
|
-
- Designed for dashboard/admin UIs in Najm-powered applications
|
|
697
|
-
- Uses Radix UI primitives under the hood — accessible by default
|
|
698
|
-
- All components are unstyled by default — apply `buttonVariants()`, `badgeVariants()`, etc. with Tailwind
|
|
699
|
-
- Requires Tailwind CSS **v4** in the host application (see Styling above)
|
|
700
|
-
- CodeMirror components are optional peer deps — import from `najm-kit/json` only if needed
|
|
701
|
-
|
|
702
|
-
## NTable responsive columns
|
|
703
|
-
|
|
704
|
-
`NTable` accepts an `NTableColumnDef<T>[]`. Each column's `meta` can carry:
|
|
705
|
-
|
|
706
|
-
- `visible?: boolean` — app-owned eligibility gate. Defaults to `true`. Set
|
|
707
|
-
this from your role / capability decision. Columns with `visible: false`
|
|
708
|
-
are removed from headers, body cells, the loading skeleton, and the
|
|
709
|
-
column-settings menu.
|
|
710
|
-
- `hiddenBelow?: "sm" | "md" | "lg" | "xl" | "2xl"` — hide the table column
|
|
711
|
-
below the chosen Tailwind breakpoint. The column remains visible at that
|
|
712
|
-
breakpoint and above (mobile-first). Table view only.
|
|
713
|
-
|
|
714
|
-
```tsx
|
|
715
|
-
import { NTable, type NTableColumnDef } from "najm-kit";
|
|
716
|
-
|
|
717
|
-
const columns: NTableColumnDef<Family>[] = [
|
|
718
|
-
{ accessorKey: "name", header: "Family account" },
|
|
719
|
-
{
|
|
720
|
-
accessorKey: "email",
|
|
721
|
-
header: "Email",
|
|
722
|
-
meta: {
|
|
723
|
-
visible: can("families.email.read"),
|
|
724
|
-
hiddenBelow: "lg",
|
|
725
|
-
},
|
|
726
|
-
},
|
|
727
|
-
];
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
Notes:
|
|
731
|
-
|
|
732
|
-
- `visible` is **application-owned eligibility**, not an NTable role system.
|
|
733
|
-
`NTable` never imports `najm-auth` or reads a session; convert your own
|
|
734
|
-
role / capabilities to a boolean.
|
|
735
|
-
- Omitting `visible` is the same as `true`.
|
|
736
|
-
- `hiddenBelow` is table-only. Card view, JSON view, and custom modes
|
|
737
|
-
ignore it. Cards must do their own capability gating inside `renderCard`.
|
|
738
|
-
- Hiding a column is **presentation only**. The backend must still enforce
|
|
739
|
-
the permission and privacy-project the field. Never rely on UI hiding to
|
|
740
|
-
protect sensitive data.
|
|
741
|
-
- The user-controlled column visibility menu (settings → Columns) keeps
|
|
742
|
-
working independently. It can report a column as selected while CSS
|
|
743
|
-
hides it below the configured breakpoint.
|
|
744
|
-
- The columns the TanStack table receives are already filtered, so the
|
|
745
|
-
settings menu will not list `visible: false` columns.
|
|
746
|
-
|
|
747
|
-
If you need to inspect or build your own effective column list, the same
|
|
748
|
-
pure helper is exported as `filterResponsiveColumns`. The literal class
|
|
749
|
-
map is also exported as `hiddenBelowClasses`, and
|
|
750
|
-
`resolveHiddenBelowClass(breakpoint)` returns the class for a single
|
|
751
|
-
breakpoint or `undefined` when no breakpoint is set.
|
|
752
|
-
|
|
753
|
-
## NTable responsive cards, loading, and pagination
|
|
754
|
-
|
|
755
|
-
Responsive row actions are visible by default on phone, tablet, and coarse or
|
|
756
|
-
non-hover pointers. Fine-pointer desktop layouts may reveal them on hover, but
|
|
757
|
-
keyboard focus always reveals the action. Applications still decide which menu
|
|
758
|
-
items exist through `menu`, `onView`, `onEdit`, and `onDelete`; visibility does
|
|
759
|
-
not grant an action or replace server authorization.
|
|
760
|
-
|
|
761
|
-
When `dynamicHeight` is enabled, table and card loading skeletons measure the
|
|
762
|
-
available body. Table rows use the same header/row geometry as dynamic page
|
|
763
|
-
sizing, while cards measure the active grid columns, card height, and gap. The
|
|
764
|
-
loading surface also follows the loaded `bordered`, design recipe, radius,
|
|
765
|
-
border color, shadow, and `classNames.content`/`classNames.cards` contract.
|
|
766
|
-
The measured fit owns the initial page size. Once a reader explicitly chooses
|
|
767
|
-
Rows/page, `NTable` preserves that choice and scrolls the bounded table body
|
|
768
|
-
when the requested rows exceed the available height.
|
|
769
|
-
|
|
770
|
-
Use `cardPagination` to choose pagination presentation whenever the effective
|
|
771
|
-
rendered mode is cards:
|
|
772
|
-
|
|
773
|
-
- `{ mode: "paged" }` (the default) preserves existing pagination.
|
|
774
|
-
- `{ mode: "all" }` renders every row already supplied and hides the footer.
|
|
775
|
-
- `{ mode: "load-more", ... }` renders every supplied row and provides a
|
|
776
|
-
guarded, keyboard-operable Load more/Retry control with polite loading,
|
|
777
|
-
appended-result, and end-of-list announcements.
|
|
778
|
-
|
|
779
|
-
`showPagination={false}` remains an absolute presentation override and hides
|
|
780
|
-
both numbered controls and Load more. In table mode, existing controlled and
|
|
781
|
-
manual server pagination remains unchanged.
|
|
782
|
-
|
|
783
|
-
```tsx
|
|
784
|
-
import { NTable, type NTableCardPagination } from "najm-kit";
|
|
785
|
-
|
|
786
|
-
const cardPagination: NTableCardPagination = {
|
|
787
|
-
mode: "load-more",
|
|
788
|
-
hasNextPage: query.hasNextPage,
|
|
789
|
-
loadingMore: query.isFetchingNextPage,
|
|
790
|
-
loadMoreError: query.isFetchNextPageError
|
|
791
|
-
? "The next page could not be loaded."
|
|
792
|
-
: undefined,
|
|
793
|
-
onLoadMore: () => query.fetchNextPage(),
|
|
794
|
-
loadMoreLabel: "Load more",
|
|
795
|
-
loadingMoreLabel: "Loading more...",
|
|
796
|
-
retryLabel: "Retry",
|
|
797
|
-
endLabel: "No more results.",
|
|
798
|
-
};
|
|
799
|
-
|
|
800
|
-
<NTable
|
|
801
|
-
data={query.data?.pages.flatMap((page) => page.rows) ?? []}
|
|
802
|
-
columns={columns}
|
|
803
|
-
getRowId={(row) => row.id}
|
|
804
|
-
renderCard={ResultCard}
|
|
805
|
-
cardPagination={cardPagination}
|
|
806
|
-
/>
|
|
807
|
-
```
|
|
808
|
-
|
|
809
|
-
The application owns the query, cursor/offset, accumulated pages, cache
|
|
810
|
-
invalidation, search/filter/sort semantics, authorization, and privacy
|
|
811
|
-
projection. Najm Kit never imports React Query, calls an endpoint, invents a
|
|
812
|
-
page size, or treats supplied rows as proof that every database row is loaded.
|
|
813
|
-
Client sorting and filtering cover the rows currently supplied unless the
|
|
814
|
-
application implements matching server-side behavior.
|
|
815
|
-
|
|
816
|
-
For a responsive screen that uses current-page data in desktop table mode and
|
|
817
|
-
accumulated pages in card mode, keep those two query shapes in the application
|
|
818
|
-
and pass the appropriate `data`. Crossing the `<640px` responsive-card
|
|
819
|
-
breakpoint does not overwrite the user's chosen view, pagination position,
|
|
820
|
-
sorting, filters, expansion, or row selection.
|
|
821
|
-
|
|
822
|
-
## Theme-backed charts
|
|
823
|
-
|
|
824
|
-
`NBarChart`, `NLineChart`, `NPieChart`, and `NStatusBreakdown` accept generic
|
|
825
|
-
caller-formatted data and use `--chart-1` through `--chart-5` by default.
|
|
826
|
-
Colors repeat deterministically after the fifth series or item; set `color` on
|
|
827
|
-
an exceptional series/item to override that one value. Each chart accepts
|
|
828
|
-
`loading`/`loadingLabel` and renders an accessible shape-matched skeleton.
|
|
829
|
-
`NPieChart` and `NDonutCard` accept `size="sm" | "md" | "lg"` or a numeric
|
|
830
|
-
pixel diameter and shrink within narrow containers.
|
|
831
|
-
|
|
832
|
-
```tsx
|
|
833
|
-
import { NBarChart, NPieChart } from "najm-kit";
|
|
834
|
-
|
|
835
|
-
const data = [
|
|
836
|
-
{ id: "jan", label: "Jan", values: { received: 12, refunded: 2 } },
|
|
837
|
-
{ id: "feb", label: "Feb", values: { received: 18, refunded: 1 } },
|
|
838
|
-
];
|
|
839
|
-
|
|
840
|
-
<NBarChart
|
|
841
|
-
title="Monthly activity"
|
|
842
|
-
data={data}
|
|
843
|
-
series={[
|
|
844
|
-
{ id: "received", label: "Received" },
|
|
845
|
-
{ id: "refunded", label: "Refunded" },
|
|
846
|
-
]}
|
|
847
|
-
valueFormatter={(value) => `${value} MAD`}
|
|
848
|
-
/>
|
|
849
|
-
|
|
850
|
-
<NPieChart
|
|
851
|
-
title="Status"
|
|
852
|
-
size={132}
|
|
853
|
-
items={[
|
|
854
|
-
{ id: "active", label: "Active", value: 8 },
|
|
855
|
-
{ id: "pending", label: "Pending", value: 3 },
|
|
856
|
-
]}
|
|
857
|
-
/>
|
|
858
|
-
```
|
|
859
|
-
|
|
860
|
-
### Server-backed combobox search
|
|
861
|
-
|
|
862
|
-
`ComboboxInput` and `FormInput type="combobox"` can delegate filtering to a
|
|
863
|
-
server by setting `shouldFilter={false}` and handling `onSearchChange`. Use
|
|
864
|
-
`loading` and `loadingMessage` while replacement options are being fetched.
|
|
865
|
-
Client-side filtering remains the default.
|
|
866
|
-
|
|
867
|
-
## Person image fallbacks (`najm-kit/person-images`)
|
|
868
|
-
|
|
869
|
-
A framework-neutral, React-free subpath that resolves person-image fallbacks
|
|
870
|
-
for any application. The seven WebP illustrations are embedded as base64 data
|
|
871
|
-
URLs in the published bundle, so consumers do not need to copy package files
|
|
872
|
-
into `public/` or wire an asset server.
|
|
873
|
-
|
|
874
|
-
```ts
|
|
875
|
-
import { getPersonImage } from "najm-kit/person-images";
|
|
876
|
-
|
|
877
|
-
const childSrc = getPersonImage({
|
|
878
|
-
image: child.image,
|
|
879
|
-
role: "child",
|
|
880
|
-
gender: child.gender,
|
|
881
|
-
});
|
|
882
|
-
```
|
|
883
|
-
|
|
884
|
-
Built-in roles:
|
|
885
|
-
|
|
886
|
-
| Role | Default | Female | Male |
|
|
887
|
-
| -------- | ---------------- | --------------- | --------------- |
|
|
888
|
-
| `child` | male child art | female child | male child |
|
|
889
|
-
| `adult` | male adult art | female adult | male adult |
|
|
890
|
-
| `parent` | male parent art | female parent | male parent |
|
|
891
|
-
| `family` | neutral family | neutral family | neutral family |
|
|
892
|
-
|
|
893
|
-
Resolution precedence, for every call:
|
|
894
|
-
|
|
895
|
-
1. A real `image` (anything that survives `resolveAvatarSrc`).
|
|
896
|
-
2. A per-call `fallback` that is not blank and is not the `noavatar.png`
|
|
897
|
-
sentinel.
|
|
898
|
-
3. The configured role's gender variant, or the role's required `default`
|
|
899
|
-
when the variant or the gender is missing.
|
|
900
|
-
|
|
901
|
-
The per-call `fallback` is treated like a real source: an empty string, a
|
|
902
|
-
blank trimmed value, or any `noavatar.png` path falls through to the role
|
|
903
|
-
default. The Kafil data is a worked example: children use `role: "child"`,
|
|
904
|
-
households use `role: "family"`, sponsors, staff, applicants, and delivery
|
|
905
|
-
staff use `role: "adult"`, and a household parent uses `role: "parent"`
|
|
906
|
-
after the family dashboard maps its relationship value (`mother`, `mère`,
|
|
907
|
-
`madre`, `أم`, …) to `F`, `M`, or `null` at the feature boundary.
|
|
908
|
-
|
|
909
|
-
### Custom roles
|
|
910
|
-
|
|
911
|
-
`createPersonImageResolver` returns a typed resolver that accepts the
|
|
912
|
-
application's own role names. Unknown role strings fail type checking:
|
|
913
|
-
|
|
914
|
-
```ts
|
|
915
|
-
import { createPersonImageResolver } from "najm-kit/person-images";
|
|
916
|
-
|
|
917
|
-
const getSmsPersonImage = createPersonImageResolver({
|
|
918
|
-
teacher: {
|
|
919
|
-
default: "/images/teachers/default.webp",
|
|
920
|
-
female: "/images/teachers/female.webp",
|
|
921
|
-
male: "/images/teachers/male.webp",
|
|
922
|
-
},
|
|
923
|
-
student: {
|
|
924
|
-
default: "/images/students/default.webp",
|
|
925
|
-
female: "/images/students/female.webp",
|
|
926
|
-
male: "/images/students/male.webp",
|
|
927
|
-
},
|
|
928
|
-
});
|
|
929
|
-
|
|
930
|
-
const teacherSrc = getSmsPersonImage({
|
|
931
|
-
image: teacher.image,
|
|
932
|
-
role: "teacher",
|
|
933
|
-
gender: teacher.gender,
|
|
934
|
-
});
|
|
935
|
-
```
|
|
936
|
-
|
|
937
|
-
The factory merges custom definitions over the built-in map. A custom `child`
|
|
938
|
-
override replaces the built-in child art for that application alone — the
|
|
939
|
-
package itself is untouched, and other consumers keep their built-in
|
|
940
|
-
fallbacks.
|
|
941
|
-
|
|
942
|
-
Custom paths may be application-relative URLs, managed API URLs, CDN URLs,
|
|
943
|
-
or data URLs. najm-kit does not fetch, upload, authorize, or persist them.
|
|
944
|
-
|
|
945
|
-
### Per-call fallback override
|
|
946
|
-
|
|
947
|
-
Every call accepts a `fallback`. It overrides the role default for that call
|
|
948
|
-
only, after a real `image` and before the role's gender variant:
|
|
949
|
-
|
|
950
|
-
```ts
|
|
951
|
-
getPersonImage({ image: child.image, role: "child", gender: child.gender, fallback: child.placeholder });
|
|
952
|
-
```
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
## Server UI bootstrap (`najm-kit/server`, `najm-kit/server/react`)
|
|
956
|
-
|
|
957
|
-
An application that renders its own theme and its own logos on the server ends
|
|
958
|
-
up writing the same module every time: fetch the public endpoints, unwrap the
|
|
959
|
-
`data` envelope, validate the payload, fall back to the built-in assets when
|
|
960
|
-
any of that fails, and run the resources in parallel. These two entries own
|
|
961
|
-
that mechanism. What stays with the application is what is genuinely
|
|
962
|
-
application-specific — how a request reaches its own backend, which paths it
|
|
963
|
-
serves, what a valid payload looks like, what the factory values are, and where
|
|
964
|
-
a diagnostic goes.
|
|
965
|
-
|
|
966
|
-
Neither entry is re-exported from `najm-kit`, `najm-kit/next`, or
|
|
967
|
-
`najm-kit/app`. `najm-kit/server` imports no React at all, so a route handler
|
|
968
|
-
or a plain script can use it.
|
|
969
|
-
|
|
970
|
-
### The application's one server module
|
|
971
|
-
|
|
972
|
-
```ts
|
|
973
|
-
// src/lib/serverLoader.ts
|
|
974
|
-
import "server-only";
|
|
975
|
-
|
|
976
|
-
import { parseNajmDesignConfig } from "najm-kit/server";
|
|
977
|
-
import { createReactServerUiBootstrap } from "najm-kit/server/react";
|
|
978
|
-
|
|
979
|
-
export const serverUi = createReactServerUiBootstrap({
|
|
980
|
-
fetcher: async (path) => {
|
|
981
|
-
const { server } = await import("@app/server");
|
|
982
|
-
return server.fetch(new Request(`http://internal${path}`));
|
|
983
|
-
},
|
|
984
|
-
resources: {
|
|
985
|
-
appearance: {
|
|
986
|
-
path: "/api/appearance",
|
|
987
|
-
parse: parseAppearance, // returns undefined or throws to reject
|
|
988
|
-
fallback: getFactoryAppearance, // called per load
|
|
989
|
-
},
|
|
990
|
-
branding: {
|
|
991
|
-
path: "/api/branding",
|
|
992
|
-
parse: parseBranding,
|
|
993
|
-
fallback: getFactoryBranding,
|
|
994
|
-
},
|
|
995
|
-
},
|
|
996
|
-
onDiagnostic: (diagnostic) => {
|
|
997
|
-
console.warn(`[ui-bootstrap] ${diagnostic.resource} ${diagnostic.reason}`, diagnostic);
|
|
998
|
-
},
|
|
999
|
-
});
|
|
1000
|
-
|
|
1001
|
-
export const loadServerUiBootstrap = serverUi.load;
|
|
1002
|
-
export const { appearance: loadServerAppearance, branding: loadServerBranding } =
|
|
1003
|
-
serverUi.loaders;
|
|
1004
|
-
```
|
|
1005
|
-
|
|
1006
|
-
`load()` resolves every resource; `loaders.<name>()` and `loadResource(name)`
|
|
1007
|
-
read one off the same resolution. Resource names, payload types, and the number
|
|
1008
|
-
of resources are the application's — the snapshot type is inferred from the
|
|
1009
|
-
`resources` object, so `snapshot.branding` is your branding type and not a
|
|
1010
|
-
package interface.
|
|
1011
|
-
|
|
1012
|
-
### Call the factory once, at module scope
|
|
1013
|
-
|
|
1014
|
-
`createReactServerUiBootstrap()` builds one `React.cache()` entry. Calling it
|
|
1015
|
-
inside a layout, page, or component builds a fresh one per call and shares
|
|
1016
|
-
nothing. Every server boundary in a render must import the same module.
|
|
1017
|
-
|
|
1018
|
-
The cache is React's, so it is request-scoped and nothing else: separate
|
|
1019
|
-
requests never see each other's snapshot or each other's failure, and a
|
|
1020
|
-
transient outage is retried on the next request rather than pinned into a
|
|
1021
|
-
process-global. That also rules out a module `Map`, a module promise,
|
|
1022
|
-
`unstable_cache`, `"use cache"`, or a durable cache here — every one of them
|
|
1023
|
-
would leak one visitor's render into another's.
|
|
1024
|
-
|
|
1025
|
-
The snapshot is deliberately stable for the length of one render. A settings
|
|
1026
|
-
surface that saves appearance or branding updates the client provider and then
|
|
1027
|
-
refreshes or navigates into a new render to observe the persisted result.
|
|
1028
|
-
|
|
1029
|
-
Outside a render — route handlers, server actions, scripts — use
|
|
1030
|
-
`createUiBootstrapLoader()` from `najm-kit/server` directly. There is no request
|
|
1031
|
-
cache for `cache()` to write to there, so the adapter would silently re-fetch
|
|
1032
|
-
per call.
|
|
1033
|
-
|
|
1034
|
-
### Failure behaviour
|
|
1035
|
-
|
|
1036
|
-
Resources fall back independently: a branding outage never discards a valid
|
|
1037
|
-
appearance. Each failure calls `onDiagnostic` once with a `reason` of
|
|
1038
|
-
`fetch-failed`, `response-not-ok`, `invalid-json`, `invalid-envelope`, or
|
|
1039
|
-
`invalid-payload`, plus the path and — for a non-success response — the status.
|
|
1040
|
-
Diagnostics never carry response bodies, headers, cookies, or raw thrown
|
|
1041
|
-
values; `error` is a normalized `"<name>: <message>"` for an `Error` and the
|
|
1042
|
-
value's type for anything else.
|
|
1043
|
-
|
|
1044
|
-
A `fallback()` that throws is **not** caught. A missing factory theme is the
|
|
1045
|
-
application's configuration error, and a second fallback would only hide it.
|
|
1046
|
-
|
|
1047
|
-
Falling back is right for *public* appearance and branding, where the worst case
|
|
1048
|
-
is a visitor seeing the built-in logo. It is not a general rule: do not route
|
|
1049
|
-
authenticated, financial, or privacy-sensitive reads through this, because a
|
|
1050
|
-
silent fallback there hides an outage behind plausible-looking data.
|
|
1051
|
-
|
|
1052
|
-
### Envelopes
|
|
1053
|
-
|
|
1054
|
-
`select` defaults to Najm's `{ data }` envelope. Applications behind a different
|
|
1055
|
-
envelope pass their own at the loader level or per resource; returning the
|
|
1056
|
-
payload unchanged is a valid selector, and throwing rejects the response as
|
|
1057
|
-
`invalid-envelope`.
|
|
1058
|
-
|
|
1059
|
-
### Client Components
|
|
1060
|
-
|
|
1061
|
-
`najm-kit/server/react` maps the `browser` export condition to a module that
|
|
1062
|
-
throws, so importing it from a Client Component fails the build with an
|
|
1063
|
-
explanation rather than shipping the application's fetcher and factory values
|
|
1064
|
-
into a browser bundle. Seed the client from the server snapshot through
|
|
1065
|
-
`NajmAppProvider` instead.
|
|
1
|
+
# najm-kit
|
|
2
|
+
|
|
3
|
+
Reusable React component library for Najm applications. Provides themed UI primitives, hooks, and form components.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add najm-kit tailwindcss @tailwindcss/postcss
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependencies: `react >=18`, `react-dom >=18`. Requires **Tailwind CSS v4** in the host app.
|
|
12
|
+
|
|
13
|
+
Optional peer dependencies: `recharts`, `@tanstack/react-table`, `react-hook-form`, `@tanstack/react-query`.
|
|
14
|
+
|
|
15
|
+
## Styling — the entire setup
|
|
16
|
+
|
|
17
|
+
najm-kit is a Tailwind v4, shadcn-compatible library. PostCSS config (`postcss.config.mjs`):
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
export default { plugins: { "@tailwindcss/postcss": {} } };
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Your global stylesheet — **two imports, that's it**:
|
|
24
|
+
|
|
25
|
+
```css
|
|
26
|
+
@import "tailwindcss";
|
|
27
|
+
@import "najm-kit/theme.css";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This gives you every najm-kit component styled, dark mode wired (the `.dark` class),
|
|
31
|
+
and a full token-backed palette you can use in your own markup too
|
|
32
|
+
(`bg-background`, `bg-card`, `bg-primary`, `text-muted-foreground`, `border-border`, …).
|
|
33
|
+
|
|
34
|
+
### Theming
|
|
35
|
+
|
|
36
|
+
najm-kit uses the **standard shadcn token names** (no prefix), so you rebrand by
|
|
37
|
+
overriding CSS variables — or paste a theme straight from
|
|
38
|
+
[tweakcn](https://tweakcn.com) / the shadcn registry:
|
|
39
|
+
|
|
40
|
+
```css
|
|
41
|
+
:root { --primary: oklch(0.55 0.2 290); --radius: 0.75rem; }
|
|
42
|
+
.dark { --primary: oklch(0.70 0.18 290); }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Add your own extra colors alongside najm-kit's:
|
|
46
|
+
|
|
47
|
+
```css
|
|
48
|
+
@theme { --color-success: oklch(0.7 0.18 150); } /* → bg-success, text-success */
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Dark mode: toggle the `dark` class on `<html>` (or any wrapper):
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
document.documentElement.classList.toggle("dark");
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Theme Provider (optional)
|
|
58
|
+
|
|
59
|
+
For scoped theming without writing CSS — useful for embedded surfaces. The provider
|
|
60
|
+
is opt-in: with no props it injects nothing and your `:root`/`.dark` CSS owns theming.
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
import { NajmThemeProvider } from 'najm-kit';
|
|
64
|
+
|
|
65
|
+
// preset:
|
|
66
|
+
<NajmThemeProvider preset="dark-blue">{children}</NajmThemeProvider>
|
|
67
|
+
|
|
68
|
+
// or mode + accent:
|
|
69
|
+
<NajmThemeProvider mode="dark" accent="emerald">{children}</NajmThemeProvider>
|
|
70
|
+
|
|
71
|
+
// shadcn-style global radius scale:
|
|
72
|
+
<NajmThemeProvider radius="0.75rem">{children}</NajmThemeProvider>
|
|
73
|
+
|
|
74
|
+
// exact same radius for cards, tables, buttons, inputs, dialogs, etc.:
|
|
75
|
+
<NajmThemeProvider radius="0.75rem">
|
|
76
|
+
{children}
|
|
77
|
+
</NajmThemeProvider>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`rounded-full` and `rounded-none` remain explicit, so avatars, pills, switches,
|
|
81
|
+
and square variants keep their intended shape.
|
|
82
|
+
|
|
83
|
+
### JSON theme settings
|
|
84
|
+
|
|
85
|
+
Store one theme object in a JSON file, local storage, or your settings API:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"mode": "dark",
|
|
90
|
+
"accent": "violet",
|
|
91
|
+
"radius": "0.75rem",
|
|
92
|
+
"appearance": { "borderWidth": "1px" },
|
|
93
|
+
"tokens": {
|
|
94
|
+
"primary": "oklch(0.62 0.2 290)",
|
|
95
|
+
"primary-foreground": "oklch(1 0 0)",
|
|
96
|
+
"sidebar": "oklch(0.18 0.02 290)",
|
|
97
|
+
"chart-1": "oklch(0.70 0.20 40)"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Load and apply it from the same settings state used by your theme editor:
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
import rawTheme from './theme.json';
|
|
106
|
+
import { NajmThemeProvider, parseNajmThemeConfig } from 'najm-kit';
|
|
107
|
+
|
|
108
|
+
const initialTheme = parseNajmThemeConfig(rawTheme);
|
|
109
|
+
|
|
110
|
+
function App() {
|
|
111
|
+
const [theme, setTheme] = useState(initialTheme);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<NajmThemeProvider config={theme}>
|
|
115
|
+
<SettingsPage value={theme} onChange={setTheme} />
|
|
116
|
+
{children}
|
|
117
|
+
</NajmThemeProvider>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Changing the state updates the complete theme immediately. Use
|
|
123
|
+
`stringifyNajmThemeConfig(theme)` when persisting it, and parse settings loaded
|
|
124
|
+
from an API or local storage with `parseNajmThemeConfig` before applying them.
|
|
125
|
+
|
|
126
|
+
## Components
|
|
127
|
+
|
|
128
|
+
Import from `najm-kit`:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { NButton, buttonVariants } from 'najm-kit';
|
|
132
|
+
import { Input } from 'najm-kit';
|
|
133
|
+
import { Card, CardHeader, CardTitle, CardContent } from 'najm-kit';
|
|
134
|
+
import { Dialog, DialogContent, DialogTrigger } from 'najm-kit';
|
|
135
|
+
import { DataTable } from 'najm-kit';
|
|
136
|
+
import { Form, FormInput, useNForm } from 'najm-kit';
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Available Primitives
|
|
140
|
+
|
|
141
|
+
| Category | Components |
|
|
142
|
+
|----------|-----------|
|
|
143
|
+
| Actions | NButton, IconButton, toggleVariants |
|
|
144
|
+
| Forms | Input, Textarea, Label, Select, Checkbox, RadioGroup, Switch, DateInput, FileInput, ImageInput, AvatarInput |
|
|
145
|
+
| Feedback | Alert, Badge, Progress, Spinner, Toast, NLoadingState, NErrorState, NEmptyState, NForbiddenState, NNotFoundState |
|
|
146
|
+
| Layout | Card, Sheet, Dialog, Popover, DropdownMenu, Tabs |
|
|
147
|
+
| Data | Table (NTable), StatCard, DetailList, CredentialsCard |
|
|
148
|
+
| Overlays | Command palette, Tooltip, Toast |
|
|
149
|
+
|
|
150
|
+
## Images and avatars
|
|
151
|
+
|
|
152
|
+
Three components, one fallback rule. Each tries its sources in order, tries a
|
|
153
|
+
source at most once, and discards what it knows about a failure the moment the
|
|
154
|
+
sources change.
|
|
155
|
+
|
|
156
|
+
### `NImage` — plain `<img>`
|
|
157
|
+
|
|
158
|
+
For a logo or an icon whose box the caller's CSS already owns. No layout is
|
|
159
|
+
invented, and `onError` is forwarded rather than swallowed.
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import { NImage } from 'najm-kit';
|
|
163
|
+
|
|
164
|
+
<NImage src={logo} fallback="/brand/logo.svg" alt="Acme" className="h-8 w-auto" />
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### `NAvatar` — person or record
|
|
168
|
+
|
|
169
|
+
The image is a native `<img>` loaded directly by the browser, so a same-origin
|
|
170
|
+
protected route works with the session the page already has, and the package
|
|
171
|
+
needs no knowledge of which routes are protected.
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
import { NAvatar } from 'najm-kit';
|
|
175
|
+
|
|
176
|
+
<NAvatar
|
|
177
|
+
src={member.image}
|
|
178
|
+
fallbackSrc={stockPortrait}
|
|
179
|
+
version={member.imageRevision}
|
|
180
|
+
title={member.name}
|
|
181
|
+
subtitle={member.role}
|
|
182
|
+
size="lg"
|
|
183
|
+
/>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
- The primary source is tried first, then `fallbackSrc`, then the initials.
|
|
187
|
+
- `version` (or `srcVersion`) is appended as `?v=…` to every remote source, so a
|
|
188
|
+
re-upload is not served from cache. `data:` and `blob:` sources are left alone.
|
|
189
|
+
- Initials stay visible until an image paints and come back if every source
|
|
190
|
+
fails — a transparent PNG never shows letters through itself.
|
|
191
|
+
- `imageProps` reaches the element for `loading`, `sizes`, `crossOrigin`,
|
|
192
|
+
`referrerPolicy`, and the load/error handlers. It defaults to `loading="lazy"`,
|
|
193
|
+
and supplied handlers are composed with the fallback chain rather than
|
|
194
|
+
replacing it.
|
|
195
|
+
|
|
196
|
+
### `NNextImage` — optimized, from `najm-kit/next`
|
|
197
|
+
|
|
198
|
+
Same fallback contract with Next's optimizer, layout reservation, `fill`, and
|
|
199
|
+
`sizes`. It lives only in the `najm-kit/next` entry, because the root package
|
|
200
|
+
stays installable without Next.
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
import { NNextImage } from 'najm-kit/next';
|
|
204
|
+
|
|
205
|
+
// A public asset: let the optimizer resize and re-encode it.
|
|
206
|
+
<NNextImage src="/covers/spring.png" alt="Spring" width={64} height={64} />
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
For an asset the browser must fetch directly — one behind an authenticated route,
|
|
210
|
+
typically — the *application* says so:
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
<NNextImage
|
|
214
|
+
src={record.image}
|
|
215
|
+
alt={record.name}
|
|
216
|
+
fill
|
|
217
|
+
sizes="64px"
|
|
218
|
+
unoptimized
|
|
219
|
+
/>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`unoptimized` is passed at the call site rather than inferred from the URL:
|
|
223
|
+
which routes are protected is the application's fact, not something a package
|
|
224
|
+
can read off a path. It changes delivery mechanics only — session validation,
|
|
225
|
+
permissions, privacy projection, and what bytes come back all remain the
|
|
226
|
+
backend's.
|
|
227
|
+
|
|
228
|
+
## Status badges
|
|
229
|
+
|
|
230
|
+
`<NBadge status="…" />` already maps a broad lifecycle vocabulary onto the
|
|
231
|
+
semantic colors, so it is correct without configuration:
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
import { NBadge } from 'najm-kit';
|
|
235
|
+
|
|
236
|
+
<NBadge status="out_for_delivery" /> // warning, "Out For Delivery"
|
|
237
|
+
<NBadge status="nebulous" /> // neutral, "Nebulous"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
What an application usually adds on top is the same three things at every call
|
|
241
|
+
site: its look, its shape, and its own translated label. Declare them once:
|
|
242
|
+
|
|
243
|
+
```tsx
|
|
244
|
+
<NajmAppProvider
|
|
245
|
+
badgeDefaults={{
|
|
246
|
+
look: 'soft',
|
|
247
|
+
shape: 'pill',
|
|
248
|
+
statusLabelKeys: {
|
|
249
|
+
active: 'status.active',
|
|
250
|
+
out_for_delivery: 'status.outForDelivery',
|
|
251
|
+
},
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`badgeDefaults` lives on `NajmUIProvider` and is inherited by
|
|
257
|
+
`NajmNextUIProvider` and `NajmAppProvider`, so there is one place to set it.
|
|
258
|
+
The keys are the application's catalog keys, resolved through the same `t` the
|
|
259
|
+
provider already has — this package ships no status catalog. A language change
|
|
260
|
+
recomputes every label without a remount.
|
|
261
|
+
|
|
262
|
+
Resolution, most specific first:
|
|
263
|
+
|
|
264
|
+
1. An explicit prop beats every provider default.
|
|
265
|
+
2. `label` beats string children; string children beat the provider's label.
|
|
266
|
+
3. A `statusLabels` literal beats a `statusLabelKeys` catalog lookup.
|
|
267
|
+
4. An unmapped status is humanized (`pending_review` → `Pending Review`).
|
|
268
|
+
5. A per-instance `statusMap`/`iconMap` merges over the provider's, so
|
|
269
|
+
overriding one status costs one status.
|
|
270
|
+
6. Provider status defaults apply **only** when `status` is set —
|
|
271
|
+
`<NBadge>Beta</NBadge>` keeps the ordinary content-badge look.
|
|
272
|
+
|
|
273
|
+
Statuses are matched through one rule, exported as `normalizeStatusToken`, so
|
|
274
|
+
`Out-For-Delivery `, `out for delivery`, and `out_for_delivery` are the same
|
|
275
|
+
key for colors, icons, and labels alike. Badge text is presentation: it renames
|
|
276
|
+
nothing in the backend and validates no lifecycle transition.
|
|
277
|
+
|
|
278
|
+
## Feedback states
|
|
279
|
+
|
|
280
|
+
Five public state components cover the reusable cases every application
|
|
281
|
+
otherwise repeats: `NLoadingState`, `NErrorState`, `NEmptyState`,
|
|
282
|
+
`NForbiddenState`, and `NNotFoundState`. They share one layout frame and one
|
|
283
|
+
provider-defaults channel, so an application configures its copy once and
|
|
284
|
+
every consumer below inherits it.
|
|
285
|
+
|
|
286
|
+
### Surfaces
|
|
287
|
+
|
|
288
|
+
Three layouts, one prop. `surface` selects the frame:
|
|
289
|
+
|
|
290
|
+
| `surface` | Use it for | What it does |
|
|
291
|
+
| --- | --- | --- |
|
|
292
|
+
| `"inline"` (default) | A small slot inside an existing component | Legacy sizing, no landmark |
|
|
293
|
+
| `"panel"` | A table body, card body, dialog, or sheet | Centered with a minimum height, no page gutter, no landmark |
|
|
294
|
+
| `"page"` | A real route-level state | Uses page spacing from the design config; renders through a non-`<main>` root |
|
|
295
|
+
|
|
296
|
+
`NLoadingState.fullScreen` keeps its fixed viewport overlay regardless of
|
|
297
|
+
surface — it always wins.
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
import { NLoadingState, NErrorState, NEmptyState } from 'najm-kit';
|
|
301
|
+
|
|
302
|
+
// Inline (default): drop into a card or section.
|
|
303
|
+
<NLoadingState label="Loading orders..." />
|
|
304
|
+
|
|
305
|
+
// Panel: table body or dialog content.
|
|
306
|
+
<NEmptyState surface="panel" title="No orders yet" icon={Inbox} />
|
|
307
|
+
|
|
308
|
+
// Page: route-level empty state. Never introduces a second <main>.
|
|
309
|
+
<NErrorState
|
|
310
|
+
surface="page"
|
|
311
|
+
title="Dashboard unavailable"
|
|
312
|
+
message="We are working on it."
|
|
313
|
+
onRetry={() => refetch()}
|
|
314
|
+
/>
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Provider defaults
|
|
318
|
+
|
|
319
|
+
Pass one `feedbackDefaults` map to `NajmUIProvider` (or to `NajmAppProvider`
|
|
320
|
+
through it) and every feedback state beneath uses it. There is one place for
|
|
321
|
+
loading, empty, error, retry, forbidden, and not-found labels, and a single
|
|
322
|
+
language change recomputes them all without remounting the tree.
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
import { NajmAppProvider } from 'najm-kit/app';
|
|
326
|
+
|
|
327
|
+
<NajmAppProvider
|
|
328
|
+
feedbackDefaults={{
|
|
329
|
+
labels: {
|
|
330
|
+
loadingLabel: 'Chargement…',
|
|
331
|
+
emptyTitle: 'Aucune donnée',
|
|
332
|
+
errorTitle: 'Une erreur est survenue',
|
|
333
|
+
retryLabel: 'Réessayer',
|
|
334
|
+
forbiddenTitle: 'Accès refusé',
|
|
335
|
+
forbiddenDescription: 'Vous n\'avez pas la permission.',
|
|
336
|
+
notFoundTitle: 'Page introuvable',
|
|
337
|
+
notFoundDescription: 'La page demandée n\'existe pas.',
|
|
338
|
+
},
|
|
339
|
+
labelKeys: {
|
|
340
|
+
emptyTitle: 'common.empty',
|
|
341
|
+
errorTitle: 'common.error',
|
|
342
|
+
},
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
<App />
|
|
346
|
+
</NajmAppProvider>
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Resolution order, most specific first:
|
|
350
|
+
|
|
351
|
+
1. An explicit component prop.
|
|
352
|
+
2. A literal in `feedbackDefaults.labels`.
|
|
353
|
+
3. A translated `feedbackDefaults.labelKeys` value resolved through the
|
|
354
|
+
provider's existing structural `t` function.
|
|
355
|
+
4. `` `<prefix>.<field>` `` resolved through the same `t`, where `prefix`
|
|
356
|
+
defaults to `common.feedback`.
|
|
357
|
+
5. The current packaged English fallback, when that field has one.
|
|
358
|
+
|
|
359
|
+
#### The prefix convention
|
|
360
|
+
|
|
361
|
+
Step 4 is the reason most applications need no `feedbackDefaults` at all. Name
|
|
362
|
+
the nine catalog entries after the fields — `common.feedback.emptyTitle`,
|
|
363
|
+
`common.feedback.retryLabel`, and so on — and a provider that already has a
|
|
364
|
+
translator resolves every feedback state with no mapping object to write or
|
|
365
|
+
memoize:
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
<NajmAppProvider translations={translations} initialLanguage="fr">
|
|
369
|
+
<App />
|
|
370
|
+
</NajmAppProvider>
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Use `prefix` to point at a different branch, and `FeedbackKey<Prefix>` to type
|
|
374
|
+
a translator against exactly those nine keys:
|
|
375
|
+
|
|
376
|
+
```tsx
|
|
377
|
+
import type { FeedbackKey } from 'najm-kit';
|
|
378
|
+
|
|
379
|
+
<NajmAppProvider feedbackDefaults={{ prefix: 'app.states' }}>
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Unlike `buildToolbarLabels` and `buildPaginationLabels`, a translator result
|
|
383
|
+
equal to the key it was handed is treated as *missing* here rather than
|
|
384
|
+
rendered. The prefix is a convention an application may never have adopted, so
|
|
385
|
+
an unanswered key falls through to packaged English instead of painting
|
|
386
|
+
`common.feedback.emptyTitle` across an empty state. The same rule applies to an
|
|
387
|
+
explicit `labelKeys` entry, which makes a typo in the mapping degrade to English
|
|
388
|
+
rather than to visible key text.
|
|
389
|
+
|
|
390
|
+
Generic `NErrorState.message` and `NEmptyState.description` deliberately have
|
|
391
|
+
no packaged fallback — the no-provider render must look the same as it did
|
|
392
|
+
before this contract shipped. A configured `errorMessage` opts the generic
|
|
393
|
+
error state into rendering a body; absent that opt-in, the existing
|
|
394
|
+
no-body render is preserved.
|
|
395
|
+
|
|
396
|
+
### Forbidden and not-found
|
|
397
|
+
|
|
398
|
+
Two first-class preset states for the routes every application grows:
|
|
399
|
+
|
|
400
|
+
```tsx
|
|
401
|
+
import { NForbiddenState, NNotFoundState } from 'najm-kit';
|
|
402
|
+
|
|
403
|
+
// Forbidden: provider copy + ShieldOff icon + page surface by default.
|
|
404
|
+
<NForbiddenState
|
|
405
|
+
action={<Link href="/dashboard">Back to dashboard</Link>}
|
|
406
|
+
/>
|
|
407
|
+
|
|
408
|
+
// Not found: provider copy + Compass icon + page surface by default.
|
|
409
|
+
<NNotFoundState
|
|
410
|
+
action={<Link href="/dashboard">Back to dashboard</Link>}
|
|
411
|
+
/>
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Both are presentation only. They do not know the dashboard URL, render a
|
|
415
|
+
Next `Link`, redirect, or write route metadata — those belong to the
|
|
416
|
+
application's `not-found.tsx` / `forbidden/page.tsx` files.
|
|
417
|
+
|
|
418
|
+
### Root and `najm-kit/app` imports
|
|
419
|
+
|
|
420
|
+
Both entries export the same five state components. Pick the one that matches
|
|
421
|
+
your boundary:
|
|
422
|
+
|
|
423
|
+
```tsx
|
|
424
|
+
// Client feature code: import from the root barrel.
|
|
425
|
+
import { NEmptyState } from 'najm-kit';
|
|
426
|
+
|
|
427
|
+
// Next Server Component route: import from najm-kit/app, which is the
|
|
428
|
+
// Client Component boundary. A route file can render a state component
|
|
429
|
+
// without authoring a local "use client" wrapper.
|
|
430
|
+
import { NNotFoundState } from 'najm-kit/app';
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
## Global form development tools
|
|
434
|
+
|
|
435
|
+
Enable schema-driven test values once on the full application provider. Every
|
|
436
|
+
`NForm` and `WizardForm` below it then fills from its Zod schema when F8 is
|
|
437
|
+
pressed; applications do not need a second provider or a form-fill helper.
|
|
438
|
+
|
|
439
|
+
```tsx
|
|
440
|
+
import { NajmAppProvider } from "najm-kit/app";
|
|
441
|
+
|
|
442
|
+
<NajmAppProvider formDevTools>
|
|
443
|
+
<App />
|
|
444
|
+
</NajmAppProvider>;
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Pass a boolean to control it from application settings:
|
|
448
|
+
|
|
449
|
+
```tsx
|
|
450
|
+
<NajmAppProvider formDevTools={formFillEnabled}>
|
|
451
|
+
<App />
|
|
452
|
+
</NajmAppProvider>
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Forms with live relation options can override only those fields. The provider
|
|
456
|
+
still owns enablement and Najm Kit still owns schema traversal and generation.
|
|
457
|
+
|
|
458
|
+
```tsx
|
|
459
|
+
<NForm
|
|
460
|
+
schema={orderSchema}
|
|
461
|
+
devTools={{ overrides: { customerId: customerOptions } }}
|
|
462
|
+
onSubmit={saveOrder}
|
|
463
|
+
>
|
|
464
|
+
{/* fields */}
|
|
465
|
+
</NForm>
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
## ImageInput and AvatarInput
|
|
469
|
+
|
|
470
|
+
`ImageInput` and `AvatarInput` ship with a resilient preview contract so
|
|
471
|
+
consumers do not need to wrap them with application-specific preview
|
|
472
|
+
components.
|
|
473
|
+
|
|
474
|
+
Source precedence:
|
|
475
|
+
|
|
476
|
+
- When `value` is a non-empty string URL, candidates are tried in order:
|
|
477
|
+
1. `value` is the primary preview source.
|
|
478
|
+
2. If the primary source fails, `fallbackImage` is tried when supplied.
|
|
479
|
+
3. `defaultImage` is the last-resort fallback.
|
|
480
|
+
- When `value` is `null` or empty, only `defaultImage` is tracked. The
|
|
481
|
+
`fallbackImage` is intentionally not used in the empty state — a null
|
|
482
|
+
`value` is the consumer's empty-state signal, and only the configured
|
|
483
|
+
default participates in the failed-default → unavailable transition.
|
|
484
|
+
If `defaultImage` itself fails, `onPreviewError({ source: "default" })`
|
|
485
|
+
fires and the unavailable state is rendered.
|
|
486
|
+
|
|
487
|
+
Candidate URLs are deduplicated so the same failing URL is never retried
|
|
488
|
+
through multiple stages. When every candidate fails, the broken `<img>` is
|
|
489
|
+
unmounted and `unavailableContent` (or a neutral default) is rendered in its
|
|
490
|
+
place. A `data-image-input-state="empty" | "preview" | "fallback" | "unavailable"`
|
|
491
|
+
marker is exposed for styling, testing, and consumer diagnostics.
|
|
492
|
+
|
|
493
|
+
Candidate URLs are deduplicated so the same failing URL is never retried
|
|
494
|
+
through multiple stages. When every candidate fails, the broken `<img>` is
|
|
495
|
+
unmounted and `unavailableContent` (or a neutral default) is rendered in its
|
|
496
|
+
place. A `data-image-input-state="empty" | "preview" | "fallback" | "unavailable"`
|
|
497
|
+
marker is exposed for styling, testing, and consumer diagnostics.
|
|
498
|
+
|
|
499
|
+
```tsx
|
|
500
|
+
import { ImageInput } from "najm-kit";
|
|
501
|
+
|
|
502
|
+
<ImageInput
|
|
503
|
+
value="https://cdn.example.com/avatar.png"
|
|
504
|
+
onChange={setAvatar}
|
|
505
|
+
previewAlt="Workspace logo"
|
|
506
|
+
fallbackImage="/assets/logo-default.png"
|
|
507
|
+
fallbackAlt="Default workspace logo"
|
|
508
|
+
unavailableContent={<span>Logo unavailable</span>}
|
|
509
|
+
imageClassName="object-contain"
|
|
510
|
+
imageVersion={cacheBustVersion}
|
|
511
|
+
replaceAriaLabel="Replace workspace logo"
|
|
512
|
+
clearAriaLabel="Remove workspace logo"
|
|
513
|
+
onPreviewError={(err) => log(err)}
|
|
514
|
+
/>
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Key behaviors:
|
|
518
|
+
|
|
519
|
+
- The replace and clear controls are real `<button>` elements, are reachable
|
|
520
|
+
with the keyboard (`Enter` and `Space` activate them once), and stay
|
|
521
|
+
visible on touch and coarse-pointer devices. Only on `(hover: hover) and
|
|
522
|
+
(pointer: fine)` desktops do the controls fall back to a hover/focus
|
|
523
|
+
reveal. `focus-visible` always restores visibility.
|
|
524
|
+
- Positioning uses logical properties (`end-*`) so the clear button works
|
|
525
|
+
correctly in RTL layouts.
|
|
526
|
+
- `imageVersion` is appended safely to relative, absolute, queried, and
|
|
527
|
+
fragmented URLs. `data:`, `blob:`, `javascript:`, and `file:` URLs are
|
|
528
|
+
left unchanged.
|
|
529
|
+
- File selection is race-safe: stale `FileReader` completions cannot replace
|
|
530
|
+
a newer value, and object URLs created by the component are tracked so
|
|
531
|
+
consumer-owned blob URLs are never revoked.
|
|
532
|
+
|
|
533
|
+
`AvatarInput` forwards every preview and accessibility prop unchanged while
|
|
534
|
+
preserving its circular, size, fill, and camera-icon defaults.
|
|
535
|
+
|
|
536
|
+
## Credentials handover
|
|
537
|
+
|
|
538
|
+
`NCredentialsCard` renders the recurring "show a freshly generated secret
|
|
539
|
+
once, let the operator hand it over, never show it again" surface. It owns
|
|
540
|
+
the frame, the description-list semantics, the copy flow, the failure
|
|
541
|
+
handling, and the accessible feedback. Every domain label — title,
|
|
542
|
+
description, field labels, action labels, and any translated toast — stays
|
|
543
|
+
with the application.
|
|
544
|
+
|
|
545
|
+
```tsx
|
|
546
|
+
import { NCredentialsCard, NButton } from "najm-kit";
|
|
547
|
+
import { KeyRound, Phone } from "lucide-react";
|
|
548
|
+
|
|
549
|
+
<NCredentialsCard
|
|
550
|
+
title={t("staff.access.created")}
|
|
551
|
+
description={t("staff.access.oneTimeHint")}
|
|
552
|
+
fields={[
|
|
553
|
+
{ label: t("common.phone"), value: credentials.phone, icon: Phone },
|
|
554
|
+
{ label: t("staff.access.initialPassword"), value: credentials.password, icon: KeyRound },
|
|
555
|
+
]}
|
|
556
|
+
copyLabel={t("common.copyDetails")}
|
|
557
|
+
copiedLabel={t("common.copied")}
|
|
558
|
+
copyErrorLabel={t("common.copyError")}
|
|
559
|
+
actions={<NButton onClick={() => pop()}>{t("common.done")}</NButton>}
|
|
560
|
+
/>
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Behaviour worth knowing:
|
|
564
|
+
|
|
565
|
+
- `fields` renders as a `<dl>` of `<dt>`/`<dd>` pairs. Values default to
|
|
566
|
+
monospaced and mid-string wrapping so secrets stay readable on every
|
|
567
|
+
width.
|
|
568
|
+
- The header icon defaults to a check mark and is always rendered when a
|
|
569
|
+
header is shown. Pass `icon={SomeLucideIcon}` to replace it; pass any
|
|
570
|
+
supported `NIconSource` to swap in a logo, image, or remote URL.
|
|
571
|
+
- The Copy button resolves text through `copyText` when supplied, otherwise
|
|
572
|
+
joins `${label}: ${value}` with `\n` in field order. The button is
|
|
573
|
+
disabled while the clipboard write is pending. Success swaps the label to
|
|
574
|
+
`copiedLabel` and a check icon; failure swaps to `copyErrorLabel` and a
|
|
575
|
+
warning icon. Either state reverts to idle after roughly two seconds.
|
|
576
|
+
- The copy button renders before any consumer `actions` so a Done-style
|
|
577
|
+
dismiss stays the last tab stop and never gets pressed before the secret
|
|
578
|
+
is actually copied.
|
|
579
|
+
- Missing `navigator.clipboard`, rejected `writeText`, and synchronously
|
|
580
|
+
thrown `copyText` / `writeText` all land in the error state and call
|
|
581
|
+
`onCopyError` instead of rethrowing. State updates and revert timers are
|
|
582
|
+
guarded, so unmounting during a pending copy, or starting a second copy
|
|
583
|
+
while the first success state is still showing, never fire stale setters.
|
|
584
|
+
- Status is announced through a polite `aria-live` region. The visible swap
|
|
585
|
+
is the primary feedback — no toast is emitted by the component.
|
|
586
|
+
- Packaged English fallbacks exist for `copyLabel`, `copiedLabel`, and
|
|
587
|
+
`copyErrorLabel` only. Title, description, and every field label are the
|
|
588
|
+
application's text; a consumer that omits them gets no text, not English.
|
|
589
|
+
- Spacing uses logical properties only, so a `dir="rtl"` tree needs no
|
|
590
|
+
override. Each value also carries `dir="auto"`, isolating it from the
|
|
591
|
+
surrounding paragraph direction: a phone number or password inherited into an
|
|
592
|
+
RTL tree otherwise *paints* reordered (`+1 555 0100` as `0100 555 1+`) even
|
|
593
|
+
though the DOM and the copied text are correct. A value whose first strong
|
|
594
|
+
character is Arabic still renders right-to-left.
|
|
595
|
+
|
|
596
|
+
When you only want consumer buttons and no built-in copy, pass
|
|
597
|
+
`hideCopyAction`. Pass `copyText` to format the copied text differently
|
|
598
|
+
(one CSV line per field, a JSON blob, a single concatenated value, …).
|
|
599
|
+
|
|
600
|
+
## Formatting
|
|
601
|
+
|
|
602
|
+
Pure formatters are available from the server-safe `najm-kit/format` entry.
|
|
603
|
+
Money values are integer minor units and use the currency's own exponent (for
|
|
604
|
+
example MAD has two decimals, JPY zero, and KWD three).
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
import { formatCurrency, formatDate, slugify } from 'najm-kit/format';
|
|
608
|
+
|
|
609
|
+
formatCurrency(12_500, { locale: 'fr-MA', currency: 'MAD' });
|
|
610
|
+
formatDate('2026-08-08T20:00:00Z', {
|
|
611
|
+
locale: 'fr-MA',
|
|
612
|
+
timeZone: 'Africa/Casablanca',
|
|
613
|
+
});
|
|
614
|
+
slugify('Najm Format & Pagination');
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Client code can use the active locale, time zone, currency, and placeholder
|
|
618
|
+
through `useNajmFormat`. `NajmAppProvider` mounts the format provider for you:
|
|
619
|
+
|
|
620
|
+
```tsx
|
|
621
|
+
import { NajmAppProvider } from 'najm-kit/app';
|
|
622
|
+
import { useNajmFormat } from 'najm-kit';
|
|
623
|
+
|
|
624
|
+
<NajmAppProvider
|
|
625
|
+
translations={translations}
|
|
626
|
+
currency="MAD"
|
|
627
|
+
locales={{ en: 'en-MA', fr: 'fr-MA' }}
|
|
628
|
+
>
|
|
629
|
+
<App />
|
|
630
|
+
</NajmAppProvider>
|
|
631
|
+
|
|
632
|
+
function Total({ value }: { value: number }) {
|
|
633
|
+
return <span>{useNajmFormat().money(value)}</span>;
|
|
634
|
+
}
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
## Offset pagination and queries
|
|
638
|
+
|
|
639
|
+
`najm-kit/pagination` is server-safe and framework-independent. It accepts
|
|
640
|
+
endpoints that return either `{ rows, total }` or a bare row array. When no
|
|
641
|
+
total exists it probes for one extra row; when a total exists continuation is
|
|
642
|
+
calculated without another request.
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
import {
|
|
646
|
+
createOffsetPagination,
|
|
647
|
+
fetchOffsetPage,
|
|
648
|
+
} from 'najm-kit/pagination';
|
|
649
|
+
|
|
650
|
+
const pagination = createOffsetPagination(pageIndex, pageSize);
|
|
651
|
+
const page = await fetchOffsetPage(
|
|
652
|
+
({ limit, offset }) => api.orders.list({ limit, offset }),
|
|
653
|
+
pagination,
|
|
654
|
+
);
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
React Query consumers install the optional `@tanstack/react-query` peer and use
|
|
658
|
+
the isolated `najm-kit/query` entry. `useResponsiveOffsetList` resolves numbered
|
|
659
|
+
desktop paging versus card continuation and exposes props that plug directly
|
|
660
|
+
into `NTable` and `createCardPagination`.
|
|
661
|
+
|
|
662
|
+
```tsx
|
|
663
|
+
import { NTable, createCardPagination } from 'najm-kit';
|
|
664
|
+
import { useResponsiveOffsetList } from 'najm-kit/query';
|
|
665
|
+
|
|
666
|
+
const list = useResponsiveOffsetList({
|
|
667
|
+
queryKey: ['orders'],
|
|
668
|
+
fetchPage: ({ limit, offset }) => api.orders.list({ limit, offset }),
|
|
669
|
+
strategy: 'paged',
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
<NTable
|
|
673
|
+
data={list.data}
|
|
674
|
+
columns={columns}
|
|
675
|
+
manualPagination
|
|
676
|
+
pageCount={list.pageCount}
|
|
677
|
+
pagination={list.pagination}
|
|
678
|
+
onPaginationChange={list.onPaginationChange}
|
|
679
|
+
cardPagination={createCardPagination(list, labels)}
|
|
680
|
+
/>
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
## Hooks
|
|
684
|
+
|
|
685
|
+
```tsx
|
|
686
|
+
import { useKeyboard } from 'najm-kit';
|
|
687
|
+
import { useDelayedLoading } from 'najm-kit';
|
|
688
|
+
import { useClickOutside } from 'najm-kit';
|
|
689
|
+
import { useDebouncedValue } from 'najm-kit';
|
|
690
|
+
import { useInfiniteScroll } from 'najm-kit';
|
|
691
|
+
import { useSelection } from 'najm-kit';
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
## Production Notes
|
|
695
|
+
|
|
696
|
+
- Designed for dashboard/admin UIs in Najm-powered applications
|
|
697
|
+
- Uses Radix UI primitives under the hood — accessible by default
|
|
698
|
+
- All components are unstyled by default — apply `buttonVariants()`, `badgeVariants()`, etc. with Tailwind
|
|
699
|
+
- Requires Tailwind CSS **v4** in the host application (see Styling above)
|
|
700
|
+
- CodeMirror components are optional peer deps — import from `najm-kit/json` only if needed
|
|
701
|
+
|
|
702
|
+
## NTable responsive columns
|
|
703
|
+
|
|
704
|
+
`NTable` accepts an `NTableColumnDef<T>[]`. Each column's `meta` can carry:
|
|
705
|
+
|
|
706
|
+
- `visible?: boolean` — app-owned eligibility gate. Defaults to `true`. Set
|
|
707
|
+
this from your role / capability decision. Columns with `visible: false`
|
|
708
|
+
are removed from headers, body cells, the loading skeleton, and the
|
|
709
|
+
column-settings menu.
|
|
710
|
+
- `hiddenBelow?: "sm" | "md" | "lg" | "xl" | "2xl"` — hide the table column
|
|
711
|
+
below the chosen Tailwind breakpoint. The column remains visible at that
|
|
712
|
+
breakpoint and above (mobile-first). Table view only.
|
|
713
|
+
|
|
714
|
+
```tsx
|
|
715
|
+
import { NTable, type NTableColumnDef } from "najm-kit";
|
|
716
|
+
|
|
717
|
+
const columns: NTableColumnDef<Family>[] = [
|
|
718
|
+
{ accessorKey: "name", header: "Family account" },
|
|
719
|
+
{
|
|
720
|
+
accessorKey: "email",
|
|
721
|
+
header: "Email",
|
|
722
|
+
meta: {
|
|
723
|
+
visible: can("families.email.read"),
|
|
724
|
+
hiddenBelow: "lg",
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
];
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
Notes:
|
|
731
|
+
|
|
732
|
+
- `visible` is **application-owned eligibility**, not an NTable role system.
|
|
733
|
+
`NTable` never imports `najm-auth` or reads a session; convert your own
|
|
734
|
+
role / capabilities to a boolean.
|
|
735
|
+
- Omitting `visible` is the same as `true`.
|
|
736
|
+
- `hiddenBelow` is table-only. Card view, JSON view, and custom modes
|
|
737
|
+
ignore it. Cards must do their own capability gating inside `renderCard`.
|
|
738
|
+
- Hiding a column is **presentation only**. The backend must still enforce
|
|
739
|
+
the permission and privacy-project the field. Never rely on UI hiding to
|
|
740
|
+
protect sensitive data.
|
|
741
|
+
- The user-controlled column visibility menu (settings → Columns) keeps
|
|
742
|
+
working independently. It can report a column as selected while CSS
|
|
743
|
+
hides it below the configured breakpoint.
|
|
744
|
+
- The columns the TanStack table receives are already filtered, so the
|
|
745
|
+
settings menu will not list `visible: false` columns.
|
|
746
|
+
|
|
747
|
+
If you need to inspect or build your own effective column list, the same
|
|
748
|
+
pure helper is exported as `filterResponsiveColumns`. The literal class
|
|
749
|
+
map is also exported as `hiddenBelowClasses`, and
|
|
750
|
+
`resolveHiddenBelowClass(breakpoint)` returns the class for a single
|
|
751
|
+
breakpoint or `undefined` when no breakpoint is set.
|
|
752
|
+
|
|
753
|
+
## NTable responsive cards, loading, and pagination
|
|
754
|
+
|
|
755
|
+
Responsive row actions are visible by default on phone, tablet, and coarse or
|
|
756
|
+
non-hover pointers. Fine-pointer desktop layouts may reveal them on hover, but
|
|
757
|
+
keyboard focus always reveals the action. Applications still decide which menu
|
|
758
|
+
items exist through `menu`, `onView`, `onEdit`, and `onDelete`; visibility does
|
|
759
|
+
not grant an action or replace server authorization.
|
|
760
|
+
|
|
761
|
+
When `dynamicHeight` is enabled, table and card loading skeletons measure the
|
|
762
|
+
available body. Table rows use the same header/row geometry as dynamic page
|
|
763
|
+
sizing, while cards measure the active grid columns, card height, and gap. The
|
|
764
|
+
loading surface also follows the loaded `bordered`, design recipe, radius,
|
|
765
|
+
border color, shadow, and `classNames.content`/`classNames.cards` contract.
|
|
766
|
+
The measured fit owns the initial page size. Once a reader explicitly chooses
|
|
767
|
+
Rows/page, `NTable` preserves that choice and scrolls the bounded table body
|
|
768
|
+
when the requested rows exceed the available height.
|
|
769
|
+
|
|
770
|
+
Use `cardPagination` to choose pagination presentation whenever the effective
|
|
771
|
+
rendered mode is cards:
|
|
772
|
+
|
|
773
|
+
- `{ mode: "paged" }` (the default) preserves existing pagination.
|
|
774
|
+
- `{ mode: "all" }` renders every row already supplied and hides the footer.
|
|
775
|
+
- `{ mode: "load-more", ... }` renders every supplied row and provides a
|
|
776
|
+
guarded, keyboard-operable Load more/Retry control with polite loading,
|
|
777
|
+
appended-result, and end-of-list announcements.
|
|
778
|
+
|
|
779
|
+
`showPagination={false}` remains an absolute presentation override and hides
|
|
780
|
+
both numbered controls and Load more. In table mode, existing controlled and
|
|
781
|
+
manual server pagination remains unchanged.
|
|
782
|
+
|
|
783
|
+
```tsx
|
|
784
|
+
import { NTable, type NTableCardPagination } from "najm-kit";
|
|
785
|
+
|
|
786
|
+
const cardPagination: NTableCardPagination = {
|
|
787
|
+
mode: "load-more",
|
|
788
|
+
hasNextPage: query.hasNextPage,
|
|
789
|
+
loadingMore: query.isFetchingNextPage,
|
|
790
|
+
loadMoreError: query.isFetchNextPageError
|
|
791
|
+
? "The next page could not be loaded."
|
|
792
|
+
: undefined,
|
|
793
|
+
onLoadMore: () => query.fetchNextPage(),
|
|
794
|
+
loadMoreLabel: "Load more",
|
|
795
|
+
loadingMoreLabel: "Loading more...",
|
|
796
|
+
retryLabel: "Retry",
|
|
797
|
+
endLabel: "No more results.",
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
<NTable
|
|
801
|
+
data={query.data?.pages.flatMap((page) => page.rows) ?? []}
|
|
802
|
+
columns={columns}
|
|
803
|
+
getRowId={(row) => row.id}
|
|
804
|
+
renderCard={ResultCard}
|
|
805
|
+
cardPagination={cardPagination}
|
|
806
|
+
/>
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
The application owns the query, cursor/offset, accumulated pages, cache
|
|
810
|
+
invalidation, search/filter/sort semantics, authorization, and privacy
|
|
811
|
+
projection. Najm Kit never imports React Query, calls an endpoint, invents a
|
|
812
|
+
page size, or treats supplied rows as proof that every database row is loaded.
|
|
813
|
+
Client sorting and filtering cover the rows currently supplied unless the
|
|
814
|
+
application implements matching server-side behavior.
|
|
815
|
+
|
|
816
|
+
For a responsive screen that uses current-page data in desktop table mode and
|
|
817
|
+
accumulated pages in card mode, keep those two query shapes in the application
|
|
818
|
+
and pass the appropriate `data`. Crossing the `<640px` responsive-card
|
|
819
|
+
breakpoint does not overwrite the user's chosen view, pagination position,
|
|
820
|
+
sorting, filters, expansion, or row selection.
|
|
821
|
+
|
|
822
|
+
## Theme-backed charts
|
|
823
|
+
|
|
824
|
+
`NBarChart`, `NLineChart`, `NPieChart`, and `NStatusBreakdown` accept generic
|
|
825
|
+
caller-formatted data and use `--chart-1` through `--chart-5` by default.
|
|
826
|
+
Colors repeat deterministically after the fifth series or item; set `color` on
|
|
827
|
+
an exceptional series/item to override that one value. Each chart accepts
|
|
828
|
+
`loading`/`loadingLabel` and renders an accessible shape-matched skeleton.
|
|
829
|
+
`NPieChart` and `NDonutCard` accept `size="sm" | "md" | "lg"` or a numeric
|
|
830
|
+
pixel diameter and shrink within narrow containers.
|
|
831
|
+
|
|
832
|
+
```tsx
|
|
833
|
+
import { NBarChart, NPieChart } from "najm-kit";
|
|
834
|
+
|
|
835
|
+
const data = [
|
|
836
|
+
{ id: "jan", label: "Jan", values: { received: 12, refunded: 2 } },
|
|
837
|
+
{ id: "feb", label: "Feb", values: { received: 18, refunded: 1 } },
|
|
838
|
+
];
|
|
839
|
+
|
|
840
|
+
<NBarChart
|
|
841
|
+
title="Monthly activity"
|
|
842
|
+
data={data}
|
|
843
|
+
series={[
|
|
844
|
+
{ id: "received", label: "Received" },
|
|
845
|
+
{ id: "refunded", label: "Refunded" },
|
|
846
|
+
]}
|
|
847
|
+
valueFormatter={(value) => `${value} MAD`}
|
|
848
|
+
/>
|
|
849
|
+
|
|
850
|
+
<NPieChart
|
|
851
|
+
title="Status"
|
|
852
|
+
size={132}
|
|
853
|
+
items={[
|
|
854
|
+
{ id: "active", label: "Active", value: 8 },
|
|
855
|
+
{ id: "pending", label: "Pending", value: 3 },
|
|
856
|
+
]}
|
|
857
|
+
/>
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
### Server-backed combobox search
|
|
861
|
+
|
|
862
|
+
`ComboboxInput` and `FormInput type="combobox"` can delegate filtering to a
|
|
863
|
+
server by setting `shouldFilter={false}` and handling `onSearchChange`. Use
|
|
864
|
+
`loading` and `loadingMessage` while replacement options are being fetched.
|
|
865
|
+
Client-side filtering remains the default.
|
|
866
|
+
|
|
867
|
+
## Person image fallbacks (`najm-kit/person-images`)
|
|
868
|
+
|
|
869
|
+
A framework-neutral, React-free subpath that resolves person-image fallbacks
|
|
870
|
+
for any application. The seven WebP illustrations are embedded as base64 data
|
|
871
|
+
URLs in the published bundle, so consumers do not need to copy package files
|
|
872
|
+
into `public/` or wire an asset server.
|
|
873
|
+
|
|
874
|
+
```ts
|
|
875
|
+
import { getPersonImage } from "najm-kit/person-images";
|
|
876
|
+
|
|
877
|
+
const childSrc = getPersonImage({
|
|
878
|
+
image: child.image,
|
|
879
|
+
role: "child",
|
|
880
|
+
gender: child.gender,
|
|
881
|
+
});
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
Built-in roles:
|
|
885
|
+
|
|
886
|
+
| Role | Default | Female | Male |
|
|
887
|
+
| -------- | ---------------- | --------------- | --------------- |
|
|
888
|
+
| `child` | male child art | female child | male child |
|
|
889
|
+
| `adult` | male adult art | female adult | male adult |
|
|
890
|
+
| `parent` | male parent art | female parent | male parent |
|
|
891
|
+
| `family` | neutral family | neutral family | neutral family |
|
|
892
|
+
|
|
893
|
+
Resolution precedence, for every call:
|
|
894
|
+
|
|
895
|
+
1. A real `image` (anything that survives `resolveAvatarSrc`).
|
|
896
|
+
2. A per-call `fallback` that is not blank and is not the `noavatar.png`
|
|
897
|
+
sentinel.
|
|
898
|
+
3. The configured role's gender variant, or the role's required `default`
|
|
899
|
+
when the variant or the gender is missing.
|
|
900
|
+
|
|
901
|
+
The per-call `fallback` is treated like a real source: an empty string, a
|
|
902
|
+
blank trimmed value, or any `noavatar.png` path falls through to the role
|
|
903
|
+
default. The Kafil data is a worked example: children use `role: "child"`,
|
|
904
|
+
households use `role: "family"`, sponsors, staff, applicants, and delivery
|
|
905
|
+
staff use `role: "adult"`, and a household parent uses `role: "parent"`
|
|
906
|
+
after the family dashboard maps its relationship value (`mother`, `mère`,
|
|
907
|
+
`madre`, `أم`, …) to `F`, `M`, or `null` at the feature boundary.
|
|
908
|
+
|
|
909
|
+
### Custom roles
|
|
910
|
+
|
|
911
|
+
`createPersonImageResolver` returns a typed resolver that accepts the
|
|
912
|
+
application's own role names. Unknown role strings fail type checking:
|
|
913
|
+
|
|
914
|
+
```ts
|
|
915
|
+
import { createPersonImageResolver } from "najm-kit/person-images";
|
|
916
|
+
|
|
917
|
+
const getSmsPersonImage = createPersonImageResolver({
|
|
918
|
+
teacher: {
|
|
919
|
+
default: "/images/teachers/default.webp",
|
|
920
|
+
female: "/images/teachers/female.webp",
|
|
921
|
+
male: "/images/teachers/male.webp",
|
|
922
|
+
},
|
|
923
|
+
student: {
|
|
924
|
+
default: "/images/students/default.webp",
|
|
925
|
+
female: "/images/students/female.webp",
|
|
926
|
+
male: "/images/students/male.webp",
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
const teacherSrc = getSmsPersonImage({
|
|
931
|
+
image: teacher.image,
|
|
932
|
+
role: "teacher",
|
|
933
|
+
gender: teacher.gender,
|
|
934
|
+
});
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
The factory merges custom definitions over the built-in map. A custom `child`
|
|
938
|
+
override replaces the built-in child art for that application alone — the
|
|
939
|
+
package itself is untouched, and other consumers keep their built-in
|
|
940
|
+
fallbacks.
|
|
941
|
+
|
|
942
|
+
Custom paths may be application-relative URLs, managed API URLs, CDN URLs,
|
|
943
|
+
or data URLs. najm-kit does not fetch, upload, authorize, or persist them.
|
|
944
|
+
|
|
945
|
+
### Per-call fallback override
|
|
946
|
+
|
|
947
|
+
Every call accepts a `fallback`. It overrides the role default for that call
|
|
948
|
+
only, after a real `image` and before the role's gender variant:
|
|
949
|
+
|
|
950
|
+
```ts
|
|
951
|
+
getPersonImage({ image: child.image, role: "child", gender: child.gender, fallback: child.placeholder });
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
## Server UI bootstrap (`najm-kit/server`, `najm-kit/server/react`)
|
|
956
|
+
|
|
957
|
+
An application that renders its own theme and its own logos on the server ends
|
|
958
|
+
up writing the same module every time: fetch the public endpoints, unwrap the
|
|
959
|
+
`data` envelope, validate the payload, fall back to the built-in assets when
|
|
960
|
+
any of that fails, and run the resources in parallel. These two entries own
|
|
961
|
+
that mechanism. What stays with the application is what is genuinely
|
|
962
|
+
application-specific — how a request reaches its own backend, which paths it
|
|
963
|
+
serves, what a valid payload looks like, what the factory values are, and where
|
|
964
|
+
a diagnostic goes.
|
|
965
|
+
|
|
966
|
+
Neither entry is re-exported from `najm-kit`, `najm-kit/next`, or
|
|
967
|
+
`najm-kit/app`. `najm-kit/server` imports no React at all, so a route handler
|
|
968
|
+
or a plain script can use it.
|
|
969
|
+
|
|
970
|
+
### The application's one server module
|
|
971
|
+
|
|
972
|
+
```ts
|
|
973
|
+
// src/lib/serverLoader.ts
|
|
974
|
+
import "server-only";
|
|
975
|
+
|
|
976
|
+
import { parseNajmDesignConfig } from "najm-kit/server";
|
|
977
|
+
import { createReactServerUiBootstrap } from "najm-kit/server/react";
|
|
978
|
+
|
|
979
|
+
export const serverUi = createReactServerUiBootstrap({
|
|
980
|
+
fetcher: async (path) => {
|
|
981
|
+
const { server } = await import("@app/server");
|
|
982
|
+
return server.fetch(new Request(`http://internal${path}`));
|
|
983
|
+
},
|
|
984
|
+
resources: {
|
|
985
|
+
appearance: {
|
|
986
|
+
path: "/api/appearance",
|
|
987
|
+
parse: parseAppearance, // returns undefined or throws to reject
|
|
988
|
+
fallback: getFactoryAppearance, // called per load
|
|
989
|
+
},
|
|
990
|
+
branding: {
|
|
991
|
+
path: "/api/branding",
|
|
992
|
+
parse: parseBranding,
|
|
993
|
+
fallback: getFactoryBranding,
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
onDiagnostic: (diagnostic) => {
|
|
997
|
+
console.warn(`[ui-bootstrap] ${diagnostic.resource} ${diagnostic.reason}`, diagnostic);
|
|
998
|
+
},
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
export const loadServerUiBootstrap = serverUi.load;
|
|
1002
|
+
export const { appearance: loadServerAppearance, branding: loadServerBranding } =
|
|
1003
|
+
serverUi.loaders;
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
`load()` resolves every resource; `loaders.<name>()` and `loadResource(name)`
|
|
1007
|
+
read one off the same resolution. Resource names, payload types, and the number
|
|
1008
|
+
of resources are the application's — the snapshot type is inferred from the
|
|
1009
|
+
`resources` object, so `snapshot.branding` is your branding type and not a
|
|
1010
|
+
package interface.
|
|
1011
|
+
|
|
1012
|
+
### Call the factory once, at module scope
|
|
1013
|
+
|
|
1014
|
+
`createReactServerUiBootstrap()` builds one `React.cache()` entry. Calling it
|
|
1015
|
+
inside a layout, page, or component builds a fresh one per call and shares
|
|
1016
|
+
nothing. Every server boundary in a render must import the same module.
|
|
1017
|
+
|
|
1018
|
+
The cache is React's, so it is request-scoped and nothing else: separate
|
|
1019
|
+
requests never see each other's snapshot or each other's failure, and a
|
|
1020
|
+
transient outage is retried on the next request rather than pinned into a
|
|
1021
|
+
process-global. That also rules out a module `Map`, a module promise,
|
|
1022
|
+
`unstable_cache`, `"use cache"`, or a durable cache here — every one of them
|
|
1023
|
+
would leak one visitor's render into another's.
|
|
1024
|
+
|
|
1025
|
+
The snapshot is deliberately stable for the length of one render. A settings
|
|
1026
|
+
surface that saves appearance or branding updates the client provider and then
|
|
1027
|
+
refreshes or navigates into a new render to observe the persisted result.
|
|
1028
|
+
|
|
1029
|
+
Outside a render — route handlers, server actions, scripts — use
|
|
1030
|
+
`createUiBootstrapLoader()` from `najm-kit/server` directly. There is no request
|
|
1031
|
+
cache for `cache()` to write to there, so the adapter would silently re-fetch
|
|
1032
|
+
per call.
|
|
1033
|
+
|
|
1034
|
+
### Failure behaviour
|
|
1035
|
+
|
|
1036
|
+
Resources fall back independently: a branding outage never discards a valid
|
|
1037
|
+
appearance. Each failure calls `onDiagnostic` once with a `reason` of
|
|
1038
|
+
`fetch-failed`, `response-not-ok`, `invalid-json`, `invalid-envelope`, or
|
|
1039
|
+
`invalid-payload`, plus the path and — for a non-success response — the status.
|
|
1040
|
+
Diagnostics never carry response bodies, headers, cookies, or raw thrown
|
|
1041
|
+
values; `error` is a normalized `"<name>: <message>"` for an `Error` and the
|
|
1042
|
+
value's type for anything else.
|
|
1043
|
+
|
|
1044
|
+
A `fallback()` that throws is **not** caught. A missing factory theme is the
|
|
1045
|
+
application's configuration error, and a second fallback would only hide it.
|
|
1046
|
+
|
|
1047
|
+
Falling back is right for *public* appearance and branding, where the worst case
|
|
1048
|
+
is a visitor seeing the built-in logo. It is not a general rule: do not route
|
|
1049
|
+
authenticated, financial, or privacy-sensitive reads through this, because a
|
|
1050
|
+
silent fallback there hides an outage behind plausible-looking data.
|
|
1051
|
+
|
|
1052
|
+
### Envelopes
|
|
1053
|
+
|
|
1054
|
+
`select` defaults to Najm's `{ data }` envelope. Applications behind a different
|
|
1055
|
+
envelope pass their own at the loader level or per resource; returning the
|
|
1056
|
+
payload unchanged is a valid selector, and throwing rejects the response as
|
|
1057
|
+
`invalid-envelope`.
|
|
1058
|
+
|
|
1059
|
+
### Client Components
|
|
1060
|
+
|
|
1061
|
+
`najm-kit/server/react` maps the `browser` export condition to a module that
|
|
1062
|
+
throws, so importing it from a Client Component fails the build with an
|
|
1063
|
+
explanation rather than shipping the application's fetcher and factory values
|
|
1064
|
+
into a browser bundle. Seed the client from the server snapshot through
|
|
1065
|
+
`NajmAppProvider` instead.
|
|
1066
|
+
|
|
1067
|
+
## Language, theme, and time-zone preferences (`najm-kit/server`)
|
|
1068
|
+
|
|
1069
|
+
Three route handlers and a root layout, as configuration. `defineNajmPreferences`
|
|
1070
|
+
owns the parts every application writes identically — validating a posted value,
|
|
1071
|
+
writing a secure cookie, answering `400` for anything else, and reading the three
|
|
1072
|
+
cookies back before the first paint.
|
|
1073
|
+
|
|
1074
|
+
```ts
|
|
1075
|
+
// src/preferences.ts
|
|
1076
|
+
import { defineNajmPreferences } from "najm-kit/server";
|
|
1077
|
+
import { appI18n } from "@app/server/locales";
|
|
1078
|
+
|
|
1079
|
+
export const preferences = defineNajmPreferences({ i18n: appI18n });
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
That is the whole configuration for a new application. `light` is the default
|
|
1083
|
+
theme, `light | dark` the only accepted modes, `UTC` the default time zone, the
|
|
1084
|
+
canonical `TimeZoneInput` list the accepted zones, `najm-ui-language`,
|
|
1085
|
+
`najm-ui-theme`, and `najm-ui-timezone` the cookie names, and the cookies are
|
|
1086
|
+
`HttpOnly`, `SameSite=Lax`, `Path=/`, one year. None of it is restated by the
|
|
1087
|
+
application, and there is no guard or normalizer to call.
|
|
1088
|
+
|
|
1089
|
+
An application with published cookie names or a different product default
|
|
1090
|
+
overrides only those:
|
|
1091
|
+
|
|
1092
|
+
```ts
|
|
1093
|
+
export const preferences = defineNajmPreferences({
|
|
1094
|
+
i18n: appI18n,
|
|
1095
|
+
defaultTimeZone: "Africa/Casablanca",
|
|
1096
|
+
cookieNames: {
|
|
1097
|
+
language: "app-ui-language",
|
|
1098
|
+
theme: "app-ui-theme",
|
|
1099
|
+
timeZone: "app-ui-timezone",
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
`i18n` is structural — `supportedLanguages`, `defaultLanguage`, and
|
|
1105
|
+
`normalizeLanguage`. A `najm-i18n` definition satisfies it as it is, and
|
|
1106
|
+
`najm-i18n` stays an optional peer.
|
|
1107
|
+
|
|
1108
|
+
### The three route files
|
|
1109
|
+
|
|
1110
|
+
Each is one line. The handlers are `(request: Request) => Promise<Response>`,
|
|
1111
|
+
which is exactly a Next.js route handler.
|
|
1112
|
+
|
|
1113
|
+
```ts
|
|
1114
|
+
// src/app/api/ui-language/route.ts
|
|
1115
|
+
import { preferences } from "@/preferences";
|
|
1116
|
+
export const POST = preferences.handlers.language;
|
|
1117
|
+
|
|
1118
|
+
// src/app/api/ui-theme/route.ts
|
|
1119
|
+
export const POST = preferences.handlers.theme;
|
|
1120
|
+
|
|
1121
|
+
// src/app/api/ui-timezone/route.ts
|
|
1122
|
+
export const POST = preferences.handlers.timeZone;
|
|
1123
|
+
```
|
|
1124
|
+
|
|
1125
|
+
These are the endpoints `NajmNextUIProvider` and `NajmAppProvider` already POST
|
|
1126
|
+
to by default. A handler validates before it normalizes, so an unsupported value
|
|
1127
|
+
is a `400` with a generic message and **no** `Set-Cookie` — it never becomes the
|
|
1128
|
+
default written into a cookie. Malformed JSON, a non-object body, and a missing
|
|
1129
|
+
field are the same `400`. Nothing from the request body reaches the response.
|
|
1130
|
+
|
|
1131
|
+
### The root layout
|
|
1132
|
+
|
|
1133
|
+
```tsx
|
|
1134
|
+
// src/app/layout.tsx
|
|
1135
|
+
import { cookies } from "next/headers";
|
|
1136
|
+
import { preferences } from "@/preferences";
|
|
1137
|
+
|
|
1138
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1139
|
+
const [cookieStore, session] = await Promise.all([cookies(), getSession()]);
|
|
1140
|
+
const { language, theme, timeZone } = preferences.resolve(cookieStore, {
|
|
1141
|
+
languageFallback: session?.user.language,
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
return (
|
|
1145
|
+
<html
|
|
1146
|
+
lang={language}
|
|
1147
|
+
dir={appI18n.direction(language)}
|
|
1148
|
+
data-time-zone={timeZone}
|
|
1149
|
+
className={theme === "dark" ? "dark" : ""}
|
|
1150
|
+
suppressHydrationWarning
|
|
1151
|
+
>
|
|
1152
|
+
<body>{children}</body>
|
|
1153
|
+
</html>
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
`resolve` takes anything with `get(name)` — Next's cookie store, or a plain
|
|
1159
|
+
object in a test. Precedence is cookie, then `languageFallback`, then the
|
|
1160
|
+
catalog default; an invalid or dropped cookie language falls through to the
|
|
1161
|
+
fallback rather than pinning the UI.
|
|
1162
|
+
|
|
1163
|
+
### Types
|
|
1164
|
+
|
|
1165
|
+
`NajmPreferenceLanguage<typeof preferences>` and
|
|
1166
|
+
`NajmPreferenceTimeZone<typeof preferences>` are inferred from the definition,
|
|
1167
|
+
and `NajmMode` is the theme union. An application declares no `AppLanguage`,
|
|
1168
|
+
`AppTheme`, or `AppTimeZone` alias of its own.
|
|
1169
|
+
|
|
1170
|
+
### Time zones
|
|
1171
|
+
|
|
1172
|
+
`NAJM_TIME_ZONES` is the single canonical list. `TimeZoneInput` builds its
|
|
1173
|
+
options from it and the default handlers accept exactly it, so a zone cannot be
|
|
1174
|
+
offered by the control and rejected by the server. An application that passes
|
|
1175
|
+
custom `items` to the input must pass the same values as `timeZones` here:
|
|
1176
|
+
|
|
1177
|
+
```ts
|
|
1178
|
+
const zones = ["Europe/Paris", "Africa/Casablanca"] as const;
|
|
1179
|
+
|
|
1180
|
+
export const preferences = defineNajmPreferences({ i18n: appI18n, timeZones: zones });
|
|
1181
|
+
<TimeZoneInput items={zones.map((value) => ({ value, label: "" }))} />
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
### Cookie options
|
|
1185
|
+
|
|
1186
|
+
`cookieOptions` merges per key over the defaults. `secure` is **not** set by
|
|
1187
|
+
default, so these cookies survive `http://localhost` and a deployment that
|
|
1188
|
+
terminates TLS at the edge; an application served only over HTTPS should set it:
|
|
1189
|
+
|
|
1190
|
+
```ts
|
|
1191
|
+
defineNajmPreferences({ i18n: appI18n, cookieOptions: { secure: true } });
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
The returned definition, its `cookieNames`, `cookieOptions`, `timeZones`, and
|
|
1195
|
+
`handlers` are all frozen.
|