najm-kit 2.7.2 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
- # Changelog
2
-
3
- ## 2.6.2
1
+ # Changelog
2
+
3
+ ## 2.8.0 - 2026-08-08
4
+
5
+ - Added server-safe `najm-kit/format` helpers for currency minor units,
6
+ numbers, percentages, dates, times, relative time, tokens, local date inputs,
7
+ and slugs. Client applications can use the same contract reactively through
8
+ `NajmFormatProvider` and `useNajmFormat`.
9
+ - Added the server-safe `najm-kit/pagination` offset protocol, including bounded
10
+ page creation, total-aware continuation, probe-row continuation for APIs
11
+ without totals, and query cleanup that preserves meaningful `false` and `0`.
12
+ - Added the optional-peer `najm-kit/query` entry with offset infinite-query and
13
+ responsive paged/card-list hooks, plus shared card-pagination adapters and
14
+ localized continuation labels.
15
+ - Extended `NajmAppProvider` with formatting locale, currency, and placeholder
16
+ configuration so applications can bind language, time zone, and formatting
17
+ without another host bridge provider.
18
+ - Added shared media-query/card-viewport helpers and avatar-source utilities.
19
+
20
+ ## 2.6.2
4
21
 
5
22
  - Added `NSidebarProvider` and `useNSidebar`, so sidebar state can be read from a distance. `NSidebar` renders beside the page content rather than around it, which left applications hand-rolling a context to hand `setMobileOpen` down to a page header — a wrapper component plus an aliased import at every call site. Wrap the shell in `NSidebarProvider` and `NPageHeader` now resolves both `onSidebarOpen` and `mobileBreakpoint` from it, so a header nested anywhere below renders a working mobile trigger with no props threaded to it. Also exports the `NSidebarContextValue` type.
6
23
  - `NSidebar` resolves its open and collapsed state as explicit prop → surrounding provider → internal state. Passing `collapsed`, `mobileOpen`, `onCollapsedChange`, or `onMobileOpenChange` keeps behaving exactly as before, and a sidebar with no provider around it still owns its own state, so this is additive for every existing consumer.
package/README.md CHANGED
@@ -212,10 +212,93 @@ Key behaviors:
212
212
  a newer value, and object URLs created by the component are tracked so
213
213
  consumer-owned blob URLs are never revoked.
214
214
 
215
- `AvatarInput` forwards every preview and accessibility prop unchanged while
216
- preserving its circular, size, fill, and camera-icon defaults.
217
-
218
- ## Hooks
215
+ `AvatarInput` forwards every preview and accessibility prop unchanged while
216
+ preserving its circular, size, fill, and camera-icon defaults.
217
+
218
+ ## Formatting
219
+
220
+ Pure formatters are available from the server-safe `najm-kit/format` entry.
221
+ Money values are integer minor units and use the currency's own exponent (for
222
+ example MAD has two decimals, JPY zero, and KWD three).
223
+
224
+ ```ts
225
+ import { formatCurrency, formatDate, slugify } from 'najm-kit/format';
226
+
227
+ formatCurrency(12_500, { locale: 'fr-MA', currency: 'MAD' });
228
+ formatDate('2026-08-08T20:00:00Z', {
229
+ locale: 'fr-MA',
230
+ timeZone: 'Africa/Casablanca',
231
+ });
232
+ slugify('Najm Format & Pagination');
233
+ ```
234
+
235
+ Client code can use the active locale, time zone, currency, and placeholder
236
+ through `useNajmFormat`. `NajmAppProvider` mounts the format provider for you:
237
+
238
+ ```tsx
239
+ import { NajmAppProvider } from 'najm-kit/app';
240
+ import { useNajmFormat } from 'najm-kit';
241
+
242
+ <NajmAppProvider
243
+ translations={translations}
244
+ currency="MAD"
245
+ locales={{ en: 'en-MA', fr: 'fr-MA' }}
246
+ >
247
+ <App />
248
+ </NajmAppProvider>
249
+
250
+ function Total({ value }: { value: number }) {
251
+ return <span>{useNajmFormat().money(value)}</span>;
252
+ }
253
+ ```
254
+
255
+ ## Offset pagination and queries
256
+
257
+ `najm-kit/pagination` is server-safe and framework-independent. It accepts
258
+ endpoints that return either `{ rows, total }` or a bare row array. When no
259
+ total exists it probes for one extra row; when a total exists continuation is
260
+ calculated without another request.
261
+
262
+ ```ts
263
+ import {
264
+ createOffsetPagination,
265
+ fetchOffsetPage,
266
+ } from 'najm-kit/pagination';
267
+
268
+ const pagination = createOffsetPagination(pageIndex, pageSize);
269
+ const page = await fetchOffsetPage(
270
+ ({ limit, offset }) => api.orders.list({ limit, offset }),
271
+ pagination,
272
+ );
273
+ ```
274
+
275
+ React Query consumers install the optional `@tanstack/react-query` peer and use
276
+ the isolated `najm-kit/query` entry. `useResponsiveOffsetList` resolves numbered
277
+ desktop paging versus card continuation and exposes props that plug directly
278
+ into `NTable` and `createCardPagination`.
279
+
280
+ ```tsx
281
+ import { NTable, createCardPagination } from 'najm-kit';
282
+ import { useResponsiveOffsetList } from 'najm-kit/query';
283
+
284
+ const list = useResponsiveOffsetList({
285
+ queryKey: ['orders'],
286
+ fetchPage: ({ limit, offset }) => api.orders.list({ limit, offset }),
287
+ strategy: 'paged',
288
+ });
289
+
290
+ <NTable
291
+ data={list.data}
292
+ columns={columns}
293
+ manualPagination
294
+ pageCount={list.pageCount}
295
+ pagination={list.pagination}
296
+ onPaginationChange={list.onPaginationChange}
297
+ cardPagination={createCardPagination(list, labels)}
298
+ />
299
+ ```
300
+
301
+ ## Hooks
219
302
 
220
303
  ```tsx
221
304
  import { useKeyboard } from 'najm-kit';
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as React$1 from 'react';
3
3
  import React__default from 'react';
4
- import { a as NTablePaginationLabels, e as NajmTranslate } from './paginationLabels-CY2PvbMj.js';
4
+ import { b as NTablePaginationLabels, N as NajmTranslate } from './paginationLabels-dZLSNxfo.js';
5
5
 
6
6
  type NajmMode = 'light' | 'dark';
7
7
  type NajmAccent = 'neutral' | 'emerald' | 'green' | 'slate' | 'blue' | 'violet';
@@ -3,8 +3,8 @@ import { Translations } from 'najm-i18n';
3
3
  import { N as NBrandingInput } from '../NBrandingContext-dkADu8JS.js';
4
4
  import { NajmNextUIProviderProps } from './next.js';
5
5
  import 'react';
6
- import '../NajmUIProvider-ClpHD-55.js';
7
- import '../paginationLabels-CY2PvbMj.js';
6
+ import '../NajmUIProvider-BSbXaqak.js';
7
+ import '../paginationLabels-dZLSNxfo.js';
8
8
 
9
9
  /** Branding shown by the kit's chrome. Purely presentational values. */
10
10
  interface NajmAppBranding {
@@ -1,8 +1,9 @@
1
1
  'use client';
2
- import { NBrandingStateProvider, NajmFormatProvider } from '../chunk-QOP7L6RD.mjs';
2
+ import { NBrandingStateProvider, NajmFormatProvider } from '../chunk-PCPMAEKP.mjs';
3
3
  import { NajmNextUIProvider } from '../chunk-IRFFSAO2.mjs';
4
4
  import '../chunk-USZUOJMK.mjs';
5
5
  import '../chunk-KVZACF4G.mjs';
6
+ import '../chunk-JABLSOQN.mjs';
6
7
  import * as React from 'react';
7
8
  import { I18nProvider, useTranslation } from 'najm-i18n/react';
8
9
  import { jsx } from 'react/jsx-runtime';
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import React__default from 'react';
3
- import { N as NajmUIProviderProps } from '../NajmUIProvider-ClpHD-55.js';
4
- import '../paginationLabels-CY2PvbMj.js';
3
+ import { N as NajmUIProviderProps } from '../NajmUIProvider-BSbXaqak.js';
4
+ import '../paginationLabels-dZLSNxfo.js';
5
5
 
6
6
  interface NextLinkAdapterProps extends Record<string, any> {
7
7
  href: string;
@@ -0,0 +1,65 @@
1
+ import { N as NajmTranslate, a as NTableCardPagination } from './paginationLabels-dZLSNxfo.js';
2
+
3
+ /**
4
+ * How a list continues.
5
+ *
6
+ * - `paged` — numbered pages on desktop, scroll continuation on card viewports.
7
+ * - `infinite` — scroll continuation everywhere.
8
+ * - `all` — one request for the whole set, no controls. Only valid for a list
9
+ * with a proven bound.
10
+ */
11
+ type ListStrategy = "all" | "infinite" | "paged";
12
+ /** The presentation mode resolved for the current viewport and strategy. */
13
+ type ResolvedListMode = "all" | "infinite" | "paged";
14
+ interface CardPaginationState {
15
+ /**
16
+ * The resolved mode. When omitted, `cardViewport` alone decides — a card
17
+ * viewport continues on scroll, a desktop one paginates.
18
+ */
19
+ mode?: ResolvedListMode;
20
+ cardViewport?: boolean;
21
+ hasNextPage: boolean;
22
+ loadingMore: boolean;
23
+ /**
24
+ * Truthy when the last append failed. The value is not rendered — the message
25
+ * shown is `loadMoreError` from the labels, so a thrown `Error` or an API
26
+ * envelope can be passed straight through without leaking its text into the UI.
27
+ */
28
+ loadMoreError?: unknown;
29
+ onLoadMore: () => unknown | Promise<unknown>;
30
+ }
31
+ interface CardPaginationLabels {
32
+ loadMoreError?: string;
33
+ retryLabel?: string;
34
+ itemsLoaded?: (count: number) => string;
35
+ }
36
+ declare const DEFAULT_CARD_PAGINATION_KEY_PREFIX = "common.pagination";
37
+ type DefaultCardPaginationPrefix = typeof DEFAULT_CARD_PAGINATION_KEY_PREFIX;
38
+ /** The three catalog keys `buildCardPaginationLabels` reads under `Prefix`. */
39
+ type CardPaginationKey<Prefix extends string = DefaultCardPaginationPrefix> = `${Prefix}.itemsLoaded` | `${Prefix}.loadMoreError` | `${Prefix}.retryLoadMore`;
40
+ /**
41
+ * Projects a translator onto the three card-continuation labels, matching
42
+ * `buildPaginationLabels` — same prefix convention, same key-per-field naming.
43
+ *
44
+ * The keys are named in the type, not just built at runtime, so an application
45
+ * whose `t` is typed to a generated union of its catalog can pass it directly
46
+ * and have the three keys verified against that union.
47
+ */
48
+ declare function buildCardPaginationLabels<Prefix extends string = DefaultCardPaginationPrefix>(t: NajmTranslate<CardPaginationKey<Prefix>>, prefix?: Prefix): CardPaginationLabels;
49
+ /**
50
+ * Builds the `cardPagination` prop from the state a paged list already holds.
51
+ *
52
+ * The three modes are not interchangeable presentations of one thing: `paged`
53
+ * hands page-size control back to NTable, which measures the container and
54
+ * reports the size it wants through `onPaginationChange`; `all` renders exactly
55
+ * what it is given and shows no controls; `infinite` is the only one that needs
56
+ * continuation wiring, which is why the other two ignore the labels entirely.
57
+ *
58
+ * The second argument takes a translator as well as a label bundle. That is the
59
+ * common case — a list page has `t` in hand and nothing else to say about these
60
+ * three strings — and passing it here rather than pre-building labels also means
61
+ * the lookups only happen in `infinite` mode, where they are rendered.
62
+ */
63
+ declare function createCardPagination<Prefix extends string = DefaultCardPaginationPrefix>(state: CardPaginationState, labels?: CardPaginationLabels | NajmTranslate<CardPaginationKey<Prefix>>, prefix?: Prefix): NTableCardPagination;
64
+
65
+ export { type CardPaginationKey as C, DEFAULT_CARD_PAGINATION_KEY_PREFIX as D, type ListStrategy as L, type ResolvedListMode as R, type CardPaginationLabels as a, type CardPaginationState as b, buildCardPaginationLabels as c, createCardPagination as d };
@@ -0,0 +1,42 @@
1
+ // src/lib/pagination.ts
2
+ var DEFAULT_PAGE_SIZE = 25;
3
+ var DEFAULT_MAX_PAGE_SIZE = 100;
4
+ function toApiPage(result) {
5
+ return Array.isArray(result) ? { rows: result, total: null } : result;
6
+ }
7
+ function createOffsetPagination(pageIndex = 0, pageSize = DEFAULT_PAGE_SIZE, { maxLimit = DEFAULT_MAX_PAGE_SIZE } = {}) {
8
+ const safePageIndex = Math.max(0, Math.trunc(pageIndex));
9
+ const safePageSize = Math.min(maxLimit, Math.max(1, Math.trunc(pageSize)));
10
+ return { limit: safePageSize, offset: safePageIndex * safePageSize };
11
+ }
12
+ function getPageIndex({ limit, offset }) {
13
+ return Math.floor(Math.max(0, offset) / Math.max(1, limit));
14
+ }
15
+ async function fetchOffsetPage(fetchPage, pagination, { maxLimit = DEFAULT_MAX_PAGE_SIZE } = {}) {
16
+ const requestedLimit = Math.min(maxLimit, Math.max(1, pagination.limit));
17
+ const probeLimit = requestedLimit < maxLimit ? requestedLimit + 1 : requestedLimit;
18
+ const page = toApiPage(
19
+ await fetchPage({ limit: probeLimit, offset: pagination.offset })
20
+ );
21
+ const rows = page.rows.slice(0, requestedLimit);
22
+ const nextOffset = pagination.offset + rows.length;
23
+ if (page.total !== null) {
24
+ return {
25
+ rows,
26
+ hasNextPage: nextOffset < page.total,
27
+ nextOffset,
28
+ total: page.total
29
+ };
30
+ }
31
+ const hasNextPage = page.rows.length > requestedLimit || probeLimit === requestedLimit && page.rows.length === requestedLimit && toApiPage(await fetchPage({ limit: 1, offset: nextOffset })).rows.length > 0;
32
+ return { rows, hasNextPage, nextOffset, total: null };
33
+ }
34
+ function cleanQuery(query) {
35
+ return Object.fromEntries(
36
+ Object.entries(query).filter(
37
+ ([, value]) => value !== void 0 && value !== null && value !== ""
38
+ )
39
+ );
40
+ }
41
+
42
+ export { DEFAULT_MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE, cleanQuery, createOffsetPagination, fetchOffsetPage, getPageIndex };
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,114 @@
1
+ // src/format/format.ts
2
+ var DEFAULT_PLACEHOLDER = "\u2014";
3
+ var numberFormats = /* @__PURE__ */ new Map();
4
+ var dateFormats = /* @__PURE__ */ new Map();
5
+ function numberFormat(locale, options) {
6
+ const key = `${locale}|${options ? JSON.stringify(options) : ""}`;
7
+ let format = numberFormats.get(key);
8
+ if (!format) {
9
+ format = new Intl.NumberFormat(locale, options);
10
+ numberFormats.set(key, format);
11
+ }
12
+ return format;
13
+ }
14
+ function dateFormat(locale, options) {
15
+ const key = `${locale}|${JSON.stringify(options)}`;
16
+ let format = dateFormats.get(key);
17
+ if (!format) {
18
+ format = new Intl.DateTimeFormat(locale, options);
19
+ dateFormats.set(key, format);
20
+ }
21
+ return format;
22
+ }
23
+ function minorUnitScale(locale, currency) {
24
+ const digits = numberFormat(locale, { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
25
+ return 10 ** digits;
26
+ }
27
+ function toDate(value) {
28
+ const date = value instanceof Date ? value : new Date(value);
29
+ return Number.isNaN(date.getTime()) ? null : date;
30
+ }
31
+ function isBlank(value) {
32
+ return value === null || value === void 0 || value === "";
33
+ }
34
+ function formatCurrency(minorUnits, { locale, currency, placeholder = DEFAULT_PLACEHOLDER }) {
35
+ if (!currency) {
36
+ throw new Error(
37
+ "formatCurrency requires a `currency`. Set it on NajmFormatProvider or pass it here."
38
+ );
39
+ }
40
+ if (minorUnits === null || minorUnits === void 0) return placeholder;
41
+ if (!Number.isSafeInteger(minorUnits)) return placeholder;
42
+ return numberFormat(locale, {
43
+ style: "currency",
44
+ currency
45
+ }).format(minorUnits / minorUnitScale(locale, currency));
46
+ }
47
+ function formatNumber(value, { locale, placeholder = DEFAULT_PLACEHOLDER }, options) {
48
+ if (value === null || value === void 0 || !Number.isFinite(value)) {
49
+ return placeholder;
50
+ }
51
+ return numberFormat(locale, options).format(value);
52
+ }
53
+ function formatPercent(value, config, fractionDigits = 0) {
54
+ return formatNumber(value, config, {
55
+ style: "percent",
56
+ minimumFractionDigits: fractionDigits,
57
+ maximumFractionDigits: fractionDigits
58
+ });
59
+ }
60
+ function formatDate(value, {
61
+ locale,
62
+ timeZone,
63
+ placeholder = DEFAULT_PLACEHOLDER
64
+ }, options = { dateStyle: "medium" }) {
65
+ if (isBlank(value)) return placeholder;
66
+ const date = toDate(value);
67
+ if (!date) return placeholder;
68
+ return dateFormat(locale, { ...options, timeZone }).format(date);
69
+ }
70
+ function formatDateTime(value, config, options = {
71
+ dateStyle: "short",
72
+ timeStyle: "short"
73
+ }) {
74
+ return formatDate(value, config, options);
75
+ }
76
+ function formatTime(value, config) {
77
+ return formatDate(value, config, { timeStyle: "short" });
78
+ }
79
+ var RELATIVE_UNITS = [
80
+ ["year", 31536e6],
81
+ ["month", 2592e6],
82
+ ["day", 864e5],
83
+ ["hour", 36e5],
84
+ ["minute", 6e4],
85
+ ["second", 1e3]
86
+ ];
87
+ function formatRelativeTime(value, { locale, placeholder = DEFAULT_PLACEHOLDER }, now = Date.now()) {
88
+ if (isBlank(value)) return placeholder;
89
+ const date = toDate(value);
90
+ if (!date) return placeholder;
91
+ const elapsed = date.getTime() - (now instanceof Date ? now.getTime() : now);
92
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
93
+ for (const [unit, ms] of RELATIVE_UNITS) {
94
+ if (Math.abs(elapsed) >= ms) {
95
+ return format.format(Math.trunc(elapsed / ms), unit);
96
+ }
97
+ }
98
+ return format.format(0, "second");
99
+ }
100
+ function humanizeToken(value) {
101
+ return value.trim().replace(/[_-]+/g, " ").replace(/\s+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
102
+ }
103
+ function localDateInput(date = /* @__PURE__ */ new Date()) {
104
+ const month = String(date.getMonth() + 1).padStart(2, "0");
105
+ const day = String(date.getDate()).padStart(2, "0");
106
+ return `${date.getFullYear()}-${month}-${day}`;
107
+ }
108
+ function slugify(value, { upperCase = false, maxLength = 160 } = {}) {
109
+ const normalized = value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLength);
110
+ const slug = normalized || crypto.randomUUID().slice(0, 8);
111
+ return upperCase ? slug.toUpperCase() : slug;
112
+ }
113
+
114
+ export { DEFAULT_PLACEHOLDER, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, formatRelativeTime, formatTime, humanizeToken, localDateInput, slugify };
@@ -0,0 +1,54 @@
1
+ import { useCallback, useSyncExternalStore } from 'react';
2
+
3
+ // src/hooks/useMediaQuery.ts
4
+ function useMediaQuery(query, serverSnapshot = false) {
5
+ const subscribe = useCallback(
6
+ (onStoreChange) => {
7
+ const media = window.matchMedia(query);
8
+ media.addEventListener("change", onStoreChange);
9
+ return () => media.removeEventListener("change", onStoreChange);
10
+ },
11
+ [query]
12
+ );
13
+ return useSyncExternalStore(
14
+ subscribe,
15
+ () => window.matchMedia(query).matches,
16
+ () => serverSnapshot
17
+ );
18
+ }
19
+ var DEFAULT_CARD_BREAKPOINT = 1024;
20
+ function useCardViewport(breakpoint = DEFAULT_CARD_BREAKPOINT) {
21
+ return useMediaQuery(`(max-width: ${breakpoint - 1}px)`);
22
+ }
23
+ function useDesktopTableMode(breakpoint = DEFAULT_CARD_BREAKPOINT) {
24
+ return useMediaQuery(`(min-width: ${breakpoint}px)`) ? "table" : "cards";
25
+ }
26
+
27
+ // src/components/table/cardPagination.ts
28
+ var DEFAULT_CARD_PAGINATION_KEY_PREFIX = "common.pagination";
29
+ function buildCardPaginationLabels(t, prefix) {
30
+ const scope = prefix ?? DEFAULT_CARD_PAGINATION_KEY_PREFIX;
31
+ return {
32
+ loadMoreError: t(`${scope}.loadMoreError`),
33
+ retryLabel: t(`${scope}.retryLoadMore`),
34
+ itemsLoaded: (count) => t(`${scope}.itemsLoaded`, { count })
35
+ };
36
+ }
37
+ function createCardPagination(state, labels = {}, prefix) {
38
+ const mode = state.mode ?? (state.cardViewport ? "infinite" : "paged");
39
+ if (mode === "paged") return { mode: "paged" };
40
+ if (mode === "all") return { mode: "all" };
41
+ const resolved = typeof labels === "function" ? buildCardPaginationLabels(labels, prefix) : labels;
42
+ return {
43
+ mode: "infinite",
44
+ hasNextPage: state.hasNextPage,
45
+ loadingMore: state.loadingMore,
46
+ loadMoreError: state.loadMoreError ? resolved.loadMoreError : void 0,
47
+ onLoadMore: state.onLoadMore,
48
+ retryLabel: resolved.retryLabel,
49
+ loadMoreErrorLabel: resolved.loadMoreError,
50
+ itemsLoadedLabel: resolved.itemsLoaded
51
+ };
52
+ }
53
+
54
+ export { DEFAULT_CARD_BREAKPOINT, DEFAULT_CARD_PAGINATION_KEY_PREFIX, buildCardPaginationLabels, createCardPagination, useCardViewport, useDesktopTableMode, useMediaQuery };
@@ -1,4 +1,5 @@
1
1
  import { useNajmPreferencesContext } from './chunk-USZUOJMK.mjs';
2
+ import { humanizeToken, DEFAULT_PLACEHOLDER, formatRelativeTime, formatTime, formatDateTime, formatDate, formatPercent, formatNumber, formatCurrency } from './chunk-JABLSOQN.mjs';
2
3
  import * as React from 'react';
3
4
  import { createContext, useContext, useMemo, useState, useCallback } from 'react';
4
5
  import { jsx } from 'react/jsx-runtime';
@@ -62,109 +63,6 @@ function NBrandingStateProvider({
62
63
  }
63
64
  function noop() {
64
65
  }
65
-
66
- // src/format/format.ts
67
- var DEFAULT_PLACEHOLDER = "\u2014";
68
- var numberFormats = /* @__PURE__ */ new Map();
69
- var dateFormats = /* @__PURE__ */ new Map();
70
- function numberFormat(locale, options) {
71
- const key = `${locale}|${options ? JSON.stringify(options) : ""}`;
72
- let format = numberFormats.get(key);
73
- if (!format) {
74
- format = new Intl.NumberFormat(locale, options);
75
- numberFormats.set(key, format);
76
- }
77
- return format;
78
- }
79
- function dateFormat(locale, options) {
80
- const key = `${locale}|${JSON.stringify(options)}`;
81
- let format = dateFormats.get(key);
82
- if (!format) {
83
- format = new Intl.DateTimeFormat(locale, options);
84
- dateFormats.set(key, format);
85
- }
86
- return format;
87
- }
88
- function minorUnitScale(locale, currency) {
89
- const digits = numberFormat(locale, { style: "currency", currency }).resolvedOptions().maximumFractionDigits ?? 2;
90
- return 10 ** digits;
91
- }
92
- function toDate(value) {
93
- const date = value instanceof Date ? value : new Date(value);
94
- return Number.isNaN(date.getTime()) ? null : date;
95
- }
96
- function isBlank(value) {
97
- return value === null || value === void 0 || value === "";
98
- }
99
- function formatCurrency(minorUnits, { locale, currency, placeholder = DEFAULT_PLACEHOLDER }) {
100
- if (!currency) {
101
- throw new Error(
102
- "formatCurrency requires a `currency`. Set it on NajmFormatProvider or pass it here."
103
- );
104
- }
105
- if (minorUnits === null || minorUnits === void 0) return placeholder;
106
- if (!Number.isSafeInteger(minorUnits)) return placeholder;
107
- return numberFormat(locale, {
108
- style: "currency",
109
- currency
110
- }).format(minorUnits / minorUnitScale(locale, currency));
111
- }
112
- function formatNumber(value, { locale, placeholder = DEFAULT_PLACEHOLDER }, options) {
113
- if (value === null || value === void 0 || !Number.isFinite(value)) {
114
- return placeholder;
115
- }
116
- return numberFormat(locale, options).format(value);
117
- }
118
- function formatPercent(value, config, fractionDigits = 0) {
119
- return formatNumber(value, config, {
120
- style: "percent",
121
- minimumFractionDigits: fractionDigits,
122
- maximumFractionDigits: fractionDigits
123
- });
124
- }
125
- function formatDate(value, {
126
- locale,
127
- timeZone,
128
- placeholder = DEFAULT_PLACEHOLDER
129
- }, options = { dateStyle: "medium" }) {
130
- if (isBlank(value)) return placeholder;
131
- const date = toDate(value);
132
- if (!date) return placeholder;
133
- return dateFormat(locale, { ...options, timeZone }).format(date);
134
- }
135
- function formatDateTime(value, config, options = {
136
- dateStyle: "short",
137
- timeStyle: "short"
138
- }) {
139
- return formatDate(value, config, options);
140
- }
141
- function formatTime(value, config) {
142
- return formatDate(value, config, { timeStyle: "short" });
143
- }
144
- var RELATIVE_UNITS = [
145
- ["year", 31536e6],
146
- ["month", 2592e6],
147
- ["day", 864e5],
148
- ["hour", 36e5],
149
- ["minute", 6e4],
150
- ["second", 1e3]
151
- ];
152
- function formatRelativeTime(value, { locale, placeholder = DEFAULT_PLACEHOLDER }, now = Date.now()) {
153
- if (isBlank(value)) return placeholder;
154
- const date = toDate(value);
155
- if (!date) return placeholder;
156
- const elapsed = date.getTime() - (now instanceof Date ? now.getTime() : now);
157
- const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
158
- for (const [unit, ms] of RELATIVE_UNITS) {
159
- if (Math.abs(elapsed) >= ms) {
160
- return format.format(Math.trunc(elapsed / ms), unit);
161
- }
162
- }
163
- return format.format(0, "second");
164
- }
165
- function humanizeToken(value) {
166
- return value.trim().replace(/[_-]+/g, " ").replace(/\s+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
167
- }
168
66
  var NajmFormatContext = React.createContext(
169
67
  null
170
68
  );
@@ -215,4 +113,4 @@ function useNajmFormat() {
215
113
  return value;
216
114
  }
217
115
 
218
- export { DEFAULT_PLACEHOLDER, NBrandingProvider, NBrandingStateProvider, NajmFormatProvider, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, formatRelativeTime, formatTime, humanizeToken, normalizeBranding, useNBranding, useNBrandingEditor, useNajmFormat, useNajmFormatContext };
116
+ export { NBrandingProvider, NBrandingStateProvider, NajmFormatProvider, normalizeBranding, useNBranding, useNBrandingEditor, useNajmFormat, useNajmFormatContext };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Locale-aware value formatting.
3
+ *
4
+ * Pure functions with no React and no DOM: every input arrives through
5
+ * `NajmFormatConfig`. `NajmFormatProvider` binds them to the live locale and
6
+ * time zone, and is what an application normally uses — these are exported for
7
+ * the server, where there is no context to read.
8
+ */
9
+ /** Rendered in place of a value that is absent or not formattable. */
10
+ declare const DEFAULT_PLACEHOLDER = "\u2014";
11
+ interface NajmFormatConfig {
12
+ /** BCP 47 tag handed to `Intl`, e.g. `"fr-MA"`. */
13
+ locale: string;
14
+ /**
15
+ * IANA zone every date is rendered in. Omitted means the host zone, which is
16
+ * the runtime's guess rather than the user's preference — supply it.
17
+ */
18
+ timeZone?: string;
19
+ /** ISO 4217 code for `formatCurrency`, e.g. `"MAD"`. */
20
+ currency?: string;
21
+ /** Defaults to an em dash. */
22
+ placeholder?: string;
23
+ }
24
+ /**
25
+ * Formats an integer count of minor units — cents, centimes — as currency.
26
+ *
27
+ * Minor units are the input because money that survives a round trip is an
28
+ * integer; a major-unit float cannot represent 0.1 exactly and has no business
29
+ * in a ledger. A non-integer input is refused rather than rounded, since at
30
+ * this layer it means the caller has already lost precision upstream.
31
+ */
32
+ declare function formatCurrency(minorUnits: number | null | undefined, { locale, currency, placeholder }: NajmFormatConfig): string;
33
+ declare function formatNumber(value: number | null | undefined, { locale, placeholder }: NajmFormatConfig, options?: Intl.NumberFormatOptions): string;
34
+ declare function formatPercent(value: number | null | undefined, config: NajmFormatConfig, fractionDigits?: number): string;
35
+ declare function formatDate(value: Date | number | string | null | undefined, { locale, timeZone, placeholder, }: NajmFormatConfig, options?: Intl.DateTimeFormatOptions): string;
36
+ declare function formatDateTime(value: Date | number | string | null | undefined, config: NajmFormatConfig, options?: Intl.DateTimeFormatOptions): string;
37
+ declare function formatTime(value: Date | number | string | null | undefined, config: NajmFormatConfig): string;
38
+ /**
39
+ * Renders a timestamp as distance from now — "3 days ago", "in 2 hours".
40
+ *
41
+ * Time zone is not a parameter: an elapsed duration is the same in every zone.
42
+ */
43
+ declare function formatRelativeTime(value: Date | number | string | null | undefined, { locale, placeholder }: NajmFormatConfig, now?: Date | number): string;
44
+ /**
45
+ * Turns a machine token into readable text: `out_for_delivery` → `Out For
46
+ * Delivery`.
47
+ *
48
+ * The fallback for a value with no catalog entry, not a substitute for one.
49
+ * Anything user-visible and known in advance belongs in the translations.
50
+ */
51
+ declare function humanizeToken(value: string): string;
52
+ /**
53
+ * Today as `YYYY-MM-DD` in the host's zone, for `<input type="date">`.
54
+ *
55
+ * Not `toISOString().slice(0, 10)`, which is the same line everyone writes and
56
+ * is wrong west of UTC for the first hours of the day: it converts to UTC
57
+ * first, so a date picker in Casablanca opens on tomorrow. The parts are read
58
+ * off the local calendar instead.
59
+ *
60
+ * Deliberately host-zone rather than preference-zone. This produces the value a
61
+ * date *input* round-trips, and that control is bound to the machine the user
62
+ * is typing on; `formatDate` is what renders a date for reading.
63
+ */
64
+ declare function localDateInput(date?: Date): string;
65
+ interface SlugifyOptions {
66
+ /** Uppercases the result, for an identifier conventionally read in caps. */
67
+ upperCase?: boolean;
68
+ /** Longest slug produced, before the case change. Defaults to 160. */
69
+ maxLength?: number;
70
+ }
71
+ /**
72
+ * Turns a label into a URL- and identifier-safe token: `Épicerie Fine` →
73
+ * `epicerie-fine`.
74
+ *
75
+ * Accents are folded rather than dropped. Stripping them outright is the usual
76
+ * one-liner and it silently eats letters — `Épicerie` becomes `picerie` — which
77
+ * is a poor slug in exactly the languages most likely to need one.
78
+ *
79
+ * A value with nothing to transliterate — Arabic or CJK, where folding has no
80
+ * ASCII to fall back to — yields a random token rather than an empty string.
81
+ * Empty is never a usable slug, and returning one pushes the failure to a
82
+ * uniqueness constraint far from the cause.
83
+ */
84
+ declare function slugify(value: string, { upperCase, maxLength }?: SlugifyOptions): string;
85
+
86
+ export { DEFAULT_PLACEHOLDER, type NajmFormatConfig, type SlugifyOptions, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, formatRelativeTime, formatTime, humanizeToken, localDateInput, slugify };
@@ -0,0 +1,2 @@
1
+ import './chunk-F5KXJCCJ.mjs';
2
+ export { DEFAULT_PLACEHOLDER, formatCurrency, formatDate, formatDateTime, formatNumber, formatPercent, formatRelativeTime, formatTime, humanizeToken, localDateInput, slugify } from './chunk-JABLSOQN.mjs';