najm-kit 2.11.13 → 2.11.15

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/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.