najm-kit
v3.0.0
Published
Reusable React UI component package for Najm framework
Downloads
7,140
Readme
najm-kit
Reusable React component library for Najm applications. Provides themed UI primitives, hooks, and form components.
Install
bun add najm-kit tailwindcss @tailwindcss/postcssPeer dependencies: react >=18, react-dom >=18. Requires Tailwind CSS v4 in the host app.
Optional peer dependencies include recharts, @tanstack/react-table,
react-hook-form, @tanstack/react-query, leaflet, and
@googlemaps/js-api-loader. Map dependencies are reached only through their
dedicated location adapter subpaths.
Styling — the entire setup
najm-kit is a Tailwind v4, shadcn-compatible library. PostCSS config (postcss.config.mjs):
export default { plugins: { "@tailwindcss/postcss": {} } };Your global stylesheet — two imports, that's it:
@import "tailwindcss";
@import "najm-kit/theme.css";This gives you every najm-kit component styled, dark mode wired (the .dark class),
and a full token-backed palette you can use in your own markup too
(bg-background, bg-card, bg-primary, text-muted-foreground, border-border, …).
Theming
najm-kit uses the standard shadcn token names (no prefix), so you rebrand by overriding CSS variables — or paste a theme straight from tweakcn / the shadcn registry:
:root { --primary: oklch(0.55 0.2 290); --radius: 0.75rem; }
.dark { --primary: oklch(0.70 0.18 290); }Add your own extra colors alongside najm-kit's:
@theme { --color-success: oklch(0.7 0.18 150); } /* → bg-success, text-success */Dark mode: toggle the dark class on <html> (or any wrapper):
document.documentElement.classList.toggle("dark");Theme Provider (optional)
For scoped theming without writing CSS — useful for embedded surfaces. The provider
is opt-in: with no props it injects nothing and your :root/.dark CSS owns theming.
import { NajmThemeProvider } from 'najm-kit';
// preset:
<NajmThemeProvider preset="dark-blue">{children}</NajmThemeProvider>
// or mode + accent:
<NajmThemeProvider mode="dark" accent="emerald">{children}</NajmThemeProvider>
// shadcn-style global radius scale:
<NajmThemeProvider radius="0.75rem">{children}</NajmThemeProvider>
// exact same radius for cards, tables, buttons, inputs, dialogs, etc.:
<NajmThemeProvider radius="0.75rem">
{children}
</NajmThemeProvider>rounded-full and rounded-none remain explicit, so avatars, pills, switches,
and square variants keep their intended shape.
JSON theme settings
Store one theme object in a JSON file, local storage, or your settings API:
{
"mode": "dark",
"accent": "violet",
"radius": "0.75rem",
"appearance": { "borderWidth": "1px" },
"tokens": {
"primary": "oklch(0.62 0.2 290)",
"primary-foreground": "oklch(1 0 0)",
"sidebar": "oklch(0.18 0.02 290)",
"chart-1": "oklch(0.70 0.20 40)"
}
}Load and apply it from the same settings state used by your theme editor:
import rawTheme from './theme.json';
import { NajmThemeProvider, parseNajmThemeConfig } from 'najm-kit';
const initialTheme = parseNajmThemeConfig(rawTheme);
function App() {
const [theme, setTheme] = useState(initialTheme);
return (
<NajmThemeProvider config={theme}>
<SettingsPage value={theme} onChange={setTheme} />
{children}
</NajmThemeProvider>
);
}Changing the state updates the complete theme immediately. Use
stringifyNajmThemeConfig(theme) when persisting it, and parse settings loaded
from an API or local storage with parseNajmThemeConfig before applying them.
Components
Import from najm-kit:
import { NButton, buttonVariants } from 'najm-kit';
import { Input } from 'najm-kit';
import { Card, CardHeader, CardTitle, CardContent } from 'najm-kit';
import { Dialog, DialogContent, DialogTrigger } from 'najm-kit';
import { DataTable } from 'najm-kit';
import { Form, FormInput, useNForm } from 'najm-kit';Available Primitives
| Category | Components | |----------|-----------| | Actions | NButton, IconButton, toggleVariants | | Forms | Input, Textarea, Label, Select, Checkbox, RadioGroup, Switch, DateInput, FileInput, ImageInput, AvatarInput | | Feedback | Alert, Badge, Progress, Spinner, Toast, NLoadingState, NErrorState, NEmptyState, NForbiddenState, NNotFoundState | | Layout | Card, Sheet, Dialog, Popover, DropdownMenu, Tabs | | Data | Table (NTable), StatCard, DetailList, CredentialsCard | | Overlays | Command palette, Tooltip, Toast |
Images and avatars
Three components, one fallback rule. Each tries its sources in order, tries a source at most once, and discards what it knows about a failure the moment the sources change.
NImage — plain <img>
For a logo or an icon whose box the caller's CSS already owns. No layout is
invented, and onError is forwarded rather than swallowed.
import { NImage } from 'najm-kit';
<NImage src={logo} fallback="/brand/logo.svg" alt="Acme" className="h-8 w-auto" />NAvatar — person or record
The image is a native <img> loaded directly by the browser, so a same-origin
protected route works with the session the page already has, and the package
needs no knowledge of which routes are protected.
import { NAvatar } from 'najm-kit';
<NAvatar
src={member.image}
fallbackSrc={stockPortrait}
version={member.imageRevision}
title={member.name}
subtitle={member.role}
size="lg"
/>- The primary source is tried first, then
fallbackSrc, then the initials. version(orsrcVersion) is appended as?v=…to every remote source, so a re-upload is not served from cache.data:andblob:sources are left alone.- Initials stay visible until an image paints and come back if every source fails — a transparent PNG never shows letters through itself.
imagePropsreaches the element forloading,sizes,crossOrigin,referrerPolicy, and the load/error handlers. It defaults toloading="lazy", and supplied handlers are composed with the fallback chain rather than replacing it.
NNextImage — optimized, from najm-kit/next
Same fallback contract with Next's optimizer, layout reservation, fill, and
sizes. It lives only in the najm-kit/next entry, because the root package
stays installable without Next.
import { NNextImage } from 'najm-kit/next';
// A public asset: let the optimizer resize and re-encode it.
<NNextImage src="/covers/spring.png" alt="Spring" width={64} height={64} />For an asset the browser must fetch directly — one behind an authenticated route, typically — the application says so:
<NNextImage
src={record.image}
alt={record.name}
fill
sizes="64px"
unoptimized
/>unoptimized is passed at the call site rather than inferred from the URL:
which routes are protected is the application's fact, not something a package
can read off a path. It changes delivery mechanics only — session validation,
permissions, privacy projection, and what bytes come back all remain the
backend's.
App snapshot
NajmKitProvider from najm-kit/app owns Kit's UI/i18n/formatting/preferences
stack and accepts the standard public server snapshot directly. The
snapshot seeds language, theme, time zone, design, and branding; application
policy stays explicit:
<NajmKitProvider
snapshot={snapshot}
i18n={appI18n}
appName="Example"
currency="MAD"
>
{children}
</NajmKitProvider>Existing initialLanguage, initialTheme, initialTimeZone, initialDesign,
and initialBranding props remain available as explicit overrides. The
snapshot is structural, so Kit does not depend on the server or Next package.
The former NajmAppProvider name and matching types remain deprecated aliases
to this same implementation and context identity. Full Auth/Query/Theme stacks
should use NajmAppProvider from najm-next/app/client instead.
Status badges
<NBadge status="…" /> ships the whole thing: the lifecycle, review,
fulfilment, payment and attendance vocabulary mapped onto the semantic colors,
labels for it in English, French, Arabic and Spanish, and the soft pill shape a
status wears. No configuration, no provider, no catalog:
import { NBadge } from 'najm-kit';
<NBadge status="out_for_delivery" /> // warning, soft pill, "Out for delivery"
<NBadge status="overdue" /> // destructive
<NBadge status="nebulous" /> // neutral, "Nebulous"Soft and pill are the defaults for a status badge only. A content badge —
<NBadge>Beta</NBadge> — keeps the solid look it has always had.
The application's own wording
An application whose catalog is keyed by the convention needs no map at all.
status.<token> is looked up through the provider's t, and a hit wins over
the packaged label:
{ "status": { "in_preparation": "Purchasing and preparation" } }NajmKitProvider and NajmAppProvider supply both the translator and the
active language, so switching language relabels every badge below without a
remount. Mounting NajmUIProvider directly, pass t and language yourself.
Anything the convention does not cover goes on badgeDefaults, which lives on
NajmUIProvider and is inherited by NajmNextUIProvider and
NajmAppProvider:
<NajmAppProvider
badgeDefaults={{
// Only what differs. A catalog keyed camelCase against snake_case tokens,
// a status this package has never heard of, a different look.
statusMap: { on_leave: 'info' },
statusLabelKeys: { on_leave: 'status.onLeave' },
statusKeyPrefix: 'status', // '' switches the convention off
}}
>The same labels as plain text
formatStatusLabel resolves the identical text without a badge, from the
server-safe najm-kit/format leaf — for a sentence, an export, or an email:
import { formatStatusLabel } from 'najm-kit/format';
formatStatusLabel('out_for_delivery', { language: 'fr' }); // "En cours de livraison"
formatStatusLabel('in_preparation', { language: 'fr', t }); // the catalog's wordingResolution, most specific first
- An explicit prop beats every provider default, which beats the packaged one.
labelbeats string children; string children beat any resolved label.- A
statusLabelsliteral beats astatusLabelKeyscatalog lookup, which beats the conventionalstatus.<token>lookup, which beats the packaged label for the active language. - A key the catalog does not hold never renders as itself — it falls through.
- An unmapped status is humanized (
bespoke_state→Bespoke State). - A per-instance
statusMap/iconMapmerges over the provider's, which merges over the packaged one, so overriding one status costs one status. - Provider and packaged status defaults apply only when
statusis set.
A regional tag resolves to its base language (fr-MA → fr), and an
unpackaged language falls back to English rather than to the raw token.
Statuses are matched through one rule, exported as normalizeStatusToken, so
Out-For-Delivery , out for delivery, and out_for_delivery are the same
key for colors, icons, and labels alike. Badge text is presentation: it renames
nothing in the backend and validates no lifecycle transition.
Feedback states
Five public state components cover the reusable cases every application
otherwise repeats: NLoadingState, NErrorState, NEmptyState,
NForbiddenState, and NNotFoundState. They share one layout frame and one
provider-defaults channel, so an application configures its copy once and
every consumer below inherits it.
Surfaces
Three layouts, one prop. surface selects the frame:
| surface | Use it for | What it does |
| --- | --- | --- |
| "inline" (default) | A small slot inside an existing component | Legacy sizing, no landmark |
| "panel" | A table body, card body, dialog, or sheet | Centered with a minimum height, no page gutter, no landmark |
| "page" | A real route-level state | Uses page spacing from the design config; renders through a non-<main> root |
NLoadingState.fullScreen keeps its fixed viewport overlay regardless of
surface — it always wins.
import { NLoadingState, NErrorState, NEmptyState } from 'najm-kit';
// Inline (default): drop into a card or section.
<NLoadingState label="Loading orders..." />
// Panel: table body or dialog content.
<NEmptyState surface="panel" title="No orders yet" icon={Inbox} />
// Page: route-level empty state. Never introduces a second <main>.
<NErrorState
surface="page"
title="Dashboard unavailable"
message="We are working on it."
onRetry={() => refetch()}
/>Provider defaults
Pass one feedbackDefaults map to NajmUIProvider (or to NajmAppProvider
through it) and every feedback state beneath uses it. There is one place for
loading, empty, error, retry, forbidden, and not-found labels, and a single
language change recomputes them all without remounting the tree.
import { NajmAppProvider } from 'najm-kit/app';
<NajmAppProvider
feedbackDefaults={{
labels: {
loadingLabel: 'Chargement…',
emptyTitle: 'Aucune donnée',
errorTitle: 'Une erreur est survenue',
retryLabel: 'Réessayer',
forbiddenTitle: 'Accès refusé',
forbiddenDescription: 'Vous n\'avez pas la permission.',
notFoundTitle: 'Page introuvable',
notFoundDescription: 'La page demandée n\'existe pas.',
},
labelKeys: {
emptyTitle: 'common.empty',
errorTitle: 'common.error',
},
}}
>
<App />
</NajmAppProvider>Resolution order, most specific first:
- An explicit component prop.
- A literal in
feedbackDefaults.labels. - A translated
feedbackDefaults.labelKeysvalue resolved through the provider's existing structuraltfunction. `<prefix>.<field>`resolved through the samet, whereprefixdefaults tocommon.feedback.- The current packaged English fallback, when that field has one.
The prefix convention
Step 4 is the reason most applications need no feedbackDefaults at all. Name
the nine catalog entries after the fields — common.feedback.emptyTitle,
common.feedback.retryLabel, and so on — and a provider that already has a
translator resolves every feedback state with no mapping object to write or
memoize:
<NajmAppProvider translations={translations} initialLanguage="fr">
<App />
</NajmAppProvider>Use prefix to point at a different branch, and FeedbackKey<Prefix> to type
a translator against exactly those nine keys:
import type { FeedbackKey } from 'najm-kit';
<NajmAppProvider feedbackDefaults={{ prefix: 'app.states' }}>Unlike buildToolbarLabels and buildPaginationLabels, a translator result
equal to the key it was handed is treated as missing here rather than
rendered. The prefix is a convention an application may never have adopted, so
an unanswered key falls through to packaged English instead of painting
common.feedback.emptyTitle across an empty state. The same rule applies to an
explicit labelKeys entry, which makes a typo in the mapping degrade to English
rather than to visible key text.
Generic NErrorState.message and NEmptyState.description deliberately have
no packaged fallback — the no-provider render must look the same as it did
before this contract shipped. A configured errorMessage opts the generic
error state into rendering a body; absent that opt-in, the existing
no-body render is preserved.
Forbidden and not-found
Two first-class preset states for the routes every application grows:
import { NForbiddenState, NNotFoundState } from 'najm-kit';
// Forbidden: provider copy + ShieldOff icon + page surface by default.
<NForbiddenState
action={<Link href="/dashboard">Back to dashboard</Link>}
/>
// Not found: provider copy + Compass icon + page surface by default.
<NNotFoundState
action={<Link href="/dashboard">Back to dashboard</Link>}
/>Both are presentation only. They do not know the dashboard URL, render a
Next Link, redirect, or write route metadata — those belong to the
application's not-found.tsx / forbidden/page.tsx files.
Root and najm-kit/app imports
Both entries export the same five state components. Pick the one that matches your boundary:
// Client feature code: import from the root barrel.
import { NEmptyState } from 'najm-kit';
// Next Server Component route: import from najm-kit/app, which is the
// Client Component boundary. A route file can render a state component
// without authoring a local "use client" wrapper.
import { NNotFoundState } from 'najm-kit/app';Global form development tools
Enable schema-driven test values once on the full application provider. Every
NForm and WizardForm below it then fills from its Zod schema when F8 is
pressed; applications do not need a second provider or a form-fill helper.
import { NajmAppProvider } from "najm-kit/app";
<NajmAppProvider formDevTools>
<App />
</NajmAppProvider>;Pass a boolean to control it from application settings:
<NajmAppProvider formDevTools={formFillEnabled}>
<App />
</NajmAppProvider>Forms with live relation options can override only those fields. The provider still owns enablement and Najm Kit still owns schema traversal and generation.
<NForm
schema={orderSchema}
devTools={{ overrides: { customerId: customerOptions } }}
onSubmit={saveOrder}
>
{/* fields */}
</NForm>ImageInput and AvatarInput
ImageInput and AvatarInput ship with a resilient preview contract so
consumers do not need to wrap them with application-specific preview
components.
Source precedence:
- When
valueis a non-empty string URL, candidates are tried in order:valueis the primary preview source.- If the primary source fails,
fallbackImageis tried when supplied. defaultImageis the last-resort fallback.
- When
valueisnullor empty, onlydefaultImageis tracked. ThefallbackImageis intentionally not used in the empty state — a nullvalueis the consumer's empty-state signal, and only the configured default participates in the failed-default → unavailable transition. IfdefaultImageitself fails,onPreviewError({ source: "default" })fires and the unavailable state is rendered.
Candidate URLs are deduplicated so the same failing URL is never retried
through multiple stages. When every candidate fails, the broken <img> is
unmounted and unavailableContent (or a neutral default) is rendered in its
place. A data-image-input-state="empty" | "preview" | "fallback" | "unavailable"
marker is exposed for styling, testing, and consumer diagnostics.
Candidate URLs are deduplicated so the same failing URL is never retried
through multiple stages. When every candidate fails, the broken <img> is
unmounted and unavailableContent (or a neutral default) is rendered in its
place. A data-image-input-state="empty" | "preview" | "fallback" | "unavailable"
marker is exposed for styling, testing, and consumer diagnostics.
import { ImageInput } from "najm-kit";
<ImageInput
value="https://cdn.example.com/avatar.png"
onChange={setAvatar}
previewAlt="Workspace logo"
fallbackImage="/assets/logo-default.png"
fallbackAlt="Default workspace logo"
unavailableContent={<span>Logo unavailable</span>}
imageClassName="object-contain"
imageVersion={cacheBustVersion}
replaceAriaLabel="Replace workspace logo"
clearAriaLabel="Remove workspace logo"
onPreviewError={(err) => log(err)}
/>Key behaviors:
- The replace and clear controls are real
<button>elements, are reachable with the keyboard (EnterandSpaceactivate them once), and stay visible on touch and coarse-pointer devices. Only on(hover: hover) and (pointer: fine)desktops do the controls fall back to a hover/focus reveal.focus-visiblealways restores visibility. - Positioning uses logical properties (
end-*) so the clear button works correctly in RTL layouts. imageVersionis appended safely to relative, absolute, queried, and fragmented URLs.data:,blob:,javascript:, andfile:URLs are left unchanged.- File selection is race-safe: stale
FileReadercompletions cannot replace a newer value, and object URLs created by the component are tracked so consumer-owned blob URLs are never revoked.
AvatarInput forwards every preview and accessibility prop unchanged while
preserving its circular, size, fill, and camera-icon defaults.
Credentials handover
NCredentialsCard renders the recurring "show a freshly generated secret
once, let the operator hand it over, never show it again" surface. It owns
the frame, the description-list semantics, the copy flow, the failure
handling, and the accessible feedback. Every domain label — title,
description, field labels, action labels, and any translated toast — stays
with the application.
import { NCredentialsCard, NButton } from "najm-kit";
import { KeyRound, Phone } from "lucide-react";
<NCredentialsCard
title={t("staff.access.created")}
description={t("staff.access.oneTimeHint")}
fields={[
{ label: t("common.phone"), value: credentials.phone, icon: Phone },
{ label: t("staff.access.initialPassword"), value: credentials.password, icon: KeyRound },
]}
copyLabel={t("common.copyDetails")}
copiedLabel={t("common.copied")}
copyErrorLabel={t("common.copyError")}
actions={<NButton onClick={() => pop()}>{t("common.done")}</NButton>}
/>Behaviour worth knowing:
fieldsrenders as a<dl>of<dt>/<dd>pairs. Values default to monospaced and mid-string wrapping so secrets stay readable on every width.- The header icon defaults to a check mark and is always rendered when a
header is shown. Pass
icon={SomeLucideIcon}to replace it; pass any supportedNIconSourceto swap in a logo, image, or remote URL. - The Copy button resolves text through
copyTextwhen supplied, otherwise joins${label}: ${value}with\nin field order. The button is disabled while the clipboard write is pending. Success swaps the label tocopiedLabeland a check icon; failure swaps tocopyErrorLabeland a warning icon. Either state reverts to idle after roughly two seconds. - The copy button renders before any consumer
actionsso a Done-style dismiss stays the last tab stop and never gets pressed before the secret is actually copied. - Missing
navigator.clipboard, rejectedwriteText, and synchronously throwncopyText/writeTextall land in the error state and callonCopyErrorinstead of rethrowing. State updates and revert timers are guarded, so unmounting during a pending copy, or starting a second copy while the first success state is still showing, never fire stale setters. - Status is announced through a polite
aria-liveregion. The visible swap is the primary feedback — no toast is emitted by the component. - Packaged English fallbacks exist for
copyLabel,copiedLabel, andcopyErrorLabelonly. Title, description, and every field label are the application's text; a consumer that omits them gets no text, not English. - Spacing uses logical properties only, so a
dir="rtl"tree needs no override. Each value also carriesdir="auto", isolating it from the surrounding paragraph direction: a phone number or password inherited into an RTL tree otherwise paints reordered (+1 555 0100as0100 555 1+) even though the DOM and the copied text are correct. A value whose first strong character is Arabic still renders right-to-left.
When you only want consumer buttons and no built-in copy, pass
hideCopyAction. Pass copyText to format the copied text differently
(one CSV line per field, a JSON blob, a single concatenated value, …).
Formatting
Pure formatters are available from the server-safe najm-kit/format entry.
Money values are integer minor units and use the currency's own exponent (for
example MAD has two decimals, JPY zero, and KWD three).
import { formatCurrency, formatDate, slugify } from 'najm-kit/format';
formatCurrency(12_500, { locale: 'fr-MA', currency: 'MAD' });
formatDate('2026-08-08T20:00:00Z', {
locale: 'fr-MA',
timeZone: 'Africa/Casablanca',
});
slugify('Najm Format & Pagination');Client code can use the active locale, time zone, currency, and placeholder
through useNajmFormat. NajmAppProvider mounts the format provider for you:
import { NajmAppProvider } from 'najm-kit/app';
import { useNajmFormat } from 'najm-kit';
<NajmAppProvider
translations={translations}
currency="MAD"
locales={{ en: 'en-MA', fr: 'fr-MA' }}
>
<App />
</NajmAppProvider>
function Total({ value }: { value: number }) {
return <span>{useNajmFormat().money(value)}</span>;
}Offset pagination and queries
najm-kit/pagination is server-safe and framework-independent. It accepts
endpoints that return either { rows, total } or a bare row array. When no
total exists it probes for one extra row; when a total exists continuation is
calculated without another request.
import {
createOffsetPagination,
fetchOffsetPage,
} from 'najm-kit/pagination';
const pagination = createOffsetPagination(pageIndex, pageSize);
const page = await fetchOffsetPage(
({ limit, offset }) => api.orders.list({ limit, offset }),
pagination,
);React Query consumers install the optional @tanstack/react-query peer and use
the isolated najm-kit/query entry. useResponsiveOffsetList resolves numbered
desktop paging versus card continuation and exposes props that plug directly
into NTable and createCardPagination.
import { NTable, createCardPagination } from 'najm-kit';
import { useResponsiveOffsetList } from 'najm-kit/query';
const list = useResponsiveOffsetList({
queryKey: ['orders'],
fetchPage: ({ limit, offset }) => api.orders.list({ limit, offset }),
strategy: 'paged',
});
<NTable
data={list.data}
columns={columns}
manualPagination
pageCount={list.pageCount}
pagination={list.pagination}
onPaginationChange={list.onPaginationChange}
cardPagination={createCardPagination(list, labels)}
/>Shared entity reads and commands use the same client entry. Commands await all declared cache invalidations before running the consumer success callback.
import { useEntityCommand, useEntityQuery } from 'najm-kit/query';
import { createEntityKeys } from 'najm-kit/query/keys';
const familyKeys = createEntityKeys('families');
const families = useEntityQuery({
queryKey: familyKeys.list({ status: 'active' }),
queryFn: api.families.list,
});
const updateFamily = useEntityCommand({
mutationFn: api.families.update,
invalidate: [familyKeys.all],
successMessage: 'Family updated.',
});errorMessage takes a string or a resolver. A string is only the fallback when
the failure carries no message of its own; a resolver is the application
deciding what the failure means, so its answer is what the toast shows. Pass
getErrorMessage to replace the normalization applied to unresolved errors.
najm-kit/query/keys is server-safe and has no React or browser runtime. Keep
feature-specific key relationships in the application. Applications using the
older endpoint-map pattern can migrate without copying its implementation:
import { useEntityCRUD } from 'najm-kit/query/crud';
const crud = useEntityCRUD(['students', 'parents'], {
getAll: api.students.list,
getById: api.students.get,
create: api.students.create,
update: api.students.update,
delete: api.students.remove,
});The CRUD compatibility entry also requires the optional najm-i18n peer and
uses the application's active catalog for its established success fallbacks.
Hooks
import { useKeyboard } from 'najm-kit';
import { useDelayedLoading } from 'najm-kit';
import { useClickOutside } from 'najm-kit';
import { useDebouncedValue } from 'najm-kit';
import { useInfiniteScroll } from 'najm-kit';
import { useSelection } from 'najm-kit';Production Notes
- Designed for dashboard/admin UIs in Najm-powered applications
- Uses Radix UI primitives under the hood — accessible by default
- All components are unstyled by default — apply
buttonVariants(),badgeVariants(), etc. with Tailwind - Requires Tailwind CSS v4 in the host application (see Styling above)
- CodeMirror components are optional peer deps — import from
najm-kit/jsononly if needed
NTable export, import, and print actions
Enable dataActions for built-in Export, Import, and Print icon buttons beside
Create, with no action callbacks required. The same actions are available from
the Table actions button and by right-clicking the toolbar or a column header.
<NTable
data={members}
columns={columns}
dataActions
toolbarActionDisplay="both"
/>To enable defaults across an app, use
<NTableDefaultsProvider value={{ dataActions: true }}>. An individual table
can opt out with dataActions={false}. Existing tables keep their current
behavior until enabled.
Control each action with showExportButton, showImportButton, and
showPrintButton, following showAddButton. A true flag enables that action's
default workflow without needing dataActions or a callback. A false flag
hides it from both the toolbar and the default header menu, even when
dataActions, an inherited default, or a callback would otherwise enable it.
Omitted flags keep the existing automatic behavior. Custom menu.header items
remain application-owned.
// Import only, including when the app enables all actions by default.
<NTable
data={members}
columns={columns}
showImportButton
showExportButton={false}
showPrintButton={false}
/>
// Export and Print, with Import hidden.
<NTable data={members} columns={columns} dataActions showImportButton={false} />- Export: downloads
table.csv, using visible accessor columns and the loaded rows after filtering and sorting, before client pagination. In server pagination mode this includes only the rows the app has fetched. Headers use accessor keys (or column IDs) so files can be imported again. Text that could run as a spreadsheet formula is prefixed with an apostrophe. - Import: accepts a JSON array of row objects or comma-separated CSV with a header row, shows a preview, and replaces the displayed rows on confirmation. CSV values stay strings; dotted accessor keys become nested fields. JSON preserves value types. File fields must match what your columns/renderers expect. Import performs structural parsing, not application schema validation.
- Print: opens the browser print dialog for a plain table containing the same rows and columns as export, without the app's navigation and controls.
Default import changes this table's local data without mutating data or saving
to a server. The next new data array from the parent replaces that local
dataset. A locally imported dataset uses local pagination even when the table
was displaying server-paginated results.
Supply onDataChange={async (rows) => ...} to take ownership of imported rows
instead, validate/save them, and update data through your app's normal flow.
The import dialog waits for this callback and remains open on rejection so the
user can retry. It does not install local rows when this callback is supplied.
Import also works on empty tables.
toolbarActionDisplay accepts "buttons", "menu", or "both" (default).
Menu mode provides a visible trigger for touch and keyboard users. Right-click
menus apply to the toolbar and column headers, keeping row and background menus
independent. Use menu={{ header: () => [...] }} to replace the default header
menu with your own ContextMenuItem[], including format submenus or extra
actions. onExport, onImport, and onPrint override individual default
workflows and also enable that action on their own. Use them for custom formats,
server-wide export, application import workflows, or a custom print layout.
Controls are disabled during the first load and remain usable during a refresh
with existing rows.
Localize export, import, print, and tableActions through toolbarLabels
or NTableDefaultsProvider. NajmAppProvider reads the corresponding
common.table.* keys, falling back to English when an action key is missing.
The import dialog also accepts importDescription, importFile,
importReading, importPreview(count), importConfirm, importCancel, and
importFailed. Its parser errors fall back to their English detail.
The playground's Table > Export, import, and print example demonstrates the
defaults, visibility switches, and empty/loading states. Run its desktop/mobile
browser acceptance with
bun run --cwd packages/najm-kit test:acceptance table-data-actions.acceptance.ts
from the repository root. Acceptance evidence
records coverage, screenshots, and the print-dialog boundary.
NTable responsive columns
NTable accepts an NTableColumnDef<T>[]. Each column's meta can carry:
visible?: boolean— app-owned eligibility gate. Defaults totrue. Set this from your role / capability decision. Columns withvisible: falseare removed from headers, body cells, the loading skeleton, and the column-settings menu.hiddenBelow?: "sm" | "md" | "lg" | "xl" | "2xl"— hide the table column below the chosen Tailwind breakpoint. The column remains visible at that breakpoint and above (mobile-first). Table view only.
Inline editing is part of the same metadata contract. Set
editable?: boolean | ((row) => boolean) and pass onCellEdit to activate it.
Use editor?: "text" | "number" | "select" | "checkbox" | "textarea" to
choose the control. Number editors also accept min, max, and step; select
editors use options; every editor can use validate.
import { NTable, type NTableColumnDef } from "najm-kit";
const columns: NTableColumnDef<Family>[] = [
{
accessorKey: "name",
header: "Family account",
meta: {
editable: (family) => canEdit(family),
editor: "text",
validate: (value) => value.trim() ? null : "Name is required",
},
},
{
accessorKey: "email",
header: "Email",
meta: {
visible: can("families.email.read"),
hiddenBelow: "lg",
},
},
];
<NTable
data={families}
columns={columns}
onCellEdit={(family, columnId, value) => updateFamily(family.id, { [columnId]: value })}
/>Notes:
visibleis application-owned eligibility, not an NTable role system.NTablenever importsnajm-author reads a session; convert your own role / capabilities to a boolean.- Omitting
visibleis the same astrue. hiddenBelowis table-only. Card view, JSON view, and custom modes ignore it. Cards must do their own capability gating insiderenderCard.- Hiding a column is presentation only. The backend must still enforce the permission and privacy-project the field. Never rely on UI hiding to protect sensitive data.
- The user-controlled column visibility menu (settings → Columns) keeps working independently. It can report a column as selected while CSS hides it below the configured breakpoint.
- The columns the TanStack table receives are already filtered, so the
settings menu will not list
visible: falsecolumns.
If you need to inspect or build your own effective column list, the same
pure helper is exported as filterResponsiveColumns. The literal class
map is also exported as hiddenBelowClasses, and
resolveHiddenBelowClass(breakpoint) returns the class for a single
breakpoint or undefined when no breakpoint is set.
NTable responsive cards, loading, and pagination
Responsive row actions are visible by default on phone, tablet, and coarse or
non-hover pointers. Fine-pointer desktop layouts may reveal them on hover, but
keyboard focus always reveals the action. Applications still decide which menu
items exist through menu, onView, onEdit, and onDelete; visibility does
not grant an action or replace server authorization.
When dynamicHeight is enabled, table and card loading skeletons measure the
available body. Table rows use the same header/row geometry as dynamic page
sizing, while cards measure the active grid columns, card height, and gap. The
loading surface also follows the loaded bordered, design recipe, radius,
border color, shadow, and classNames.content/classNames.cards contract.
The measured fit owns the initial page size. Once a reader explicitly chooses
Rows/page, NTable preserves that choice and scrolls the bounded table body
when the requested rows exceed the available height.
Use cardPagination to choose pagination presentation whenever the effective
rendered mode is cards:
{ mode: "paged" }(the default) preserves existing pagination.{ mode: "all" }renders every row already supplied and hides the footer.{ mode: "load-more", ... }renders every supplied row and provides a guarded, keyboard-operable Load more/Retry control with polite loading, appended-result, and end-of-list announcements.
showPagination={false} remains an absolute presentation override and hides
both numbered controls and Load more. In table mode, existing controlled and
manual server pagination remains unchanged.
import { NTable, type NTableCardPagination } from "najm-kit";
const cardPagination: NTableCardPagination = {
mode: "load-more",
hasNextPage: query.hasNextPage,
loadingMore: query.isFetchingNextPage,
loadMoreError: query.isFetchNextPageError
? "The next page could not be loaded."
: undefined,
onLoadMore: () => query.fetchNextPage(),
loadMoreLabel: "Load more",
loadingMoreLabel: "Loading more...",
retryLabel: "Retry",
endLabel: "No more results.",
};
<NTable
data={query.data?.pages.flatMap((page) => page.rows) ?? []}
columns={columns}
getRowId={(row) => row.id}
renderCard={ResultCard}
cardPagination={cardPagination}
/>The application owns the query, cursor/offset, accumulated pages, cache invalidation, search/filter/sort semantics, authorization, and privacy projection. Najm Kit never imports React Query, calls an endpoint, invents a page size, or treats supplied rows as proof that every database row is loaded. Client sorting and filtering cover the rows currently supplied unless the application implements matching server-side behavior.
For a responsive screen that uses current-page data in desktop table mode and
accumulated pages in card mode, keep those two query shapes in the application
and pass the appropriate data. Crossing the <640px responsive-card
breakpoint does not overwrite the user's chosen view, pagination position,
sorting, filters, expansion, or row selection.
Theme-backed charts
NBarChart, NLineChart, NPieChart, and NStatusBreakdown accept generic
caller-formatted data and use --chart-1 through --chart-5 by default.
Colors repeat deterministically after the fifth series or item; set color on
an exceptional series/item to override that one value. Each chart accepts
loading/loadingLabel and renders an accessible shape-matched skeleton.
NPieChart and NDonutCard accept size="sm" | "md" | "lg" or a numeric
pixel diameter and shrink within narrow containers.
import { NBarChart, NPieChart } from "najm-kit";
const data = [
{ id: "jan", label: "Jan", values: { received: 12, refunded: 2 } },
{ id: "feb", label: "Feb", values: { received: 18, refunded: 1 } },
];
<NBarChart
title="Monthly activity"
data={data}
series={[
{ id: "received", label: "Received" },
{ id: "refunded", label: "Refunded" },
]}
valueFormatter={(value) => `${value} MAD`}
/>
<NPieChart
title="Status"
size={132}
items={[
{ id: "active", label: "Active", value: 8 },
{ id: "pending", label: "Pending", value: 3 },
]}
/>Server-backed combobox search
ComboboxInput and FormInput type="combobox" can delegate filtering to a
server by setting shouldFilter={false} and handling onSearchChange. Use
loading and loadingMessage while replacement options are being fetched.
Client-side filtering remains the default.
Person image fallbacks (najm-kit/person-images)
A framework-neutral, React-free subpath that resolves person-image fallbacks
for any application. The seven WebP illustrations are embedded as base64 data
URLs in the published bundle, so consumers do not need to copy package files
into public/ or wire an asset server.
import { getPersonImage } from "najm-kit/person-images";
const childSrc = getPersonImage({
image: child.image,
role: "child",
gender: child.gender,
});Built-in roles:
| Role | Default | Female | Male |
| -------- | ---------------- | --------------- | --------------- |
| child | male child art | female child | male child |
| adult | male adult art | female adult | male adult |
| parent | male parent art | female parent | male parent |
| family | neutral family | neutral family | neutral family |
Resolution precedence, for every call:
- A real
image(anything that survivesresolveAvatarSrc). - A per-call
fallbackthat is not blank and is not thenoavatar.pngsentinel. - The configured role's gender variant, or the role's required
defaultwhen the variant or the gender is missing.
The per-call fallback is treated like a real source: an empty string, a
blank trimmed value, or any noavatar.png path falls through to the role
default. The Kafil data is a worked example: children use role: "child",
households use role: "family", sponsors, staff, applicants, and delivery
staff use role: "adult", and a household parent uses role: "parent"
after the family dashboard maps its relationship value (mother, mère,
madre, أم, …) to F, M, or null at the feature boundary.
Custom roles
createPersonImageResolver returns a typed resolver that accepts the
application's own role names. Unknown role strings fail type checking:
import { createPersonImageResolver } from "najm-kit/person-images";
const getSmsPersonImage = createPersonImageResolver({
teacher: {
default: "/images/teachers/default.webp",
female: "/images/teachers/female.webp",
male: "/images/teachers/male.webp",
},
student: {
default: "/images/students/default.webp",
female: "/images/students/female.webp",
male: "/images/students/male.webp",
},
});
const teacherSrc = getSmsPersonImage({
image: teacher.image,
role: "teacher",
gender: teacher.gender,
});The factory merges custom definitions over the built-in map. A custom child
override replaces the built-in child art for that application alone — the
package itself is untouched, and other consumers keep their built-in
fallbacks.
Custom paths may be application-relative URLs, managed API URLs, CDN URLs, or data URLs. najm-kit does not fetch, upload, authorize, or persist them.
Per-call fallback override
Every call accepts a fallback. It overrides the role default for that call
only, after a real image and before the role's gender variant:
getPersonImage({ image: child.image, role: "child", gender: child.gender, fallback: child.placeholder });Server UI bootstrap (najm-kit/server, najm-kit/server/react)
An application that renders its own theme and its own logos on the server ends
up writing the same module every time: fetch the public endpoints, unwrap the
data envelope, validate the payload, fall back to the built-in assets when
any of that fails, and run the resources in parallel. These two entries own
that mechanism. What stays with the application is what is genuinely
application-specific — how a request reaches its own backend, which paths it
serves, what a valid payload looks like, what the factory values are, and where
a diagnostic goes.
Neither entry is re-exported from najm-kit, najm-kit/next, or
najm-kit/app. najm-kit/server imports no React at all, so a route handler
or a plain script can use it.
The application's one server module
// src/lib/serverLoader.ts
import "server-only";
import { parseNajmDesignConfig } from "najm-kit/server";
import { createReactServerUiBootstrap } from "najm-kit/server/react";
export const serverUi = createReactServerUiBootstrap({
fetcher: async (path) => {
const { server } = await import("@app/server");
return server.fetch(new Request(`http://internal${path}`));
},
resources: {
appearance: {
path: "/api/appearance",
parse: parseAppearance, // returns undefined or throws to reject
fallback: getFactoryAppearance, // called per load
},
branding: {
path: "/api/branding",
parse: parseBranding,
fallback: getFactoryBranding,
},
},
onDiagnostic: (diagnostic) => {
console.warn(`[ui-bootstrap] ${diagnostic.resource} ${diagnostic.reason}`, diagnostic);
},
});
export const loadServerUiBootstrap = serverUi.load;
export const { appearance: loadServerAppearance, branding: loadServerBranding } =
serverUi.loaders;load() resolves every resource; loaders.<name>() and loadResource(name)
read one off the same resolution. Resource names, payload types, and the number
of resources are the application's — the snapshot type is inferred from the
resources object, so snapshot.branding is your branding type and not a
package interface.
Call the factory once, at module scope
createReactServerUiBootstrap() builds one React.cache() entry. Calling it
inside a layout, page, or component builds a fresh one per call and shares
nothing. Every server boundary in a render must import the same module.
The cache is React's, so it is request-scoped and nothing else: separate
requests never see each other's snapshot or each other's failure, and a
transient outage is retried on the next request rather than pinned into a
process-global. That also rules out a module Map, a module promise,
unstable_cache, "use cache", or a durable cache here — every one of them
would leak one visitor's render into another's.
The snapshot is deliberately stable for the length of one render. A settings surface that saves appearance or branding updates the client provider and then refreshes or navigates into a new render to observe the persisted result.
Outside a render — route handlers, server actions, scripts — use
createUiBootstrapLoader() from najm-kit/server directly. There is no request
cache for cache() to write to there, so the adapter would silently re-fetch
per call.
Failure behaviour
Resources fall back independently: a branding outage never discards a valid
appearance. Each failure calls onDiagnostic once with a reason of
fetch-failed, response-not-ok, invalid-json, invalid-envelope, or
invalid-payload, plus the path and — for a non-success response — the status.
Diagnostics never carry response bodies, headers, cookies, or raw thrown
values; error is a normalized "<name>: <message>" for an Error and the
value's type for anything else.
A fallback() that throws is not caught. A missing factory theme is the
application's configuration error, and a second fallback would only hide it.
Falling back is right for public appearance and branding, where the worst case is a visitor seeing the built-in logo. It is not a general rule: do not route authenticated, financial, or privacy-sensitive reads through this, because a silent fallback there hides an outage behind plausible-looking data.
Envelopes
select defaults to Najm's { data } envelope. Applications behind a different
envelope pass their own at the loader level or per resource; returning the
payload unchanged is a valid selector, and throwing rejects the response as
invalid-envelope.
Client Components
najm-kit/server/react maps the browser export condition to a module that
throws, so importing it from a Client Component fails the build with an
explanation rather than shipping the application's fetcher and factory values
into a browser bundle. Seed the client from the server snapshot through
NajmAppProvider instead.
Language, theme, and time-zone preferences (najm-kit/server)
Three route handlers and a root layout, as configuration. defineNajmPreferences
owns the parts every application writes identically — validating a posted value,
writing a secure cookie, answering 400 for anything else, and reading the three
cookies back before the first paint.
// src/preferences.ts
import { defineNajmPreferences } from "najm-kit/server";
import { appI18n } from "@app/server/locales";
export const preferences = defineNajmPreferences({ i18n: appI18n });That is the whole configuration for a new application. light is the default
theme, light | dark the only accepted modes, UTC the default time zone, the
canonical TimeZoneInput list the accepted zones, najm-ui-language,
najm-ui-theme, and najm-ui-timezone the cookie names, and the cookies are
HttpOnly, SameSite=Lax, Path=/, one year. None of it is restated by the
application, and there is no guard or normalizer to call.
An application with published cookie names or a different product default overrides only those:
export const preferences = defineNajmPreferences({
i18n: appI18n,
defaultTimeZone: "Africa/Casablanca",
cookieNames: {
language: "app-ui-language",
theme: "app-ui-theme",
timeZone: "app-ui-timezone",
},
});i18n is structural — supportedLanguages, defaultLanguage, and
normalizeLanguage. A najm-i18n definition satisfies it as it is, and
najm-i18n stays an optional peer.
The three route files
Each is one line. The handlers are (request: Request) => Promise<Response>,
which is exactly a Next.js route handler.
// src/app/api/ui-language/route.ts
import { preferences } from "@/preferences";
export const POST = preferences.handlers.language;
// src/app/api/ui-theme/route.ts
export const POST = preferences.handlers.theme;
// src/app/api/ui-timezone/route.ts
export const POST = preferences.handlers.timeZone;These are the endpoints NajmNextUIProvider and NajmAppProvider already POST
to by default. A handler validates before it normalizes, so an unsupported value
is a 400 with a generic message and no Set-Cookie — it never becomes the
default written into a cookie. Malformed JSON, a non-object body, and a missing
field are the same 400. Nothing from the request body reaches the response.
The root layout
// src/app/layout.tsx
import { cookies, headers } from "next/headers";
import { preferences } from "@/preferences";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const [cookieStore, requestHeaders, session] = await Promise.all([
cookies(),
headers(),
getSession(),
]);
const { language, theme, timeZone } = preferences.resolve(cookieStore, {
languageFallback: session?.user.language,
acceptLanguage: requestHeaders.get("accept-language"),
});
return (
<html
lang={language}
dir={appI18n.direction(language)}
data-time-zone={timeZone}
className={theme === "dark" ? "dark" : ""}
suppressHydrationWarning
>
<body>{children}</body>
</html>
);
}resolve takes anything with get(name) — Next's cookie store, or a plain
object in a test. Language precedence is cookie, then languageFallback, then
the acceptLanguage request header, then the catalog default. Browser
negotiation honors quality weights and regional tags; invalid stored values
fall through rather than pinning the UI.
Types
NajmPreferenceLanguage<typeof preferences> and
NajmPreferenceTimeZone<typeof preferences> are inferred from the definition,
and NajmMode is the theme union. An application declares no AppLanguage,
AppTheme, or AppTimeZone alias of its own.
Time zones
NAJM_TIME_ZONES is the single canonical list. TimeZoneInput builds its
options from it and the default handlers accept exactly it, so a zone cannot be
offered by the control and rejected by the server. An application that passes
custom items to the input must pass the same values as timeZones here:
const zones = ["Europe/Paris", "Africa/Casablanca"] as const;
export const preferences = defineNajmPreferences({ i18n: appI18n, timeZones: zones });
<TimeZoneInput items={zones.map((value) => ({ value, label: "" }))} />Currency choices
NAJM_CURRENCY_OPTIONS provides reusable select items, and NAJM_CURRENCIES
provides their codes for defineNajmPreferences({ currencies }) and validation.
Both are available from najm-kit and the React-free najm-kit/server entry.
The package does not choose an institution's default currency; applications
set defaultCurrency themselves and may use a subset of these choices.
Cookie options
cookieOptions merges per key over the defaults. secure is not set by
default, so these cookies survive http://localhost and a deployment that
terminates TLS at the edge; an application served only over HTTPS should set it:
defineNajmPreferences({ i18n: appI18n, cookieOptions: { secure: true } });The returned definition, its cookieNames, cookieOptions, timeZones, and
handlers are all frozen.
Location picker
najm-kit/location provides a provider-neutral composite form field. For a
runtime-selected provider, pass a serializable configuration to the client-only
runtime entry; it defers the selected adapter import until the dialog renders
its map:
import { FormLocationInput } from "najm-kit/location";
import { NLocationRuntimeProvider } from "najm-kit/location/runtime";
<NLocationRuntimeProvider config={locationConfig} geocoder={approvedGeocoder}>
<FormLocationInput name="deliveryLocation" formLabel="Address" />
</NLocationRuntimeProvider>Leaflet-only applications can import
NLeafletLocationRuntimeProvider from najm-kit/location/runtime/leaflet so
their build graph never needs the optional Google Maps loader.
locationConfig may select disabled, leaflet, or google. It is safe to
serialize only when browser-visible provider values are used; never put a
server geocoding secret in it. The optional geocoder remains an explicit
application policy and never defaults to a public service.
For a fixed adapter, use the lower-level provider directly:
import { FormLocationInput, NLocationProvider } from "najm-kit/location";
import { createLeafletLocationAdapter } from "najm-kit/location/leaflet";
const adapter = createLeafletLocationAdapter({
tileUrl: "https://tiles.example.test/{z}/{x}/{y}.png",
attribution: "Required provider attribution",
});
<NLocationProvider adapter={adapter} defaultCenter={{ latitude: 33.5731, longitude: -7.5898 }}>
<FormLocationInput name="deliveryLocation" formLabel="Address" />
</NLocationProvider>The field value is { address, latitude, longitude }; coordinates are either
a complete finite pair in range or both null. Search is absent unless an
explicit NLocationGeocoderAdapter is supplied. The Google subpath exports
createGoogleLocationAdapter and createGooglePlacesGeocoder; its browser key
must be restricted by exact origins and enabled APIs.
Header actions and notifications
Every Najm dashboard grows the same four controls in its page header: a bell with an unread badge and a short preview list, a language menu, a theme toggle, and a fullscreen toggle. The package owns all of their presentation and interaction. Applications keep what is actually theirs — the API client, the query keys and polling, the router, the notification topics, the translation catalogs, and what a language change must also persist.
import {
NGlobalActions,
NLanguageMenu,
NThemeToggle,
NFullscreenToggle,
NNotifyMenu,
} from "najm-kit";
<NGlobalActions>
<NNotifyMenu {...notifications} />
<NLanguageMenu
label={t("language.label")}
onChange={changeLanguage}
options={languages}
value={language}
/>
<NThemeToggle label={t("theme.toggle")} onError={showError} />
<NFullscreenToggle label={t("fullscreen.toggle")} />
</NGlobalActions>NGlobalActions is the group container. It works inside NPageHeaderActions,
the legacy actions prop, and a navbar slot, and it fetches, translates,
authorizes and persists nothing.
The simple preset: NNotifyMenu
NNotifyMenu renders the whole list-with-read-button workflow from normalized
data, labels and callbacks. Use it when the preview data is already available.
<NNotifyMenu
items={items}
labels={labels}
loading={list.isPending}
error={list.isError}
markAllPending={markAll.isPending}
markReadPendingId={markRead.isPending ? markRead.variables : null}
onError={reportCommandFailure}
onMarkAllRead={() => markAll.mutateAsync()}
onMarkRead={(id) => markRead.mutateAsync(id)}
onOpenItem={(item) => router.push(item.href ?? "/notifications")}
onRetry={() => list.refetch()}
unreadCount={unreadCount}
viewAllLink={<Link href="/notifications">{labels.viewAll}</Link>}
/>The normalized row
interface NNotifyItemData {
id: string;
title: string;
body?: string;
href?: string;
read: boolean;
createdAt?: string | Date;
icon?: ComponentType<{ className?: string }> | ReactNode;
tone?: "default" | "success" | "warning" | "destructive";
}There is no topic, payload, aggregate, recipient or response shape in it, and
href is data: the package never imports a router. It hands the item back
through onOpenItem and the application decides what navigation means. An
application whose records are already titled maps them directly:
const items = rows.map((row) => ({
id: row.id,
title: row.title,
body: row.body,
href: row.href ?? undefined,
read: row.readAt !== null,
createdAt: row.createdAt,
}));An application whose records carry a topic keeps its registry — the safe copy for an unknown topic, the icon, the tone and the internal route are product decisions, not package ones:
const items = rows.map((row) => {
const view = buildNotificationViewModel(row.topic, locale, fallback);
return {
id: row.id,
title: view.title,
body: view.body,
href: `${view.href}?focus=${row.id}`,
read: row.readAt !== null,
createdAt: row.createdAt,
icon: view.icon,
tone: view.token,
};
});The compound form, for lazily loaded previews
The parts are exported flat — there is no NNotifications.Root namespace. Use
them when the preview query must only run while the menu is open, because
NNotifyContent does not render its children while the menu is closed:
<NNotifyRoot onOpenChange={setOpen} open={open}>
<NNotifyTrigger
label={labels.open}
unreadCount={unreadCount}
unreadLabel={labels.unread}
/>
<NNotifyContent>
<ConnectedNotificationPreview />
</NNotifyContent>
</NNotifyRoot>ConnectedNotificationPreview is an application component and may call
application hooks; it composes NNotifyHeader, NNotifyList and
NNotifyFooter around its own query. Najm Kit calls none of those hooks.
Commands, pending and failure
Every command prop is awaited. NNotifyHeader, NNotifyItem and
NNotifyFooter track their own pending state, refuse a repeated click while one
is in flight, and accept an application-owned pending flag as well
(markAllPending, markReadPendingId). A rejected command reports through
onError(error, action) and — this is the point — never fakes completion: a
failed mark-read does not navigate, does not close the menu, and leaves the row
enabled again. The package emits no product copy for the failure; the
application already has a place to show one.
Labels and counts
NNotifyLabels carries every visible string, including unread(count) for the
screen-reader announcement. The badge hides at zero, shows a localized number
for 1-99 and 99+ above that. Digits follow locale, or the document language
when it is omitted; formatCount replaces the rule entirely.
NLanguageMenu, NThemeToggle, NFullscreenToggle
NLanguageMenu owns the dropdown, the selected state and the pending state, and
awaits the application's onChange. It never calls useTranslation or a
language endpoint itself, so an application keeps its own transaction — persist
the user preference, change the package language, refresh the session,
invalidate queries, synchronize an external notification locale. Flags are
optional injected nodes (icon, iconLabel); the package does not depend on
flag-icons.
NThemeToggle reads theme and setTheme from useNajmTheme, awaits
persistence, and always releases its pending state. NFullscreenToggle owns
capability detection through screenfull, is safe to render during SSR and
hydration, disables itself where the API is missing, and stays hidden below sm
unless hiddenBelow says otherwise.
