najm-kit 2.9.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +263 -174
- package/README.md +665 -351
- package/dist/{formFill-BcH-m9Kf.d.ts → NNotFoundState-D1CLLLxc.d.ts} +84 -2
- package/dist/NajmUIProvider-cNCb8Tpn.d.ts +327 -0
- package/dist/adapters/app.d.ts +7 -2
- package/dist/adapters/app.mjs +6 -3
- package/dist/adapters/next.d.ts +27 -2
- package/dist/adapters/next.mjs +2 -2
- package/dist/chunk-2GFWF46W.mjs +650 -0
- package/dist/{chunk-IRFFSAO2.mjs → chunk-CE7MVEJ2.mjs} +26 -2
- package/dist/chunk-EEBLEDNI.mjs +480 -0
- package/dist/chunk-K7T7X6PX.mjs +117 -0
- package/dist/{chunk-JUYT2ISO.mjs → chunk-XZL32HFR.mjs} +2 -112
- package/dist/index.d.ts +134 -94
- package/dist/index.mjs +480 -365
- package/dist/json.mjs +2 -2
- package/dist/theme.css +5 -5
- package/package.json +3 -1
- package/dist/NajmUIProvider-CI_73GEp.d.ts +0 -161
- package/dist/chunk-GHMR45H3.mjs +0 -333
- package/dist/chunk-USZUOJMK.mjs +0 -257
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ Changing the state updates the complete theme immediately. Use
|
|
|
123
123
|
`stringifyNajmThemeConfig(theme)` when persisting it, and parse settings loaded
|
|
124
124
|
from an API or local storage with `parseNajmThemeConfig` before applying them.
|
|
125
125
|
|
|
126
|
-
## Components
|
|
126
|
+
## Components
|
|
127
127
|
|
|
128
128
|
Import from `najm-kit`:
|
|
129
129
|
|
|
@@ -142,47 +142,297 @@ import { Form, FormInput, useNForm } from 'najm-kit';
|
|
|
142
142
|
|----------|-----------|
|
|
143
143
|
| Actions | NButton, IconButton, toggleVariants |
|
|
144
144
|
| Forms | Input, Textarea, Label, Select, Checkbox, RadioGroup, Switch, DateInput, FileInput, ImageInput, AvatarInput |
|
|
145
|
-
| Feedback | Alert, Badge, Progress, Spinner, Toast |
|
|
145
|
+
| Feedback | Alert, Badge, Progress, Spinner, Toast, NLoadingState, NErrorState, NEmptyState, NForbiddenState, NNotFoundState |
|
|
146
146
|
| Layout | Card, Sheet, Dialog, Popover, DropdownMenu, Tabs |
|
|
147
|
-
| Data | Table (NTable), StatCard, DetailList |
|
|
148
|
-
| Overlays | Command palette, Tooltip, Toast |
|
|
149
|
-
|
|
150
|
-
##
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
<
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
{
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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. The current packaged English fallback, when that field has one.
|
|
356
|
+
|
|
357
|
+
Generic `NErrorState.message` and `NEmptyState.description` deliberately have
|
|
358
|
+
no packaged fallback — the no-provider render must look the same as it did
|
|
359
|
+
before this contract shipped. A configured `errorMessage` opts the generic
|
|
360
|
+
error state into rendering a body; absent that opt-in, the existing
|
|
361
|
+
no-body render is preserved.
|
|
362
|
+
|
|
363
|
+
### Forbidden and not-found
|
|
364
|
+
|
|
365
|
+
Two first-class preset states for the routes every application grows:
|
|
366
|
+
|
|
367
|
+
```tsx
|
|
368
|
+
import { NForbiddenState, NNotFoundState } from 'najm-kit';
|
|
369
|
+
|
|
370
|
+
// Forbidden: provider copy + ShieldOff icon + page surface by default.
|
|
371
|
+
<NForbiddenState
|
|
372
|
+
action={<Link href="/dashboard">Back to dashboard</Link>}
|
|
373
|
+
/>
|
|
374
|
+
|
|
375
|
+
// Not found: provider copy + Compass icon + page surface by default.
|
|
376
|
+
<NNotFoundState
|
|
377
|
+
action={<Link href="/dashboard">Back to dashboard</Link>}
|
|
378
|
+
/>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Both are presentation only. They do not know the dashboard URL, render a
|
|
382
|
+
Next `Link`, redirect, or write route metadata — those belong to the
|
|
383
|
+
application's `not-found.tsx` / `forbidden/page.tsx` files.
|
|
384
|
+
|
|
385
|
+
### Root and `najm-kit/app` imports
|
|
386
|
+
|
|
387
|
+
Both entries export the same five state components. Pick the one that matches
|
|
388
|
+
your boundary:
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
// Client feature code: import from the root barrel.
|
|
392
|
+
import { NEmptyState } from 'najm-kit';
|
|
393
|
+
|
|
394
|
+
// Next Server Component route: import from najm-kit/app, which is the
|
|
395
|
+
// Client Component boundary. A route file can render a state component
|
|
396
|
+
// without authoring a local "use client" wrapper.
|
|
397
|
+
import { NNotFoundState } from 'najm-kit/app';
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
## Global form development tools
|
|
401
|
+
|
|
402
|
+
Enable schema-driven test values once on the full application provider. Every
|
|
403
|
+
`NForm` and `WizardForm` below it then fills from its Zod schema when F8 is
|
|
404
|
+
pressed; applications do not need a second provider or a form-fill helper.
|
|
405
|
+
|
|
406
|
+
```tsx
|
|
407
|
+
import { NajmAppProvider } from "najm-kit/app";
|
|
408
|
+
|
|
409
|
+
<NajmAppProvider formDevTools>
|
|
410
|
+
<App />
|
|
411
|
+
</NajmAppProvider>;
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
Pass a boolean to control it from application settings:
|
|
415
|
+
|
|
416
|
+
```tsx
|
|
417
|
+
<NajmAppProvider formDevTools={formFillEnabled}>
|
|
418
|
+
<App />
|
|
419
|
+
</NajmAppProvider>
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Forms with live relation options can override only those fields. The provider
|
|
423
|
+
still owns enablement and Najm Kit still owns schema traversal and generation.
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
<NForm
|
|
427
|
+
schema={orderSchema}
|
|
428
|
+
devTools={{ overrides: { customerId: customerOptions } }}
|
|
429
|
+
onSubmit={saveOrder}
|
|
430
|
+
>
|
|
431
|
+
{/* fields */}
|
|
432
|
+
</NForm>
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## ImageInput and AvatarInput
|
|
186
436
|
|
|
187
437
|
`ImageInput` and `AvatarInput` ship with a resilient preview contract so
|
|
188
438
|
consumers do not need to wrap them with application-specific preview
|
|
@@ -247,93 +497,157 @@ Key behaviors:
|
|
|
247
497
|
a newer value, and object URLs created by the component are tracked so
|
|
248
498
|
consumer-owned blob URLs are never revoked.
|
|
249
499
|
|
|
250
|
-
`AvatarInput` forwards every preview and accessibility prop unchanged while
|
|
251
|
-
preserving its circular, size, fill, and camera-icon defaults.
|
|
252
|
-
|
|
253
|
-
##
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
500
|
+
`AvatarInput` forwards every preview and accessibility prop unchanged while
|
|
501
|
+
preserving its circular, size, fill, and camera-icon defaults.
|
|
502
|
+
|
|
503
|
+
## Credentials handover
|
|
504
|
+
|
|
505
|
+
`NCredentialsCard` renders the recurring "show a freshly generated secret
|
|
506
|
+
once, let the operator hand it over, never show it again" surface. It owns
|
|
507
|
+
the frame, the description-list semantics, the copy flow, the failure
|
|
508
|
+
handling, and the accessible feedback. Every domain label — title,
|
|
509
|
+
description, field labels, action labels, and any translated toast — stays
|
|
510
|
+
with the application.
|
|
511
|
+
|
|
512
|
+
```tsx
|
|
513
|
+
import { NCredentialsCard, NButton } from "najm-kit";
|
|
514
|
+
import { KeyRound, Phone } from "lucide-react";
|
|
515
|
+
|
|
516
|
+
<NCredentialsCard
|
|
517
|
+
title={t("staff.access.created")}
|
|
518
|
+
description={t("staff.access.oneTimeHint")}
|
|
519
|
+
fields={[
|
|
520
|
+
{ label: t("common.phone"), value: credentials.phone, icon: Phone },
|
|
521
|
+
{ label: t("staff.access.initialPassword"), value: credentials.password, icon: KeyRound },
|
|
522
|
+
]}
|
|
523
|
+
copyLabel={t("common.copyDetails")}
|
|
524
|
+
copiedLabel={t("common.copied")}
|
|
525
|
+
copyErrorLabel={t("common.copyError")}
|
|
526
|
+
actions={<NButton onClick={() => pop()}>{t("common.done")}</NButton>}
|
|
527
|
+
/>
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Behaviour worth knowing:
|
|
531
|
+
|
|
532
|
+
- `fields` renders as a `<dl>` of `<dt>`/`<dd>` pairs. Values default to
|
|
533
|
+
monospaced and mid-string wrapping so secrets stay readable on every
|
|
534
|
+
width.
|
|
535
|
+
- The header icon defaults to a check mark and is always rendered when a
|
|
536
|
+
header is shown. Pass `icon={SomeLucideIcon}` to replace it; pass any
|
|
537
|
+
supported `NIconSource` to swap in a logo, image, or remote URL.
|
|
538
|
+
- The Copy button resolves text through `copyText` when supplied, otherwise
|
|
539
|
+
joins `${label}: ${value}` with `\n` in field order. The button is
|
|
540
|
+
disabled while the clipboard write is pending. Success swaps the label to
|
|
541
|
+
`copiedLabel` and a check icon; failure swaps to `copyErrorLabel` and a
|
|
542
|
+
warning icon. Either state reverts to idle after roughly two seconds.
|
|
543
|
+
- The copy button renders before any consumer `actions` so a Done-style
|
|
544
|
+
dismiss stays the last tab stop and never gets pressed before the secret
|
|
545
|
+
is actually copied.
|
|
546
|
+
- Missing `navigator.clipboard`, rejected `writeText`, and synchronously
|
|
547
|
+
thrown `copyText` / `writeText` all land in the error state and call
|
|
548
|
+
`onCopyError` instead of rethrowing. State updates and revert timers are
|
|
549
|
+
guarded, so unmounting during a pending copy, or starting a second copy
|
|
550
|
+
while the first success state is still showing, never fire stale setters.
|
|
551
|
+
- Status is announced through a polite `aria-live` region. The visible swap
|
|
552
|
+
is the primary feedback — no toast is emitted by the component.
|
|
553
|
+
- Packaged English fallbacks exist for `copyLabel`, `copiedLabel`, and
|
|
554
|
+
`copyErrorLabel` only. Title, description, and every field label are the
|
|
555
|
+
application's text; a consumer that omits them gets no text, not English.
|
|
556
|
+
- Spacing uses logical properties only, so a `dir="rtl"` tree needs no
|
|
557
|
+
override. Each value also carries `dir="auto"`, isolating it from the
|
|
558
|
+
surrounding paragraph direction: a phone number or password inherited into an
|
|
559
|
+
RTL tree otherwise *paints* reordered (`+1 555 0100` as `0100 555 1+`) even
|
|
560
|
+
though the DOM and the copied text are correct. A value whose first strong
|
|
561
|
+
character is Arabic still renders right-to-left.
|
|
562
|
+
|
|
563
|
+
When you only want consumer buttons and no built-in copy, pass
|
|
564
|
+
`hideCopyAction`. Pass `copyText` to format the copied text differently
|
|
565
|
+
(one CSV line per field, a JSON blob, a single concatenated value, …).
|
|
566
|
+
|
|
567
|
+
## Formatting
|
|
568
|
+
|
|
569
|
+
Pure formatters are available from the server-safe `najm-kit/format` entry.
|
|
570
|
+
Money values are integer minor units and use the currency's own exponent (for
|
|
571
|
+
example MAD has two decimals, JPY zero, and KWD three).
|
|
572
|
+
|
|
573
|
+
```ts
|
|
574
|
+
import { formatCurrency, formatDate, slugify } from 'najm-kit/format';
|
|
575
|
+
|
|
576
|
+
formatCurrency(12_500, { locale: 'fr-MA', currency: 'MAD' });
|
|
577
|
+
formatDate('2026-08-08T20:00:00Z', {
|
|
578
|
+
locale: 'fr-MA',
|
|
579
|
+
timeZone: 'Africa/Casablanca',
|
|
580
|
+
});
|
|
581
|
+
slugify('Najm Format & Pagination');
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Client code can use the active locale, time zone, currency, and placeholder
|
|
585
|
+
through `useNajmFormat`. `NajmAppProvider` mounts the format provider for you:
|
|
586
|
+
|
|
587
|
+
```tsx
|
|
588
|
+
import { NajmAppProvider } from 'najm-kit/app';
|
|
589
|
+
import { useNajmFormat } from 'najm-kit';
|
|
590
|
+
|
|
591
|
+
<NajmAppProvider
|
|
592
|
+
translations={translations}
|
|
593
|
+
currency="MAD"
|
|
594
|
+
locales={{ en: 'en-MA', fr: 'fr-MA' }}
|
|
595
|
+
>
|
|
596
|
+
<App />
|
|
597
|
+
</NajmAppProvider>
|
|
598
|
+
|
|
599
|
+
function Total({ value }: { value: number }) {
|
|
600
|
+
return <span>{useNajmFormat().money(value)}</span>;
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
## Offset pagination and queries
|
|
605
|
+
|
|
606
|
+
`najm-kit/pagination` is server-safe and framework-independent. It accepts
|
|
607
|
+
endpoints that return either `{ rows, total }` or a bare row array. When no
|
|
608
|
+
total exists it probes for one extra row; when a total exists continuation is
|
|
609
|
+
calculated without another request.
|
|
610
|
+
|
|
611
|
+
```ts
|
|
612
|
+
import {
|
|
613
|
+
createOffsetPagination,
|
|
614
|
+
fetchOffsetPage,
|
|
615
|
+
} from 'najm-kit/pagination';
|
|
616
|
+
|
|
617
|
+
const pagination = createOffsetPagination(pageIndex, pageSize);
|
|
618
|
+
const page = await fetchOffsetPage(
|
|
619
|
+
({ limit, offset }) => api.orders.list({ limit, offset }),
|
|
620
|
+
pagination,
|
|
621
|
+
);
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
React Query consumers install the optional `@tanstack/react-query` peer and use
|
|
625
|
+
the isolated `najm-kit/query` entry. `useResponsiveOffsetList` resolves numbered
|
|
626
|
+
desktop paging versus card continuation and exposes props that plug directly
|
|
627
|
+
into `NTable` and `createCardPagination`.
|
|
628
|
+
|
|
629
|
+
```tsx
|
|
630
|
+
import { NTable, createCardPagination } from 'najm-kit';
|
|
631
|
+
import { useResponsiveOffsetList } from 'najm-kit/query';
|
|
632
|
+
|
|
633
|
+
const list = useResponsiveOffsetList({
|
|
634
|
+
queryKey: ['orders'],
|
|
635
|
+
fetchPage: ({ limit, offset }) => api.orders.list({ limit, offset }),
|
|
636
|
+
strategy: 'paged',
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
<NTable
|
|
640
|
+
data={list.data}
|
|
641
|
+
columns={columns}
|
|
642
|
+
manualPagination
|
|
643
|
+
pageCount={list.pageCount}
|
|
644
|
+
pagination={list.pagination}
|
|
645
|
+
onPaginationChange={list.onPaginationChange}
|
|
646
|
+
cardPagination={createCardPagination(list, labels)}
|
|
647
|
+
/>
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
## Hooks
|
|
337
651
|
|
|
338
652
|
```tsx
|
|
339
653
|
import { useKeyboard } from 'najm-kit';
|
|
@@ -397,116 +711,116 @@ Notes:
|
|
|
397
711
|
- The columns the TanStack table receives are already filtered, so the
|
|
398
712
|
settings menu will not list `visible: false` columns.
|
|
399
713
|
|
|
400
|
-
If you need to inspect or build your own effective column list, the same
|
|
401
|
-
pure helper is exported as `filterResponsiveColumns`. The literal class
|
|
402
|
-
map is also exported as `hiddenBelowClasses`, and
|
|
403
|
-
`resolveHiddenBelowClass(breakpoint)` returns the class for a single
|
|
404
|
-
breakpoint or `undefined` when no breakpoint is set.
|
|
405
|
-
|
|
406
|
-
## NTable responsive cards, loading, and pagination
|
|
407
|
-
|
|
408
|
-
Responsive row actions are visible by default on phone, tablet, and coarse or
|
|
409
|
-
non-hover pointers. Fine-pointer desktop layouts may reveal them on hover, but
|
|
410
|
-
keyboard focus always reveals the action. Applications still decide which menu
|
|
411
|
-
items exist through `menu`, `onView`, `onEdit`, and `onDelete`; visibility does
|
|
412
|
-
not grant an action or replace server authorization.
|
|
413
|
-
|
|
414
|
-
When `dynamicHeight` is enabled, table and card loading skeletons measure the
|
|
415
|
-
available body. Table rows use the same header/row geometry as dynamic page
|
|
416
|
-
sizing, while cards measure the active grid columns, card height, and gap. The
|
|
417
|
-
loading surface also follows the loaded `bordered`, design recipe, radius,
|
|
418
|
-
border color, shadow, and `classNames.content`/`classNames.cards` contract.
|
|
419
|
-
|
|
420
|
-
Use `cardPagination` to choose pagination presentation whenever the effective
|
|
421
|
-
rendered mode is cards:
|
|
422
|
-
|
|
423
|
-
- `{ mode: "paged" }` (the default) preserves existing pagination.
|
|
424
|
-
- `{ mode: "all" }` renders every row already supplied and hides the footer.
|
|
425
|
-
- `{ mode: "load-more", ... }` renders every supplied row and provides a
|
|
426
|
-
guarded, keyboard-operable Load more/Retry control with polite loading,
|
|
427
|
-
appended-result, and end-of-list announcements.
|
|
428
|
-
|
|
429
|
-
`showPagination={false}` remains an absolute presentation override and hides
|
|
430
|
-
both numbered controls and Load more. In table mode, existing controlled and
|
|
431
|
-
manual server pagination remains unchanged.
|
|
432
|
-
|
|
433
|
-
```tsx
|
|
434
|
-
import { NTable, type NTableCardPagination } from "najm-kit";
|
|
435
|
-
|
|
436
|
-
const cardPagination: NTableCardPagination = {
|
|
437
|
-
mode: "load-more",
|
|
438
|
-
hasNextPage: query.hasNextPage,
|
|
439
|
-
loadingMore: query.isFetchingNextPage,
|
|
440
|
-
loadMoreError: query.isFetchNextPageError
|
|
441
|
-
? "The next page could not be loaded."
|
|
442
|
-
: undefined,
|
|
443
|
-
onLoadMore: () => query.fetchNextPage(),
|
|
444
|
-
loadMoreLabel: "Load more",
|
|
445
|
-
loadingMoreLabel: "Loading more...",
|
|
446
|
-
retryLabel: "Retry",
|
|
447
|
-
endLabel: "No more results.",
|
|
448
|
-
};
|
|
449
|
-
|
|
450
|
-
<NTable
|
|
451
|
-
data={query.data?.pages.flatMap((page) => page.rows) ?? []}
|
|
452
|
-
columns={columns}
|
|
453
|
-
getRowId={(row) => row.id}
|
|
454
|
-
renderCard={ResultCard}
|
|
455
|
-
cardPagination={cardPagination}
|
|
456
|
-
/>
|
|
457
|
-
```
|
|
458
|
-
|
|
459
|
-
The application owns the query, cursor/offset, accumulated pages, cache
|
|
460
|
-
invalidation, search/filter/sort semantics, authorization, and privacy
|
|
461
|
-
projection. Najm Kit never imports React Query, calls an endpoint, invents a
|
|
462
|
-
page size, or treats supplied rows as proof that every database row is loaded.
|
|
463
|
-
Client sorting and filtering cover the rows currently supplied unless the
|
|
464
|
-
application implements matching server-side behavior.
|
|
465
|
-
|
|
466
|
-
For a responsive screen that uses current-page data in desktop table mode and
|
|
467
|
-
accumulated pages in card mode, keep those two query shapes in the application
|
|
468
|
-
and pass the appropriate `data`. Crossing the `<640px` responsive-card
|
|
469
|
-
breakpoint does not overwrite the user's chosen view, pagination position,
|
|
470
|
-
sorting, filters, expansion, or row selection.
|
|
471
|
-
|
|
472
|
-
## Theme-backed charts
|
|
473
|
-
|
|
474
|
-
`NBarChart`, `NLineChart`, `NPieChart`, and `NStatusBreakdown` accept generic
|
|
475
|
-
caller-formatted data and use `--chart-1` through `--chart-5` by default.
|
|
476
|
-
Colors repeat deterministically after the fifth series or item; set `color` on
|
|
477
|
-
an exceptional series/item to override that one value. Each chart accepts
|
|
478
|
-
`loading`/`loadingLabel` and renders an accessible shape-matched skeleton.
|
|
479
|
-
`NPieChart` and `NDonutCard` accept `size="sm" | "md" | "lg"` or a numeric
|
|
480
|
-
pixel diameter and shrink within narrow containers.
|
|
481
|
-
|
|
482
|
-
```tsx
|
|
483
|
-
import { NBarChart, NPieChart } from "najm-kit";
|
|
484
|
-
|
|
485
|
-
const data = [
|
|
486
|
-
{ id: "jan", label: "Jan", values: { received: 12, refunded: 2 } },
|
|
487
|
-
{ id: "feb", label: "Feb", values: { received: 18, refunded: 1 } },
|
|
488
|
-
];
|
|
489
|
-
|
|
490
|
-
<NBarChart
|
|
491
|
-
title="Monthly activity"
|
|
492
|
-
data={data}
|
|
493
|
-
series={[
|
|
494
|
-
{ id: "received", label: "Received" },
|
|
495
|
-
{ id: "refunded", label: "Refunded" },
|
|
496
|
-
]}
|
|
497
|
-
valueFormatter={(value) => `${value} MAD`}
|
|
498
|
-
/>
|
|
499
|
-
|
|
500
|
-
<NPieChart
|
|
501
|
-
title="Status"
|
|
502
|
-
size={132}
|
|
503
|
-
items={[
|
|
504
|
-
{ id: "active", label: "Active", value: 8 },
|
|
505
|
-
{ id: "pending", label: "Pending", value: 3 },
|
|
506
|
-
]}
|
|
507
|
-
/>
|
|
508
|
-
```
|
|
509
|
-
|
|
714
|
+
If you need to inspect or build your own effective column list, the same
|
|
715
|
+
pure helper is exported as `filterResponsiveColumns`. The literal class
|
|
716
|
+
map is also exported as `hiddenBelowClasses`, and
|
|
717
|
+
`resolveHiddenBelowClass(breakpoint)` returns the class for a single
|
|
718
|
+
breakpoint or `undefined` when no breakpoint is set.
|
|
719
|
+
|
|
720
|
+
## NTable responsive cards, loading, and pagination
|
|
721
|
+
|
|
722
|
+
Responsive row actions are visible by default on phone, tablet, and coarse or
|
|
723
|
+
non-hover pointers. Fine-pointer desktop layouts may reveal them on hover, but
|
|
724
|
+
keyboard focus always reveals the action. Applications still decide which menu
|
|
725
|
+
items exist through `menu`, `onView`, `onEdit`, and `onDelete`; visibility does
|
|
726
|
+
not grant an action or replace server authorization.
|
|
727
|
+
|
|
728
|
+
When `dynamicHeight` is enabled, table and card loading skeletons measure the
|
|
729
|
+
available body. Table rows use the same header/row geometry as dynamic page
|
|
730
|
+
sizing, while cards measure the active grid columns, card height, and gap. The
|
|
731
|
+
loading surface also follows the loaded `bordered`, design recipe, radius,
|
|
732
|
+
border color, shadow, and `classNames.content`/`classNames.cards` contract.
|
|
733
|
+
|
|
734
|
+
Use `cardPagination` to choose pagination presentation whenever the effective
|
|
735
|
+
rendered mode is cards:
|
|
736
|
+
|
|
737
|
+
- `{ mode: "paged" }` (the default) preserves existing pagination.
|
|
738
|
+
- `{ mode: "all" }` renders every row already supplied and hides the footer.
|
|
739
|
+
- `{ mode: "load-more", ... }` renders every supplied row and provides a
|
|
740
|
+
guarded, keyboard-operable Load more/Retry control with polite loading,
|
|
741
|
+
appended-result, and end-of-list announcements.
|
|
742
|
+
|
|
743
|
+
`showPagination={false}` remains an absolute presentation override and hides
|
|
744
|
+
both numbered controls and Load more. In table mode, existing controlled and
|
|
745
|
+
manual server pagination remains unchanged.
|
|
746
|
+
|
|
747
|
+
```tsx
|
|
748
|
+
import { NTable, type NTableCardPagination } from "najm-kit";
|
|
749
|
+
|
|
750
|
+
const cardPagination: NTableCardPagination = {
|
|
751
|
+
mode: "load-more",
|
|
752
|
+
hasNextPage: query.hasNextPage,
|
|
753
|
+
loadingMore: query.isFetchingNextPage,
|
|
754
|
+
loadMoreError: query.isFetchNextPageError
|
|
755
|
+
? "The next page could not be loaded."
|
|
756
|
+
: undefined,
|
|
757
|
+
onLoadMore: () => query.fetchNextPage(),
|
|
758
|
+
loadMoreLabel: "Load more",
|
|
759
|
+
loadingMoreLabel: "Loading more...",
|
|
760
|
+
retryLabel: "Retry",
|
|
761
|
+
endLabel: "No more results.",
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
<NTable
|
|
765
|
+
data={query.data?.pages.flatMap((page) => page.rows) ?? []}
|
|
766
|
+
columns={columns}
|
|
767
|
+
getRowId={(row) => row.id}
|
|
768
|
+
renderCard={ResultCard}
|
|
769
|
+
cardPagination={cardPagination}
|
|
770
|
+
/>
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
The application owns the query, cursor/offset, accumulated pages, cache
|
|
774
|
+
invalidation, search/filter/sort semantics, authorization, and privacy
|
|
775
|
+
projection. Najm Kit never imports React Query, calls an endpoint, invents a
|
|
776
|
+
page size, or treats supplied rows as proof that every database row is loaded.
|
|
777
|
+
Client sorting and filtering cover the rows currently supplied unless the
|
|
778
|
+
application implements matching server-side behavior.
|
|
779
|
+
|
|
780
|
+
For a responsive screen that uses current-page data in desktop table mode and
|
|
781
|
+
accumulated pages in card mode, keep those two query shapes in the application
|
|
782
|
+
and pass the appropriate `data`. Crossing the `<640px` responsive-card
|
|
783
|
+
breakpoint does not overwrite the user's chosen view, pagination position,
|
|
784
|
+
sorting, filters, expansion, or row selection.
|
|
785
|
+
|
|
786
|
+
## Theme-backed charts
|
|
787
|
+
|
|
788
|
+
`NBarChart`, `NLineChart`, `NPieChart`, and `NStatusBreakdown` accept generic
|
|
789
|
+
caller-formatted data and use `--chart-1` through `--chart-5` by default.
|
|
790
|
+
Colors repeat deterministically after the fifth series or item; set `color` on
|
|
791
|
+
an exceptional series/item to override that one value. Each chart accepts
|
|
792
|
+
`loading`/`loadingLabel` and renders an accessible shape-matched skeleton.
|
|
793
|
+
`NPieChart` and `NDonutCard` accept `size="sm" | "md" | "lg"` or a numeric
|
|
794
|
+
pixel diameter and shrink within narrow containers.
|
|
795
|
+
|
|
796
|
+
```tsx
|
|
797
|
+
import { NBarChart, NPieChart } from "najm-kit";
|
|
798
|
+
|
|
799
|
+
const data = [
|
|
800
|
+
{ id: "jan", label: "Jan", values: { received: 12, refunded: 2 } },
|
|
801
|
+
{ id: "feb", label: "Feb", values: { received: 18, refunded: 1 } },
|
|
802
|
+
];
|
|
803
|
+
|
|
804
|
+
<NBarChart
|
|
805
|
+
title="Monthly activity"
|
|
806
|
+
data={data}
|
|
807
|
+
series={[
|
|
808
|
+
{ id: "received", label: "Received" },
|
|
809
|
+
{ id: "refunded", label: "Refunded" },
|
|
810
|
+
]}
|
|
811
|
+
valueFormatter={(value) => `${value} MAD`}
|
|
812
|
+
/>
|
|
813
|
+
|
|
814
|
+
<NPieChart
|
|
815
|
+
title="Status"
|
|
816
|
+
size={132}
|
|
817
|
+
items={[
|
|
818
|
+
{ id: "active", label: "Active", value: 8 },
|
|
819
|
+
{ id: "pending", label: "Pending", value: 3 },
|
|
820
|
+
]}
|
|
821
|
+
/>
|
|
822
|
+
```
|
|
823
|
+
|
|
510
824
|
### Server-backed combobox search
|
|
511
825
|
|
|
512
826
|
`ComboboxInput` and `FormInput type="combobox"` can delegate filtering to a
|
|
@@ -600,116 +914,116 @@ only, after a real `image` and before the role's gender variant:
|
|
|
600
914
|
```ts
|
|
601
915
|
getPersonImage({ image: child.image, role: "child", gender: child.gender, fallback: child.placeholder });
|
|
602
916
|
```
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
## Server UI bootstrap (`najm-kit/server`, `najm-kit/server/react`)
|
|
606
|
-
|
|
607
|
-
An application that renders its own theme and its own logos on the server ends
|
|
608
|
-
up writing the same module every time: fetch the public endpoints, unwrap the
|
|
609
|
-
`data` envelope, validate the payload, fall back to the built-in assets when
|
|
610
|
-
any of that fails, and run the resources in parallel. These two entries own
|
|
611
|
-
that mechanism. What stays with the application is what is genuinely
|
|
612
|
-
application-specific — how a request reaches its own backend, which paths it
|
|
613
|
-
serves, what a valid payload looks like, what the factory values are, and where
|
|
614
|
-
a diagnostic goes.
|
|
615
|
-
|
|
616
|
-
Neither entry is re-exported from `najm-kit`, `najm-kit/next`, or
|
|
617
|
-
`najm-kit/app`. `najm-kit/server` imports no React at all, so a route handler
|
|
618
|
-
or a plain script can use it.
|
|
619
|
-
|
|
620
|
-
### The application's one server module
|
|
621
|
-
|
|
622
|
-
```ts
|
|
623
|
-
// src/lib/serverLoader.ts
|
|
624
|
-
import "server-only";
|
|
625
|
-
|
|
626
|
-
import { parseNajmDesignConfig } from "najm-kit/server";
|
|
627
|
-
import { createReactServerUiBootstrap } from "najm-kit/server/react";
|
|
628
|
-
|
|
629
|
-
export const serverUi = createReactServerUiBootstrap({
|
|
630
|
-
fetcher: async (path) => {
|
|
631
|
-
const { server } = await import("@app/server");
|
|
632
|
-
return server.fetch(new Request(`http://internal${path}`));
|
|
633
|
-
},
|
|
634
|
-
resources: {
|
|
635
|
-
appearance: {
|
|
636
|
-
path: "/api/appearance",
|
|
637
|
-
parse: parseAppearance, // returns undefined or throws to reject
|
|
638
|
-
fallback: getFactoryAppearance, // called per load
|
|
639
|
-
},
|
|
640
|
-
branding: {
|
|
641
|
-
path: "/api/branding",
|
|
642
|
-
parse: parseBranding,
|
|
643
|
-
fallback: getFactoryBranding,
|
|
644
|
-
},
|
|
645
|
-
},
|
|
646
|
-
onDiagnostic: (diagnostic) => {
|
|
647
|
-
console.warn(`[ui-bootstrap] ${diagnostic.resource} ${diagnostic.reason}`, diagnostic);
|
|
648
|
-
},
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
export const loadServerUiBootstrap = serverUi.load;
|
|
652
|
-
export const { appearance: loadServerAppearance, branding: loadServerBranding } =
|
|
653
|
-
serverUi.loaders;
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
`load()` resolves every resource; `loaders.<name>()` and `loadResource(name)`
|
|
657
|
-
read one off the same resolution. Resource names, payload types, and the number
|
|
658
|
-
of resources are the application's — the snapshot type is inferred from the
|
|
659
|
-
`resources` object, so `snapshot.branding` is your branding type and not a
|
|
660
|
-
package interface.
|
|
661
|
-
|
|
662
|
-
### Call the factory once, at module scope
|
|
663
|
-
|
|
664
|
-
`createReactServerUiBootstrap()` builds one `React.cache()` entry. Calling it
|
|
665
|
-
inside a layout, page, or component builds a fresh one per call and shares
|
|
666
|
-
nothing. Every server boundary in a render must import the same module.
|
|
667
|
-
|
|
668
|
-
The cache is React's, so it is request-scoped and nothing else: separate
|
|
669
|
-
requests never see each other's snapshot or each other's failure, and a
|
|
670
|
-
transient outage is retried on the next request rather than pinned into a
|
|
671
|
-
process-global. That also rules out a module `Map`, a module promise,
|
|
672
|
-
`unstable_cache`, `"use cache"`, or a durable cache here — every one of them
|
|
673
|
-
would leak one visitor's render into another's.
|
|
674
|
-
|
|
675
|
-
The snapshot is deliberately stable for the length of one render. A settings
|
|
676
|
-
surface that saves appearance or branding updates the client provider and then
|
|
677
|
-
refreshes or navigates into a new render to observe the persisted result.
|
|
678
|
-
|
|
679
|
-
Outside a render — route handlers, server actions, scripts — use
|
|
680
|
-
`createUiBootstrapLoader()` from `najm-kit/server` directly. There is no request
|
|
681
|
-
cache for `cache()` to write to there, so the adapter would silently re-fetch
|
|
682
|
-
per call.
|
|
683
|
-
|
|
684
|
-
### Failure behaviour
|
|
685
|
-
|
|
686
|
-
Resources fall back independently: a branding outage never discards a valid
|
|
687
|
-
appearance. Each failure calls `onDiagnostic` once with a `reason` of
|
|
688
|
-
`fetch-failed`, `response-not-ok`, `invalid-json`, `invalid-envelope`, or
|
|
689
|
-
`invalid-payload`, plus the path and — for a non-success response — the status.
|
|
690
|
-
Diagnostics never carry response bodies, headers, cookies, or raw thrown
|
|
691
|
-
values; `error` is a normalized `"<name>: <message>"` for an `Error` and the
|
|
692
|
-
value's type for anything else.
|
|
693
|
-
|
|
694
|
-
A `fallback()` that throws is **not** caught. A missing factory theme is the
|
|
695
|
-
application's configuration error, and a second fallback would only hide it.
|
|
696
|
-
|
|
697
|
-
Falling back is right for *public* appearance and branding, where the worst case
|
|
698
|
-
is a visitor seeing the built-in logo. It is not a general rule: do not route
|
|
699
|
-
authenticated, financial, or privacy-sensitive reads through this, because a
|
|
700
|
-
silent fallback there hides an outage behind plausible-looking data.
|
|
701
|
-
|
|
702
|
-
### Envelopes
|
|
703
|
-
|
|
704
|
-
`select` defaults to Najm's `{ data }` envelope. Applications behind a different
|
|
705
|
-
envelope pass their own at the loader level or per resource; returning the
|
|
706
|
-
payload unchanged is a valid selector, and throwing rejects the response as
|
|
707
|
-
`invalid-envelope`.
|
|
708
|
-
|
|
709
|
-
### Client Components
|
|
710
|
-
|
|
711
|
-
`najm-kit/server/react` maps the `browser` export condition to a module that
|
|
712
|
-
throws, so importing it from a Client Component fails the build with an
|
|
713
|
-
explanation rather than shipping the application's fetcher and factory values
|
|
714
|
-
into a browser bundle. Seed the client from the server snapshot through
|
|
715
|
-
`NajmAppProvider` instead.
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
## Server UI bootstrap (`najm-kit/server`, `najm-kit/server/react`)
|
|
920
|
+
|
|
921
|
+
An application that renders its own theme and its own logos on the server ends
|
|
922
|
+
up writing the same module every time: fetch the public endpoints, unwrap the
|
|
923
|
+
`data` envelope, validate the payload, fall back to the built-in assets when
|
|
924
|
+
any of that fails, and run the resources in parallel. These two entries own
|
|
925
|
+
that mechanism. What stays with the application is what is genuinely
|
|
926
|
+
application-specific — how a request reaches its own backend, which paths it
|
|
927
|
+
serves, what a valid payload looks like, what the factory values are, and where
|
|
928
|
+
a diagnostic goes.
|
|
929
|
+
|
|
930
|
+
Neither entry is re-exported from `najm-kit`, `najm-kit/next`, or
|
|
931
|
+
`najm-kit/app`. `najm-kit/server` imports no React at all, so a route handler
|
|
932
|
+
or a plain script can use it.
|
|
933
|
+
|
|
934
|
+
### The application's one server module
|
|
935
|
+
|
|
936
|
+
```ts
|
|
937
|
+
// src/lib/serverLoader.ts
|
|
938
|
+
import "server-only";
|
|
939
|
+
|
|
940
|
+
import { parseNajmDesignConfig } from "najm-kit/server";
|
|
941
|
+
import { createReactServerUiBootstrap } from "najm-kit/server/react";
|
|
942
|
+
|
|
943
|
+
export const serverUi = createReactServerUiBootstrap({
|
|
944
|
+
fetcher: async (path) => {
|
|
945
|
+
const { server } = await import("@app/server");
|
|
946
|
+
return server.fetch(new Request(`http://internal${path}`));
|
|
947
|
+
},
|
|
948
|
+
resources: {
|
|
949
|
+
appearance: {
|
|
950
|
+
path: "/api/appearance",
|
|
951
|
+
parse: parseAppearance, // returns undefined or throws to reject
|
|
952
|
+
fallback: getFactoryAppearance, // called per load
|
|
953
|
+
},
|
|
954
|
+
branding: {
|
|
955
|
+
path: "/api/branding",
|
|
956
|
+
parse: parseBranding,
|
|
957
|
+
fallback: getFactoryBranding,
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
onDiagnostic: (diagnostic) => {
|
|
961
|
+
console.warn(`[ui-bootstrap] ${diagnostic.resource} ${diagnostic.reason}`, diagnostic);
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
export const loadServerUiBootstrap = serverUi.load;
|
|
966
|
+
export const { appearance: loadServerAppearance, branding: loadServerBranding } =
|
|
967
|
+
serverUi.loaders;
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
`load()` resolves every resource; `loaders.<name>()` and `loadResource(name)`
|
|
971
|
+
read one off the same resolution. Resource names, payload types, and the number
|
|
972
|
+
of resources are the application's — the snapshot type is inferred from the
|
|
973
|
+
`resources` object, so `snapshot.branding` is your branding type and not a
|
|
974
|
+
package interface.
|
|
975
|
+
|
|
976
|
+
### Call the factory once, at module scope
|
|
977
|
+
|
|
978
|
+
`createReactServerUiBootstrap()` builds one `React.cache()` entry. Calling it
|
|
979
|
+
inside a layout, page, or component builds a fresh one per call and shares
|
|
980
|
+
nothing. Every server boundary in a render must import the same module.
|
|
981
|
+
|
|
982
|
+
The cache is React's, so it is request-scoped and nothing else: separate
|
|
983
|
+
requests never see each other's snapshot or each other's failure, and a
|
|
984
|
+
transient outage is retried on the next request rather than pinned into a
|
|
985
|
+
process-global. That also rules out a module `Map`, a module promise,
|
|
986
|
+
`unstable_cache`, `"use cache"`, or a durable cache here — every one of them
|
|
987
|
+
would leak one visitor's render into another's.
|
|
988
|
+
|
|
989
|
+
The snapshot is deliberately stable for the length of one render. A settings
|
|
990
|
+
surface that saves appearance or branding updates the client provider and then
|
|
991
|
+
refreshes or navigates into a new render to observe the persisted result.
|
|
992
|
+
|
|
993
|
+
Outside a render — route handlers, server actions, scripts — use
|
|
994
|
+
`createUiBootstrapLoader()` from `najm-kit/server` directly. There is no request
|
|
995
|
+
cache for `cache()` to write to there, so the adapter would silently re-fetch
|
|
996
|
+
per call.
|
|
997
|
+
|
|
998
|
+
### Failure behaviour
|
|
999
|
+
|
|
1000
|
+
Resources fall back independently: a branding outage never discards a valid
|
|
1001
|
+
appearance. Each failure calls `onDiagnostic` once with a `reason` of
|
|
1002
|
+
`fetch-failed`, `response-not-ok`, `invalid-json`, `invalid-envelope`, or
|
|
1003
|
+
`invalid-payload`, plus the path and — for a non-success response — the status.
|
|
1004
|
+
Diagnostics never carry response bodies, headers, cookies, or raw thrown
|
|
1005
|
+
values; `error` is a normalized `"<name>: <message>"` for an `Error` and the
|
|
1006
|
+
value's type for anything else.
|
|
1007
|
+
|
|
1008
|
+
A `fallback()` that throws is **not** caught. A missing factory theme is the
|
|
1009
|
+
application's configuration error, and a second fallback would only hide it.
|
|
1010
|
+
|
|
1011
|
+
Falling back is right for *public* appearance and branding, where the worst case
|
|
1012
|
+
is a visitor seeing the built-in logo. It is not a general rule: do not route
|
|
1013
|
+
authenticated, financial, or privacy-sensitive reads through this, because a
|
|
1014
|
+
silent fallback there hides an outage behind plausible-looking data.
|
|
1015
|
+
|
|
1016
|
+
### Envelopes
|
|
1017
|
+
|
|
1018
|
+
`select` defaults to Najm's `{ data }` envelope. Applications behind a different
|
|
1019
|
+
envelope pass their own at the loader level or per resource; returning the
|
|
1020
|
+
payload unchanged is a valid selector, and throwing rejects the response as
|
|
1021
|
+
`invalid-envelope`.
|
|
1022
|
+
|
|
1023
|
+
### Client Components
|
|
1024
|
+
|
|
1025
|
+
`najm-kit/server/react` maps the `browser` export condition to a module that
|
|
1026
|
+
throws, so importing it from a Client Component fails the build with an
|
|
1027
|
+
explanation rather than shipping the application's fetcher and factory values
|
|
1028
|
+
into a browser bundle. Seed the client from the server snapshot through
|
|
1029
|
+
`NajmAppProvider` instead.
|