@nxgt/material-core
v0.24.0
Published
The framework-free core of @nxgt/material and @nxgt/material-vue: the PDF document model and its react-pdf renderer, print formatters, PDF file helpers, the PDF viewer's pure modules, the currency digits, the file-size formatter, the field-blur layers, th
Maintainers
Readme
@nxgt/material-core
The framework-free core that @nxgt/material
(React) and @nxgt/material-vue
(Vue) share, as one copy: the PDF document model and its react-pdf renderer,
the print formatters, the PDF file helpers, the PDF viewer's framework-free
half (page geometry, scroll spy, text search), the currency field's decimals,
the file-size formatter, the slider's tick marks, the emoji list's groups,
the date range presets, local calendar-day arithmetic (/day-math), a debounce, accent-insensitive text matching and a race-free async search (/debounce, /text-match, /async-search), the room an open list has above a Drawer's footer (/drawer-room), the file preview's kind detection, the fields' blur
rule, a field's first reachable control (/focus-control) and its aria-describedby
(/described-by), where the focus goes in a repeatable row group
(/repeatable-group), the page behind a modal layer made inert
(/inert-others), Escape pressed while an overlay closes, in a field whose
list is open or in a control marked data-keeps-escape (/escape-forward), the Toaster's guard against a tap's emulated hover
(/touch-hover), the focus a toast's action hands back (/toast-focus), a
table row's activation, the zod error map's issue messages
(/zod-issue-messages), a zod error map that words an issue by its code
(/code-errors), the page's Content Security Policy nonce (/csp-nonce), the server and build side of the CSP (/csp), an
object built on its first use (/lazy-object), one submit in flight
(/submit-guard), the two-step verification helpers (/two-factor: a TOTP
secret grouped to be typed, the recovery codes' text file, a text download),
the empty and error states' kinds, copy keys, error details and illustrations (/states), printing one element of the page (/print-only), the list filter's headless
core (/filter: its model, URL codec, state machine and wording), a server
table's state in the URL (/table-url), the engine behind the background presets (/backgrounds), the geometry and motion of the progress indicators (/progress), Material 3 Expressive's shapes as data with SVG and CSS paths and morphs (/shapes), the media player's cues, chapters, transcript, keyboard map, queue, resume, gestures, peaks, quality menu and corner snapping (/media), the Vite plugin that
bundles pdfkit's standard fonts and a build.assetsInlineLimit that keeps
fonts out of data: URIs, and the Node-only resolution of those fonts that
the Next.js helper and the Nuxt module run at build time.
You usually do not install it yourself. Both libraries depend on it and
re-export what they always exported, at the same subpaths:
@nxgt/material/print, @nxgt/material-vue/pdf, /lib and /vite read
from here, and so do their PdfReader, CurrencyField, form fields (their
focus, blur, aria-describedby and zod messages), file list and upload
field (formatFileSize), modal layers (the inert page behind, Escape while
closing, Escape in a field whose list is open or in a control marked
data-keeps-escape), Toaster, DataTable rows, @nxgt/material/next and
@nxgt/material-vue/nuxt. Import it directly when you want the model or a
formatter without a UI library, or one of these helpers in a component of
your own.
import { heading, pdfDocument, text } from '@nxgt/material-core/pdf/model';
import { formatAmount } from '@nxgt/material-core/print-format';
const invoice = pdfDocument({
pages: [{ body: [heading('Invoice'), text(formatAmount(12.5, 'EUR'))] }],
});0.x. The subpaths below are what the two libraries build on; they may still move between minor versions until 1.0.
Install
bun add @nxgt/material-coreIts types resolve under "moduleResolution": "bundler" and "nodenext". The
built files are plain ES modules and also load in Node without a bundler.
| Optional peer | Needed by |
| --- | --- |
| react, @react-pdf/renderer@^4.9 | @nxgt/material-core/pdf/render and /pdf — rendering a document |
| vite@^5.2 \|\| ^6 \|\| ^7 \|\| ^8 | @nxgt/material-core/vite — its declarations name Vite's Plugin type (types only); the plugin itself runs in your Vite config |
| @fontsource-variable/inter@>=5 | @nxgt/material-core/vite — nxgtMaterial() loads Inter only when it is installed, else warns and keeps the system font; fonts: { sans: false } silences the warning |
Every other subpath imports none of them, and is safe to import on a server
with no DOM. Two subpaths are Node only: /pdfkit-fonts imports node:module
and node:path, and /vite imports node:fs/promises, node:fs and node:path
(the styles check reads stylesheets from disk; pdf: { bundle: true } reads installed package.json files), both for a framework's or a
Vite config at build time — never import them from code a browser bundle
can reach. No other subpath imports a node: built-in.
Subpaths
| Subpath | What it holds |
| --- | --- |
| @nxgt/material-core | The model, the formatters, the file helpers, http, file-loader — every document and print subpath that imports no framework. The viewer, currency and blur helpers are on their own subpaths only |
| /pdf/model | The document model: pdfDocument, page, heading, text, table, dataTable, themes and their types (PdfPageNumberScope, PdfTableStyle, PdfTableRow, PdfTableCell, …) — plain, JSON-serialisable data |
| /pdf/render | The react-pdf renderer: renderPdfElement, createPdfContext, renderPdfPage, the node renderers |
| /pdf | /pdf/model and /pdf/render together |
| /pdf/http | contentDisposition, pdfBytesResponse, toWebStream — serving a rendered PDF |
| /pdf/file-loader | createPdfFileLoader, IDLE_PDF_FILE — the state behind usePdfFile / usePdf |
| /print-format | formatAmount, formatDate, createFormatters — Intl formatting |
| /print-fraction-digits | fractionDigitsOption, MAX_FRACTION_DIGITS |
| /print-value | PrintValue, formatPrintValue, isEmptyPrintValue |
| /print-table | definePrintColumns, resolvePrintTable, resolvePrintFields, toPrintValue |
| /pdf-source | PdfSource and its helpers (toPdfjsSource, pdfSourceKey, …) |
| /pdf-file-name | resolvePdfFileName, DEFAULT_PDF_FILE_NAME |
| /pdf-download | downloadPdfFile, printPdfFile — in a browser |
| /pdf-viewer | The PDF viewer's pure half: page geometry and fit (readerFitScale, placeholderSize, rotatedSize, PdfPageSize, FitMode), page sizes (readPageSizes, createPageSizesLoader), the scroll spy (createScrollSpy and its ScrollSpyOptions, pageInView, revealHit, scrollIntoArea), the opening page's pin (pinToPage, whose optional onRelease runs once when it lets go, and pinHolds, whether the view is still where it puts the page), text search (pageTextFromItems, findTextMatches, searchDocument), escapeHtml, mapPdfOutline, link names (linkAnnotationLabel, labelLinkAnchors, resolveDestinationPage, with PdfLinkLabels, PdfLinkAnnotation, PdfDestinationDocument) |
| /pdf-reader | Experimental, used by PdfReader internally: the PDF reader's engine, with pdf.js handed in by shape (PdfjsLike, PdfjsPasswordCallback). Setup and loading: configurePdfReader (PdfReaderConfig), readPdfReaderConfig, openPdfDocument (OpenPdfOptions, PdfjsSourceParams: { url, httpHeaders?, withCredentials? } or { data }; PdfDocumentHandle, typed after the pdf.js handed in), classifyPdfError (PdfLoadError), PDF_PASSWORD_INCORRECT. Rendering: planRender (RenderPlanInput, RenderPlan, MAX_CANVAS_PIXELS), createPageCache (PageCache, PageCacheKey; counted in entries or by a size function), renderPageToCanvas (PageRenderResult, CanvasUnavailableError), the page layouts layoutSpreads (SpreadMode, SpreadOptions; one page a row, or two) with pageAtOffset, anchorAt, offsetOf (PageLayout, PageRow, PageAnchor) and the row helpers rowStarts and mountedPages. Layers: createPdfLinkService (PdfLinkServiceOptions, PdfLinkService, PdfLinkTarget, PdfLinkDocument), PDF_LINK_REL, applyPageHighlight (PdfPageSearch, PdfTextSpans) |
| /currency-digits | currencyFractionDigits — the decimals CurrencyField shows |
| /field-blur-layers | layerOf, openerOf, FieldLayers — whose overlay a layer of the page is, for a field's blur |
| /focus-control | firstControl, focusHandle, FocusHandle — the first control of a field a user can reach, and a stand-in element that focuses it for a form library |
| /repeatable-group | focusAfterRemove, focusAfterMove, focusRowTarget, rowMoveTarget, canAddRow, canRemoveRow, RowFocusTarget, RowMoveDirection, RowAnnouncement, ROW_SLOT, ROW_FIELDS_SLOT, ADD_ATTRIBUTE — where the focus goes after a row of a RepeatableGroup is added, removed or moved, and what its live region says |
| /escape-forward | forwardEscape, keepListEscape, isKeptEscape — Escape pressed while an overlay closes, pressed again for the overlay below once it has gone; Escape pressed in a field whose list is open, or in a control marked data-keeps-escape, kept from closing the overlay around it, and told from an Escape the page prevented |
| /described-by | describedBy — an aria-describedby value from the caller's ids and a field's help and error ids |
| /inert-others | inertOthers, INERT_MARKER — the rest of the page inert behind a modal layer, live regions kept, nested layers counted |
| /backgrounds | buildBackgroundScene, buildGrain, assertSafeScene, el, SCENE_ATTRIBUTES, SCENE_TAGS, svgIdPrefix, observeBackgroundActivity, mulberry32, pick, round1, valueNoise2, scatter, motifShape, place, BACKGROUND_PRESETS, SHIPPED_BACKGROUND_PRESETS, resolveBackgroundPreset, resolveShippedPreset, presetLayers, SCENE_LAYER, BACKGROUND_TONES, BACKGROUND_INTENSITIES, MOTIF_SHAPES, MOTIF_DEFAULTS, and the types BackgroundPreset, ShippedBackgroundPreset, BackgroundTone, BackgroundIntensity, MotifDensity, MotifShape, MotifSize, OpacitySpread, ShapeFill, SceneBuilder, SceneOptions, SceneTag, ScatterOptions, ScatterPoint, PlaceOptions, MotifOptions, SceneNode, PresetInfo — the engine behind the libraries' background presets: a seeded generator, scattering, motif shapes, a plain SVG scene format with its safety check, ids for url(#…), and the observer that pauses an animation |
| /progress | linearFrame, circularFrame, circularRadius, linePath, arcPath, ringWaves, n2, linearIndeterminate, circularIndeterminate, LINEAR_CYCLE, CIRCULAR_CYCLE, smilTimes, stepSpring, springAtRest, waveTarget, cubicBezier, EASING, and the types LinearSpec, LinearState, LinearFrame, Dot, CircularSpec, CircularState, CircularFrame, Spring, Bezier, WaveOptions, RingOptions — the geometry and motion behind the libraries' Progress and CircularProgress: flat and wavy strokes as SVG path data, whole frames (track, gap, active segments, stop dot), Material 3's indeterminate keyframes and the spring a value moves on |
| /shapes | buildShape, roundedPolygon, shapeOutline, SHAPES, SHAPE_NAMES, toSvgPath, toCssShape, formatNumber, opticalFit, circleFit, area, bounds, normalize, morph, morphAt, morphSequence, align, resample, MORPH_CUBICS, the 36 shapes by name (circle, cookie9, softBurst, squircle…), and the types ShapeSpec, SpecPoint, ShapeName, ShapeFit, Rounding, Cubic, Outline, Point, Morph, PathOptions — Material 3 Expressive's 35 shapes and a squircle as data: outlines of cubics, an optical fit, an SVG path or a CSS shape(), and morphs between any two; deterministic and DOM-free |
| /media | activeCueIndexes, activeCues, announcementFor, chapterAt, chapterMarkers, classifyTap, clearResume, commandForKey, cornerPosition, cueIndexAt, cueStartIndex, downsamplePeaks, flushTap, FOLLOW_QUIET_MS, formatClock, INITIAL_TAP_STATE, isResumable, levelLabel, MEDIA_RESUME_DEFAULTS, MEDIA_SHORTCUTS, nearestCorner, nextRepeatMode, parseThumbnails, parseTimestamp, parseWebVtt, peakIndexAt, playOrder, positionOf, qualityItems, readResume, resumeKey, searchTranscript, seededRandom, shouldFollow, shuffleOrder, spokenTimeParts, stepPosition, thumbnailAt, toChapters, transcriptLines, writeResume, and the types MediaAnnouncement, MediaAnnouncementEvent, MediaChapter, MediaCommand, MediaCorner, MediaCue, MediaKeyCommand, MediaKeyEventLike, MediaLevel, MediaQualityItem, MediaQualityOptions, MediaRepeatMode, MediaResumeOptions, MediaResumeStorage, MediaShortcut, MediaShortcutGroup, MediaThumbnail, SnapInsets, SnapSubject, TapAction, TapOptions, TapPoint, TapState, TranscriptLine — the media player's framework-free half: a forgiving WebVTT reader, the cue on at a time, chapters, thumbnail sprites, the synced transcript, time labels, the keyboard map, the live-region messages, the queue with seeded shuffle and repeat, storage-agnostic resume, tap gestures, waveform peaks, quality menu items and corner snapping |
| /touch-hover | ignoreTouchHover, TOASTER_PHONE_QUERY, TOASTER_PHONE_POSITION — keeps a tap from hovering an element, so a toast a finger touched does not stay paused; where the Toaster puts toasts on a phone |
| /toast-focus | focusOrigin, handFocusBack, trackToasterOrigin, TOASTER_SELECTOR — where the focus was before it entered a toast, and handing it back there before the toast's action runs, so a dialog the action opens keeps the focus |
| /zod-issue-messages | sizeMessage, invalidFormatMessage, IssueText — the translated text of a zod too_big / too_small and invalid_format issue |
| /row-activation | isRowActivation, followRowLink, ROW_CONTROL_SELECTOR, ROW_LINK_SELECTOR, RowClick — whether a click on a table row follows its link, and following it |
| /format-file-size | formatFileSize — a byte count as '1.5 KB', or in a locale's unit |
| /slider-ticks | tickValues — where a slider's tick marks sit |
| /emoji-groups | groupKey, groupEmojis, searchEmojis, groupName, GROUP_NAMES and the type EmojiItem — how the emoji list groups, searches and names its emojis |
| /date-range | DATE_RANGE_PRESET_IDS, presetRange, resolvePresetRange, isSameDayRange, isRangeDisabled, withTimeOf, endOfDayTime, clampRangeEnd, keepRangeTimes and the types DayRange, PartialDayRange, DateRangePreset, DateRangePresetId, DateRangePresetOption, DateRangeRules — DateRangeField's presets and times |
| /csp-nonce | getCspNonce, setCspNonce, claimCspNonce(nonce, depth), createNoncedStyle, and getNonce / setNonce (the get-nonce API) — the page's Content Security Policy nonce, for a <style> created at runtime; browser only |
| /csp | createCspNonce, cspPolicy, cspHash, applyCspNonce, CSP_NONCE_PLACEHOLDER, ZOD_JITLESS_SCRIPT, zodJitlessTag and the types CspPolicyOptions, CspDirectives — the Content Security Policy for a server or a build: a nonce per request, the recommended header, hashes of inline code, zod's jitless switch; no DOM |
| /lazy-object | lazyObject, buildOnce — a value built on its first use rather than when its module loads: the libraries' zod schemas |
| /debounce | debounce, the type Debounced — a function called once calls pause, with cancel and flush (SearchField's debounceMs) |
| /initials | initials — an avatar's initials from a name, 'Ada Lovelace' → 'AL' (AccountMenu) |
| /text-match | foldText, matchesText, matchRange, matchRanges, type MatchRange — case- and accent-insensitive matching ('e' finds "École"), lowercased for a locale, and where it matched (the first match, or every one) |
| /async-search | createAsyncSearch, searchAnnouncement, DEFAULT_SEARCH_DEBOUNCE_MS, DEFAULT_MIN_QUERY_LENGTH and the types AsyncSearch, AsyncSearchConfig, AsyncSearchState, AsyncSearchStatus, SearchFn, SearchContext, SearchAnnouncement — Autocomplete's server search: debounced, each run aborting the last, a stale response dropped |
| /day-math | daysAgo, dayBucket, isSameDay, startOfDay, localIsoDay, toDate, RECENT_DAYS, the types DayInput, DayBucket — local calendar-day arithmetic (DST-proof, a rolling week) behind DaySeparator and DayGroupedList |
| /drawer-room | drawerRoom(element, top) — the height in px an open floating list may take under top inside a Drawer without covering its footer (at least 80, an 8px gap kept); null outside a Drawer (Autocomplete) |
| /submit-guard | createSubmitGuard, busySettler and the types SubmitGuard, SubmitOnceOptions — one submit in flight until its request ends, behind both libraries' useSubmitOnce |
| /code-errors | createCodeErrorMap, defaultCodeOf, isEmptyRequired and the types CodeErrorMap, CodeErrorMapOptions, CodeIssue — a zod 4 error map that words an issue by the code it carries (a refinement's params.code), behind both libraries' useCodeResolver |
| /two-factor | groupSecret, recoveryCodesText, downloadText and the types RecoveryCodesTextOptions, DownloadTextOptions — a TOTP secret in groups of four, the recovery codes as a text file, and saving a text file, behind both libraries' EnrolmentSecret and RecoveryCodes |
| /print-only | printOnly, PRINT_ATTRIBUTE, PRINT_TARGET_ATTRIBUTE — printing one element of the page with no inline style, behind RecoveryCodes' Print button; re-exported by both libraries' /lib |
| /file-preview | fileKind, describeFile, safePreviewUrl, readTextPreview, FilePreviewHttpError, FilePreviewUnsafeUrlError, TEXT_PREVIEW_MAX_BYTES, fileExtension, fileNameFromUrl, dataUrlType, kindOfMimeType, normaliseMimeType, isTextFileKind, TEXT_FILE_KINDS and the types FileKind, FileKindInput, DescribedFile, FilePreviewSource, TextPreview — FilePreview's kind detection, URL allow-list and text reader |
| /table-url | createSorting, readCursor, nextPage, previousPage, firstPage, serverPagination, queryKey, isNewQuery, pagingNames, FIRST_PAGE, isFiltered, withoutFilters, tableFilters, paramsFromQuery, queryFromParams and the types RouterQuery, PageCursor, PagingNames, CursorConnection, ServerPagination, ServerPaginationOptions, SortingOptions, TableSort, TableSorting, TableFilterOptions, TableFilterBinding — a server table's state in the URL: the sort, forward cursor paging, the footer's page, the query key, and helpers over /filter's codec; queryFromParams keeps every param as an own key, a __proto__ one included |
| /filter | CLOSED_ADD_FLOW, DEFAULT_DATE_PRESETS, DEFAULT_FILTER_SHORTCUTS, EMPTY_FILTER_QUERY, FACET_FOLDED_SIZE, FACET_SHOW_MORE_AFTER, FILTER_ANSWER_GRACE_MS, FILTER_CHIP_ATTRIBUTE, FILTER_DATE_PRESET_IDS, FILTER_FOCUS_SELECTORS, FILTER_LAYER_SELECTOR, FILTER_MAX_VALUE_LENGTH, FILTER_MAX_VALUES, FILTER_OPERATOR_IDS, FILTER_PARAM, FILTER_QUICK_ATTRIBUTE, FILTER_RESULTS_WORDS, FILTER_RESULTS_WORDS_INLINE, FILTER_SECTION_ATTRIBUTE, FILTER_SHEET_HISTORY_KEY, FILTER_SHOW_RESULTS_WORDS, NO_FILTER_ADDED, NO_FILTER_LABELS, PREVIEW_TYPING_DELAY_MS, activeConditions, addableFields, addMenuFields, addedApplied, addedField, addedMessage, addFilterDays, addMenuItems, announcementKey, ariaKeyShortcuts, autocompleteOptions, bandQuickFields, barAnnouncement, booleanChoices, booleanOff, booleanValues, builtInDateRange, checkNumberDraft, chipConditions, chipParts, chipText, clearedMessage, clearQuery, commitNumberDraft, conditionOf, conditionPopoverClose, createFilterReducer, createPreviewRunner, customRenderer, dateOfFilterDay, dateOfFilterTime, datePresetItems, datePresetLabel, datePresetMessage, datePresetRange, defineFilterModel, draftCommit, editableFields, editedQuery, editingAfter, editorHint, effectiveConditions, expireFilterAnswer, facetCounts, facetFoldMessage, facetKind, facetOptions, facetsDraftEvent, facetsStage, fallbackText, fieldDatePreset, fieldDatePresetRange, fieldDatePresets, filterAnswerDue, filterAnswerGraced, filterAnswerGraceKey, filterCountPlural, filterCountText, filterDayOf, filterField, filterFocusElement, filterFormatters, filterMsg, filterOperator, filterQueryKey, filterReducer, filterSearchString, filterShortcutKeys, filterTimeOf, filterToday, filterType, focusAfterAdd, focusAfterClearAll, focusAfterEdit, focusAfterRemove, focusAfterSheet, focusAfterSheetClear, focusFilterTarget, focusSheetOpen, holdBackEntry, initialFilterState, isBooleanField, isBuiltInDatePreset, isComplete, isDatePresetKey, isDraftChanged, isFilterOperatorId, isFilterTypeId, isLayerOpen, isLockedField, isNarrowing, isQuickOn, isQuickToggle, isRangeOperator, isSameQuery, isUnreadableValue, isWideningField, labeledFormatters, labeledQuickText, matchCondition, matchQuery, missingLabels, nextAdded, numberDraft, numberDraftRestarts, numberSentKey, numberValuesKey, numberValuesOfKey, paramsKey, parseFilterQuery, pendingChipParts, plainFilterFormatters, previewTyped, quickFields, quickListFields, quickText, quickToggleEvent, removeLabel, resolveDateCondition, resolveDateConditions, resolveFilterDates, resultsMessage, schoolYear, sectionActiveCount, sectionCondition, sectionEvent, sectionOpen, sectionOperator, serializeFilterQuery, sheetApplyMessage, sheetCanClear, sheetSections, sheetTriggerCount, shortcutFor, shouldAnnounce, shownConditions, sliderBounds, sliderThumbs, titleOperator, truncateValues, unsearchableFields, unsearchableWarning, valueCounts, watchFilterAnswer, withCondition, withCustomEditors, withLabels, withoutCondition, withoutLocked, withSearch and its types (the section's "Types" line) — the list filter's headless core: the model, the URL codec a server loader runs, the state machine and add flow, client-mode matching, chip wording, focus, shortcuts, the phone sheet and the facets (see below) |
| /states | EMPTY_STATE_KINDS, ERROR_STATE_KINDS, resolveEmptyStateKind, isErrorKind, stateCopy, STATE_ACTION_KEYS, describeError, STATE_ILLUSTRATIONS, STATE_ACCENT, STATE_ICON and the types EmptyStateKind, ErrorStateKind, StateKind, LegacyEmptyStateVariant, StateFamily, StateCopy, ErrorDetail, StateIllustration, IllustrationShape, IllustrationTone, StateAccent, StateIconName — the kinds, copy keys, error details and illustrations behind EmptyState and ErrorState |
| /vite | nxgtMaterial(), pdfkitStandardFonts(), cspAssetsInlineLimit, CSP_NONCE_PLACEHOLDER, ZOD_JITLESS_SCRIPT and the types NxgtMaterialOptions, NxgtMaterialCspOptions, NxgtMaterialLibrary — see below |
| /pdfkit-fonts | Node only. pdfkitStandardFontDirs, pdfkitStandardFontFiles, PDFKIT_STANDARD_FONTS, the types PdfkitStandardFont and PdfkitFontsWarn — where the app's pdfkit keeps its standard fonts, for a server tracer |
| /data-grid | Headless, the logic of the editable DataGrid of @nxgt/material; nothing renders here. The model (buildGridModel, cellAccess, cellVersion and isNewCellVersion), navigation (navigate, gridCommandFor, whose GridKeyCommand has the members resizeColumn (Alt+ArrowLeft/Right on a header) and selectRow (Shift+Space)), row selection (rowHeaderIndex and GridModel.rowHeaderCol, the first column that is not a control column; GridColumnSpec.control marks a control column such as the selection column; the descriptors selectionCountMessage ("3 of 12 selected"), selectRowLabel and selectAllLabel), the range (extend, selectAll, rangeCells, …), the edit session (editReducer), validation (validateCell, serverErrors, refusalsByCell, Standard Schema), numberCodes, the changes per cell (NO_CHANGES, editCell, editCells for a bulk write, rebase, resolveConflict (and, for a lazy conflict whose resolve fetches their version only on "Keep mine", markConflictFetch and settleConflict), expireSaved, changeCounts over the changes or any entries, blocking), the save queue (IDLE_QUEUE, nextCall, startCall, applyAnswer, applyFailure, applyAbort, retry), undo and redo (EMPTY_HISTORY, record, undo, redo, and forgetEdits for the cells a refused save put back), copy and paste (parseTsv, serializeTsv, planPaste, planFill), the fill handle's series (fillTarget for where a drag ends, seriesValues for the numbers, dates and text a series continues, planSeriesFill and planBlockCopy for the all-or-nothing plan of a series fill and of Ctrl+D and Ctrl+R, seriesLanes (with GridSeriesLane and GridSeriesLanesInput) lane by lane, what a series fill produces before any parse or check, to tell a series from a copy, with the GridSeriesFn hook, the series input that a column's meta.series feeds, and the GridSeriesContext, GridSeriesInput, GridSeriesKind, GridSeriesMode, GridSeriesOut and GridSeriesPlanInput types), the totals (aggregate), the kept sort order (snapshotOrder, reconcileOrder, sortingChanged), the column layout (normalizeLayout, with columns locked to the start or the end through lockPosition, moveColumn, canMoveColumn (whether a column can move at all: not the row header, not a control column, not locked), headIndex (the row header's place among the layout columns, after the control columns), hideColumn, showColumn, clampWidth, sameLayout, the widths DEFAULT_MIN_WIDTH, DEFAULT_MAX_WIDTH and ROW_HEADER_MIN_WIDTH, the column…Message descriptors, the types GridColumnLayout, GridLayoutColumn, GridMoveOptions and GridWidthBounds), the conflict panel's text (conflictValues, conflictSummary, clearedText, unknownTheirsText, conflictFetchFailedText) and the live region's descriptors (savedMessage, cellStateText, the save bar's discardedMessage and blockedMessage, a refused save put back's revertedMessage and refusedRevertedMessage, a paste's and a fill's pasteProblemsMessage, pasteDroppedMessage, fillProblemsMessage, fillDroppedMessage and seriesFilledMessage, …), plus selectOptions, dateCell, isoDay, numberRuleMessages, hasPendingChanges, canUndo, canRedo and sameValue, with the types GridModel, GridColumnSpec, GridRowSpec, GridMsg, GridPos, GridRange, GridVersion, GridCode, GridChanges, GridSaveQueue, GridCall, GridSaveAnswer, GridRefusal, GridConflictAnswer, GridConflictFetched, GridSaveOutcome, GridHistory, GridStep, GridPasteInput, GridPastePlan, GridPasteProblem, GridAggregate and GridSort. A cell's version is 0 or undefined when it does not exist yet on the server. See below |
| /parse-number | parseNumberInput, decimalsTyped, clampNumber, stepNumber — a typed number read with , or . as its decimal separator (the Arabic ٫ and ٬ and a Unicode − too), its typed decimals, and the clamp and step behind NumberField |
Usage
The root entry: model, formatters and file helpers
@nxgt/material-core re-exports every document and print subpath that
imports no framework, so one import builds a document on a server with no
react installed:
import { createFormatters, dataTable, definePrintColumns, heading, pdfDocument, text } from '@nxgt/material-core';
type Line = { item: string; amount: number };
const { formatDate } = createFormatters({ locale: 'fr-FR', timeZone: 'Europe/Paris' });
const lines: Line[] = [{ item: 'Design', amount: 1200 }, { item: 'Hosting', amount: 90 }];
const invoice = pdfDocument({
language: 'fr',
pages: [{
body: [
heading('Facture 42'),
text(formatDate('2026-03-01')),
dataTable(definePrintColumns<Line>([
{ key: 'item', label: 'Item', flex: 3 },
{ key: 'amount', label: 'Amount', format: 'amount', total: 'sum' },
]), lines, { currency: 'EUR' }),
],
}],
});The document is plain JSON: build it where the data is, render it where
react and @react-pdf/renderer are installed.
Rendering a document: /pdf/render
import { renderToBuffer } from '@react-pdf/renderer';
import { pdfDocument, text } from '@nxgt/material-core/pdf/model';
import { renderPdfElement } from '@nxgt/material-core/pdf/render';
const bytes = await renderToBuffer(
renderPdfElement(pdfDocument({ pages: [{ body: [text('Hello')] }] })),
);@nxgt/material/print and @nxgt/material-vue/pdf wrap this in
renderPdfBuffer, renderPdfStream and friends, with the library's
translations; use theirs in an app built on either.
Two options of the model worth knowing: a group's own page numbers and a table's row styles.
import { page, pdfDocument, table } from '@nxgt/material-core/pdf/model';
// One entry of `pages` per person: "Page 1 of 2" restarts at each one.
const classFile = pdfDocument({
pages: students.map((s) =>
page({
pageNumbers: { scope: 'group' },
body: [
table({
columns: [{ label: 'Subject' }, { label: 'Mark', align: 'right' }],
rows: [
['Maths', 14],
// A bold, tinted row with a rule above it; a cell's style wins over its row's.
{ cells: ['Average', 14], style: { fontWeight: 'bold', backgroundColor: 'rowAlt', borderTop: true } },
],
}),
],
}),
),
});scope: 'group' is also on the pageNumber node, for a footer. See the
printing guide
for every option.
Model and renderer in one import: /pdf
/pdf is /pdf/model and /pdf/render together, for code that builds and
renders in the same place. It needs the optional peers, like /pdf/render.
import { pdf } from '@react-pdf/renderer';
import { heading, pdfDocument, renderPdfElement } from '@nxgt/material-core/pdf';
const blob = await pdf(
renderPdfElement(pdfDocument({ pages: [{ body: [heading('Report')] }] })),
).toBlob();Serving a rendered PDF: /pdf/http
pdfBytesResponse wraps bytes in a Web Response with Content-Type,
Content-Length and a Content-Disposition every client reads;
toWebStream turns react-pdf's Node stream into a Web ReadableStream.
Both run in any handler that returns a Response (Hono, a React Router
loader, Bun.serve, …).
import { renderToBuffer, renderToStream } from '@react-pdf/renderer';
import { pdfDocument, text } from '@nxgt/material-core/pdf/model';
import { renderPdfElement } from '@nxgt/material-core/pdf/render';
import { contentDisposition, pdfBytesResponse, toWebStream } from '@nxgt/material-core/pdf/http';
const doc = pdfDocument({ pages: [{ body: [text('Invoice 42')] }] });
// Buffered: the whole file, with its length.
export async function download(): Promise<Response> {
// A copy: a Node Buffer's `buffer` may be shared, a Response's may not.
const bytes = new Uint8Array(await renderToBuffer(renderPdfElement(doc)));
return pdfBytesResponse(bytes, {
fileName: 'INV-42', // `.pdf` is added: INV-42.pdf
disposition: 'inline', // 'attachment' by default
headers: { 'Cache-Control': 'no-store' },
});
}
// Streamed: no length, the headers are yours.
export async function stream(): Promise<Response> {
const source = await renderToStream(renderPdfElement(doc));
return new Response(toWebStream(source), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': contentDisposition('inline', 'INV-42.pdf'),
},
});
}Rendering to an object URL: /pdf/file-loader
createPdfFileLoader is the state machine behind usePdfFile (React) and
usePdf (Vue), without the framework: it renders with the function you give
it, keeps one object URL alive at a time, revokes the one it replaces, and
drops a render that a newer load overtook. Use it to write the same hook in
another framework; it does not serve a PDF.
import { pdf } from '@react-pdf/renderer';
import { createPdfFileLoader, IDLE_PDF_FILE, type PdfFileSnapshot } from '@nxgt/material-core/pdf/file-loader';
import { type PdfDocument, pdfDocument, text } from '@nxgt/material-core/pdf/model';
import { renderPdfElement } from '@nxgt/material-core/pdf/render';
let file: PdfFileSnapshot = IDLE_PDF_FILE; // { blob, url, loading, error }
const loader = createPdfFileLoader<PdfDocument>(
(next) => { file = next; }, // called on every change
(doc) => pdf(renderPdfElement(doc)).toBlob(),
);
await loader.load(pdfDocument({ pages: [{ body: [text('Preview')] }] }));
console.log(file.url); // blob:… — for an <iframe>, a reader or a link
await loader.load(null); // back to IDLE_PDF_FILE, URL revoked
loader.dispose(); // on unmount: revokes the URL, ignores renders in flightFormatting values: /print-format, /print-value, /print-table, /print-fraction-digits
Plain Intl formatting, no framework: amounts, dates, typed print values and
tables resolved from column definitions.
import { createFormatters, formatAmount } from '@nxgt/material-core/print-format';
import { fractionDigitsOption } from '@nxgt/material-core/print-fraction-digits';
import { definePrintColumns, resolvePrintTable } from '@nxgt/material-core/print-table';
import { formatPrintValue } from '@nxgt/material-core/print-value';
formatAmount(1234.5, 'EUR', { locale: 'fr-FR' }); // '1 234,50 €'
formatAmount(1234.5, 'JPY', { locale: 'en-US' }); // '¥1,235'
fractionDigitsOption(Number.NaN); // undefined: the currency's own decimals apply
const { formatDate } = createFormatters({ locale: 'en-US', timeZone: 'Europe/Paris' });
formatDate('2026-03-01'); // 'Mar 1, 2026' — a date-only value keeps its day
type Line = { item: string; amount: number };
const table = resolvePrintTable(
definePrintColumns<Line>([
{ key: 'item', label: 'Item' },
{ key: 'amount', label: 'Amount', format: 'amount', total: 'sum' },
]),
[{ item: 'Design', amount: 1200 }],
{ currency: 'EUR' },
);
table.rows.map((row) => row.map((cell) => formatPrintValue(cell, { locale: 'en-US' })));
// [['Design', '€1,200.00']]PDF files in a browser: /pdf-source, /pdf-file-name, /pdf-download
A PdfSource is what both libraries' PdfReader opens: a URL, a Blob, an
ArrayBuffer, a Uint8Array or { url, httpHeaders }. These helpers name
it, key it, save it and print it.
import { downloadPdfFile, printPdfFile } from '@nxgt/material-core/pdf-download';
import { resolvePdfFileName } from '@nxgt/material-core/pdf-file-name';
import { type PdfSource, pdfSourceKey } from '@nxgt/material-core/pdf-source';
const source: PdfSource = { url: '/api/invoices/42.pdf', httpHeaders: { Authorization: 'Bearer …' } };
resolvePdfFileName(source); // '42.pdf' — fileName, then the File's name, then the URL
pdfSourceKey(source); // a stable key, to tell when the source changed
await downloadPdfFile(source, { fileName: 'Invoice 42' }); // saves 'Invoice 42.pdf'
await printPdfFile(source); // prints from a hidden frame, or opens a tabdownloadPdfFile and printPdfFile touch document: call them from a click
handler, never at import or on a server.
The PDF viewer's pure half: /pdf-viewer
What both libraries' PdfReader computes without pdf.js or a framework:
the zoom that fits a page, a page's placeholder size, where a search matches
in a page's text. It takes pdf.js's objects by shape (numPages,
getPage, getTextContent), so it never imports pdf.js itself.
import { findTextMatches, pageTextFromItems, placeholderSize, readerFitScale } from '@nxgt/material-core/pdf-viewer';
const letter = { width: 612, height: 792 }; // PDF points
readerFitScale('width', [letter], 0, { width: 1000, height: 800 }); // 1.58…
placeholderSize(letter, 1.5, 90); // { width: 1188, height: 918 } — turned
const page = pageTextFromItems([{ str: 'Café au', hasEOL: true }, { str: 'lait' }]);
findTextMatches(page.text, 'cafe au lait'); // [{ start: 0, end: 12 }] — case, accents and line breaks ignoredTo open a reader on a page, pinToPage(node, beforeScroll, onRelease?)
keeps that page at the top of the page area (an element inside the
[data-slot="scroll-area-viewport"] of a [data-slot="scroll-area"]) while
the pages above it change size, until a wheel, touch, pointer or key in the
page area, or the function it returns. beforeScroll runs before each
correction (hold your scroll spy there); onRelease runs once when the pin
lets go — at once when there is nothing to pin, or no ResizeObserver (a
server). pinHolds(node) tells whether the view still shows node where the
pin puts it: at the top, or as near as the area can scroll at its end. A
scroll that leaves it elsewhere came from find in page, a screen reader's
cursor or your own scrollTop, none of which the pin hears: treat it as the
user's. Such a scroll can land within the hold a correction starts, which
ignores it: give createScrollSpy the option readerPosition, which says
whether the view is still where the reader put it, and the spy measures once,
two frames after release (past a resize's correction), when a held scroll
left it elsewhere. Its fourth argument is { idleMs?, readerPosition? }; a
bare number is still idleMs. Once such a scroll moved the view, up or
down, the pin makes no further correction (it would scroll back to the page):
it tells a scroll it did not make by a scrollTop other than the one its
last correction left, with the page off the top; the browser anchoring the
scroll on the page, or clamping it at the end as the pages above shrink, is
not taken for one. It does not let go itself then, and onRelease does not
run: call the function it returned once your spy reports the view. Keep each
page's room (a min-height) until it has drawn, at each new scale or
rotation too: a page list that shrinks for a moment has its scroll clamped
up, which the pin would take for such a scroll.
import {
createScrollSpy,
pinHolds,
pinToPage,
} from '@nxgt/material-core/pdf-viewer';
// pages: Map<number, HTMLElement>
let pinned = true;
const spy = createScrollSpy(
() => area, // the reader's page area
() => pages,
onScroll,
// still where the reader put it
{ readerPosition: () => !pinned || pinHolds(pages.get(3)) },
);
const unpin = pinToPage(
() => pages.get(3), // the page's element, looked up again on each correction
() => spy.hold(), // the corrections are not the user's scrolling
() => { pinned = false; }, // the pin let go
);
function onScroll(page: number) {
if (pinned && pinHolds(pages.get(3))) return; // still the reader's own scroll
unpin(); // the view moved elsewhere: let go, then report
reportPage(page);
}pageInView(area, pages) is the spy's own measure, on demand: the page most
on screen, the last one in view once the area is scrolled to its end, or null when
no page is in view.
const page = pageInView(area, pages); // 3pdf.js draws a link annotation as an empty <a>, which axe flags (link-name).
labelLinkAnchors gives each such anchor in an annotation layer an aria-label;
linkAnnotationLabel is the pure naming rule and resolveDestinationPage tells
the page an internal destination lands on. An anchor that already has text or an
aria-label is left alone, and its own title is kept in the name
(goToPageTitled). Pass the labels already translated; the names it sets carry
data-nxgt-link-label, so calling it again with new labels (another language)
renames them.
import { labelLinkAnchors, resolveDestinationPage } from '@nxgt/material-core/pdf-viewer';
await labelLinkAnchors(
pageElement, // holds the `.annotationLayer`
(id) => annotations.get(id), // pdf.js annotation by id: { url?, dest? }
(dest) => resolveDestinationPage(pdf, dest),
{
external: (host) => `Link: ${host}`,
goToPage: (n) => `Go to page ${n}`,
goToPageTitled: (n, title) => `Go to page ${n}: ${title}`,
internal: 'Link in document',
},
);The PDF reader's engine: /pdf-reader
Experimental, and used by PdfReader internally: the engine under the new
reader, its API still free to change. pdf.js is handed in by shape (it satisfies PdfjsLike), so core
never imports it and a server never evaluates it. pdfjs-dist, its worker
and its assets (cmaps/, standard_fonts/ and wasm/ under assetsUrl)
are the application's to install and serve; they are not peers of core.
import {
classifyPdfError, configurePdfReader, openPdfDocument, planRender,
} from '@nxgt/material-core/pdf-reader';
configurePdfReader({ assetsUrl: '/pdfjs', workerSrc: '/pdf.worker.min.mjs' });
const pdfjs = await import('pdfjs-dist'); // satisfies PdfjsLike
const controller = new AbortController();
try {
const { doc, numPages, destroy } = await openPdfDocument(pdfjs, { url: '/a.pdf' }, {
onPassword: (submit, reason) => submit(promptPassword(reason === 'wrong')),
signal: controller.signal,
});
const page = await doc.getPage(1);
console.log(numPages, page.getViewport({ scale: 1 }));
await destroy();
} catch (error) {
classifyPdfError(error).kind; // 'password-required' | 'invalid' | 'missing' | 'network' | 'canvas' | …
}
planRender({ cssWidth: 612, cssHeight: 792, devicePixelRatio: 3 }); // canvas size under MAX_CANVAS_PIXELScreatePageCache (an LRU of rendered pages with an eviction callback, its
capacity counted in entries, or in whatever a size function measures, such
as pixels),
renderPageToCanvas (cancels the pdf.js task on abort; throws
CanvasUnavailableError when the browser gives no 2D context) and
mountedPages (which rows to mount for a scroll position) complete it.
The most recently used entry always stays, even when it alone is over the
budget.
import { createPageCache } from '@nxgt/material-core/pdf-reader';
const free = (canvas: HTMLCanvasElement) => {
canvas.width = 0; // releases the backing store now (iOS caps the total)
canvas.height = 0;
};
// At most 8M pixels of canvases, the least recently used evicted first.
const cache = createPageCache<HTMLCanvasElement>(
8_000_000,
free,
(c) => c.width * c.height,
);
cache.set({ page: 1, scale: 1.5, rotation: 0 }, canvas);
cache.get({ page: 1, scale: 1.5, rotation: 0 }); // the canvas, now most recentopenPdfDocument takes pdf.js's own parameters (PdfjsSourceParams):
{ url, httpHeaders, withCredentials } for a file behind a login, { data }
for bytes (toPdfjsParams from /pdf-source builds them from a URL, a Blob
or bytes). Its handle's doc is typed after the
pdf.js handed in, so pdfjs-dist's PDFDocumentProxy comes back whole. A
byte source is transferred to the worker: pass a copy to keep the buffer.
const { doc } = await openPdfDocument(pdfjs, {
url: '/api/invoices/42.pdf',
httpHeaders: { Authorization: `Bearer ${token}` },
withCredentials: true,
});
doc.getMetadata(); // pdfjs-dist's PDFDocumentProxy, typedThe page column. layoutSpreads with mode: 'single' stacks the pages
(sizes already zoomed and turned) a gap apart, one page a row; pageAtOffset says which page a scroll
position is on (the first page of the row most on screen); anchorAt and
offsetOf turn a scroll offset into a page and a fraction of its row, and
back, so a zoom puts the same place on screen.
import {
anchorAt, layoutSpreads, offsetOf, pageAtOffset,
} from '@nxgt/material-core/pdf-reader';
const column = { mode: 'single', gap: 16 } as const;
const before = layoutSpreads(sizesAt(1), column);
const anchor = anchorAt(before, scroller.scrollTop); // { page: 3, fraction: 0.4 }
const after = layoutSpreads(sizesAt(1.5), column);
scroller.scrollTop = offsetOf(after, anchor); // page 3, 40% down, again
pageAtOffset(after, { top: scroller.scrollTop, height: 800, atEnd: false }); // 3Two pages. layoutSpreads lays the same pages out in rows:
mode: 'single' is the column above, 'spread' puts two pages side by
side, and 'spread-cover' leaves the first page alone, then pairs, as in a
book. dir: 'rtl' puts the first page of a pair on the right. The layout
gives each page its tops, lefts, widths and heights, and its rows
({ top, height, pages }, pages left to right). gap is between rows,
columnGap between the two pages of a row (default gap), dir defaults to
ltr. A last odd page sits where the first of a pair would (on the left in
ltr, on the right in rtl), and the cover on the right in ltr and on the
left in rtl, facing the first pair. A document with no
pair at all (one page, or two with a cover) is always a column.
pageAtOffset, anchorAt and offsetOf work per row: an anchor is a page
and a fraction of its row, and anchorAt(layout, offset, x) takes the page of
the pair under x (the lower number on a tie). Switching layout keeps that
fraction of the row, so with pages of mixed heights the view shifts slightly.
import { anchorAt, layoutSpreads, offsetOf } from '@nxgt/material-core/pdf-reader';
const options = { mode: 'spread-cover', gap: 16, columnGap: 8, dir: 'rtl' } as const;
const before = layoutSpreads(sizesAt(1), options);
before.rows.map((row) => row.pages); // [[1], [3, 2], [5, 4]]
const anchor = anchorAt(before, scroller.scrollTop, viewMiddleX);
scroller.scrollTop = offsetOf(layoutSpreads(sizesAt(1.5), options), anchor);Links. createPdfLinkService is what pdf.js's AnnotationLayer takes as
linkService: an internal link goes to its page through your goToPage
(pages not drawn yet included), an external one opens where
externalLinkTarget says, with rel set to PDF_LINK_REL. Only http(s),
ftp, mailto: and tel: links get an href; a javascript: or data: one
stays inert, and named actions and scripts never run.
import { createPdfLinkService } from '@nxgt/material-core/pdf-reader';
const linkService = createPdfLinkService({
doc, // the open document
goToPage: (page) => reader.goToPage(page),
externalLinkTarget: '_blank',
});
new pdfjs.AnnotationLayer({ div, page, viewport, linkService, /* … */ });Search highlight. applyPageHighlight marks a page's search matches in
the text layer pdf.js drew for it. Its PdfPageSearch holds the page's
text as the document search read it (searchDocument in /pdf-viewer),
the matches found in that text, the current match's rank on this page
(-1 when the current match is on another page) and an optional query,
marked span by span while the document's matches are not known yet; with
neither, it clears the marks. The text is escaped: a document's text never
becomes markup.
import { applyPageHighlight } from '@nxgt/material-core/pdf-reader';
const layer = new pdfjs.TextLayer({ textContentSource, container, viewport });
await layer.render();
applyPageHighlight(
{ divs: layer.textDivs, strs: layer.textContentItemsStr },
{ text: pageText, matches: pageMatches, current: 0, query: 'invoice' },
);
// Each match is a <mark class="pdf-highlight">; the current one has data-current.A currency's decimals: /currency-digits
import { currencyFractionDigits } from '@nxgt/material-core/currency-digits';
currencyFractionDigits('EUR'); // 2
currencyFractionDigits('JPY'); // 0
currencyFractionDigits('KWD'); // 3
currencyFractionDigits('EUR', 0); // 0 — an explicit value wins, as in formatAmountA file's size: /format-file-size
1024-based, at most one decimal and no trailing zero, capped at GB, locale-free by default: a server and
a browser print the same string. What both libraries' FileList,
NetworkUploadField (and its size limit message), FileCard and chat
attachments show; both re-export it as formatFileSize.
import { formatFileSize } from '@nxgt/material-core/format-file-size';
formatFileSize(0); // '0 B'
formatFileSize(512); // '512 B'
formatFileSize(1536); // '1.5 KB'
formatFileSize(5 * 1024 ** 2); // '5 MB' — at most one decimal, never a trailing zero
formatFileSize(2.4 * 1024 ** 2); // '2.4 MB'
formatFileSize(-1); // '' — as for NaN and Infinity
formatFileSize(2.4 * 1024 ** 2, { locale: 'fr' }); // '2,4 Mo' — the language's decimal mark and unit, no trailing zero ('900 ko', and 2 MB gives '2 Mo'; a language that writes the unit as English does, 'de' for one, keeps 'MB')A slider's tick marks: /slider-ticks
The values both libraries' SliderField (ticks) draws its marks at, from
min to max inclusive and evenly spaced: false gives none, a number that
many, true one per step (five without one). Any count is kept between 2
and 21 marks, except when min === max: then there is one mark, at min.
import { tickValues } from '@nxgt/material-core/slider-ticks';
tickValues(0, 100, false); // []
tickValues(0, 100, 5); // [0, 25, 50, 75, 100]
tickValues(0, 10, true, 5); // [0, 5, 10]
tickValues(0, 100, true, 1).length; // 21 — capped
tickValues(5, 5, true); // [5] — min === maxA debounced call: /debounce
The debounce both libraries' SearchField (debounceMs) and Autocomplete
use: the wrapped function runs ms after the last call, with the last
arguments. cancel() drops a pending call, flush() runs it now.
import { debounce } from '@nxgt/material-core/debounce';
const save = debounce((text: string) => console.log('save', text), 300);
save('a');
save('ab'); // 300 ms later: "save ab", once
save.flush(); // or nowAn avatar's initials: /initials
What both libraries' AccountMenu shows in its avatar when there is no
picture: the first letter of the first and of the last word, upper-cased, a
whole code point each.
import { initials } from '@nxgt/material-core/initials';
initials('Ada Lovelace'); // 'AL'
initials('Ada'); // 'A'
initials(' '); // ''Days relative to today: /day-math
What DaySeparator and DayGroupedList (and so Notifications) share:
daysAgo(value, now?) counts local calendar days (not 24-hour blocks, so a
DST day is still one), and dayBucket(ago) names the bucket: future,
today, yesterday, week (2 to 6 days ago, a rolling week that ignores
the locale's week start) or earlier. The wording stays in each component.
import { dayBucket, daysAgo } from '@nxgt/material-core/day-math';
dayBucket(daysAgo(new Date(2026, 9, 13, 23, 59), new Date(2026, 9, 14, 0, 1))); // 'yesterday'Matching text as the user types: /text-match
foldText(text, locale?) lowercases text for locale (the runtime's when
omitted; an invalid tag falls back to it), then strips its diacritics
(normalize('NFD'), combining marks removed). matchesText(text, query,
locale?) says whether the folded, trimmed query occurs in the folded text;
a blank query matches everything. Both libraries' Autocomplete filters its
local options with it, in the provider's language. matchRange(text, query,
locale?) says where: the [start, end) offsets of the first match in text
itself (a decomposed accent stays with its letter), or null with no match
or a blank query; React's ComboboxField marks the match with it.
matchRanges(text, query, locale?) gives every occurrence, under the same
rule, as [start, end) offsets into the original text (so a caller can wrap
the characters as written in a highlight); a range takes in the combining
marks after it, ranges never overlap, and a blank query has none.
import { foldText, matchesText, matchRange, matchRanges } from '@nxgt/material-core/text-match';
foldText('Élève'); // 'eleve'
foldText('İSTANBUL', 'tr'); // 'istanbul'
matchesText('Orléans', 'orle'); // true
matchesText('Cafe', 'café'); // true — both ways
matchesText('Lyon', ' '); // true — a blank query keeps everything
matchRange('École', 'eco'); // [0, 3] — 'Éco'
matchRange('Lyon', 'paris'); // null
matchRanges('École', 'ecol'); // [[0, 4]]
matchRanges('Café au café', 'cafe'); // [[0, 4], [8, 12]]Letters that are not a base letter plus a mark stay as they are: ß, ø
and æ do not fold to ss, o or ae.
A search that never answers out of order: /async-search
createAsyncSearch(config, onChange) runs config.search(query, { signal })
once request(query) calls pause for debounceMs (250 by default). Each run
aborts the one before it, and a response that still lands for an older run
is dropped, so a slow first query never overwrites a faster second one; an
abort is never reported as an error. A query shorter than minQueryLength
(1 by default, trimmed) searches nothing and resets the state to idle.
onChange receives each state: { status: 'idle' | 'loading' | 'success' |
'error', query, results, error? }, the last results kept while the next
query loads.
import { createAsyncSearch } from '@nxgt/material-core/async-search';
type City = { value: string; label: string };
const search = createAsyncSearch<City>(
{
search: (query, { signal }) =>
fetch(`/api/cities?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json()),
minQueryLength: 2,
},
(state) => render(state),
);
input.addEventListener('input', () => search.request(input.value));
// On teardown: search.cancel() drops the pending search and aborts the one in flight.configure(partial) swaps the search, the debounce or the minimum length
(React re-reads the latest props through it), flush() runs the pending
search now, and requesting the query the state already holds (or is loading)
does nothing. searchAnnouncement({ loading, failed, settled, count }) is
what a combobox's polite live region says: the failure, else "loading",
else the count once the query has settled — null while the user is still
typing.
An open list above a Drawer's footer: /drawer-room
drawerRoom(element, top) looks for the [data-slot="drawer-content"] around
element and answers how many pixels a floating list that starts at top
(a viewport coordinate) may take before it reaches the Drawer's
[data-slot="drawer-footer"], or the Drawer's bottom edge without a footer,
or the window's. It keeps an 8px gap and never answers less than 80, so the
list stays usable. Outside a Drawer it answers null: the list keeps its own
height.
import { drawerRoom } from '@nxgt/material-core/drawer-room';
const room = drawerRoom(field, field.getBoundingClientRect().bottom + 6);
list.style.maxHeight = room == null ? '' : `${room}px`;Measure again when the field or the Drawer resizes or scrolls.
One submit in flight: /submit-guard
createSubmitGuard() returns { once, settle, pendingRun }. once(run) wraps a
handler so it runs only when no submit is on its way: a second click, or
Enter, before the first request ends is dropped (its event, if it carries
one, has its default prevented, so the browser does not submit the form on
its own). settle() opens the guard again; a run that returns a promise
opens it when that promise ends, fulfilled or rejected, and a run that
throws opens it and rethrows. Any other result keeps it closed until
settle() — unless once(run, { settleSync: true }), which opens it at
once. So: return the request's promise, or follow the request with a busy
flag. The wrapper returns run's result — its promise as one that settles
the same way, so a rejection still reaches the caller.
pendingRun() is the run on its way (an increasing number, undefined when
open), and settle(run) opens the guard only while that run is still the one
on its way. busySettler(guard) uses them: call the function it returns
with each value of a busy flag, and once the flag was true and is false
again it settles the run that was on its way when the flag rose — never a
newer one.
import { createSubmitGuard } from '@nxgt/material-core/submit-guard';
const guard = createSubmitGuard();
const submit = guard.once((event: SubmitEvent) => {
event.preventDefault();
return fetch('/api/subscribe', { method: 'POST', body: new FormData(form) });
});
form.addEventListener('submit', submit); // a second submit before the reply is droppedBoth libraries wrap it as useSubmitOnce(busy?): busy is a boolean in
React (useSubmitOnce(navigation.state !== 'idle')) and a ref or a getter in
Vue (useSubmitOnce(() => pending.value)), followed through busySettler.
Without busy, the hooks pass settleSync: true, so a handler that returns
early does not lock the form.
Errors worded by code: /code-errors
createCodeErrorMap({ translate, codeOf?, fallback }) is a zod 4 error map
(typed by shape: this package does not depend on zod). An issue's code —
by default defaultCodeOf: a custom issue's params.code, when it is a
string — is worded by translate(code, params); when there is no code, or
translate returns undefined or the code itself (a translator's answer for
a key it lacks), fallback(issue) words it. isEmptyRequired(issue) tells
an empty required field (a string shorter than one character, or a missing
value), to give it a code of its own.
import { z } from 'zod';
import {
createCodeErrorMap,
defaultCodeOf,
isEmptyRequired,
} from '@nxgt/material-core/code-errors';
const words: Record<string, string> = {
'errors.taken': 'This name is already taken.',
'errors.required': 'Please fill this in.',
};
const error = createCodeErrorMap({
translate: (code) => words[code],
codeOf: (issue) => (isEmptyRequired(issue) ? 'errors.required' : defaultCodeOf(issue)),
fallback: () => undefined, // zod's own message
});
const schema = z.object({
username: z.string().min(1).refine((name) => name !== 'admin', { params: { code: 'errors.taken' } }),
});
schema.safeParse({ username: 'admin' }, { error }); // "This name is already taken."Both libraries wrap it as useCodeResolver(schema), with their
zodLocaleError as the fallback and the page's language.
Two-step verification: /two-factor
groupSecret(secret, size = 4) reads a TOTP secret in groups, as a person
types it into an authenticator app ("JBSWY3DPEHPK3PXP" → "JBSW Y3DP EHPK
3PXP"); whitespace already in it is dropped first. recoveryCodesText(codes,
{ heading?, note? }) is the text file a person keeps: the heading, the note,
a blank line, one code per line and a trailing newline (a line not given is
left out). downloadText(text, fileName, { type? }) saves it through an
object URL and a temporary <a download>, revoked a minute later,
as /pdf-download does (its OBJECT_URL_TTL_MS).
import {
downloadText,
groupSecret,
recoveryCodesText,
} from '@nxgt/material-core/two-factor';
groupSecret('JBSWY3DPEHPK3PXP'); // "JBSW Y3DP EHPK 3PXP"
const text = recoveryCodesText(['7KQ2-M9XD', 'P4TW-8HZC'], {
heading: 'Recovery codes for [email protected]',
note: 'Each code works once.',
});
downloadText(text, 'recovery-codes.txt');Both libraries' EnrolmentSecret and RecoveryCodes are built on them.
Printing one element: /print-only
printOnly(element, name?) prints that element and nothing else of the
page: it sets data-nxgt-print="<name>" on <html> and
data-nxgt-print-target on the element, calls window.print(), and removes
both on afterprint, or as soon as print() returns where it blocks until
the dialog closes. It returns a function that removes them at once; call it
when the element unmounts. No inline style and no new window: the hiding is
the libraries' stylesheet (print-only.css, in their styles.css),
which under print media and html[data-nxgt-print] shows only the target.
import { printOnly } from '@nxgt/material-core/print-only';
const stop = printOnly(document.querySelector('#codes') as HTMLElement, 'recovery-codes');
// when the element unmounts:
stop();Both libraries re-export it from /lib (@nxgt/material/lib,
@nxgt/material-vue/lib); RecoveryCodes' Print button uses it.
The emoji list's groups: /emoji-groups
How both libraries' EmojiList groups, searches and names its emojis:
groupKey turns a catalog group into a key, groupEmojis builds the tabs
(all, recent, then one per group), searchEmojis filters by name or
shortcode, groupName gives a key's English name (a key it does not know
reads as its words).
import { groupName, searchEmojis } from '@nxgt/material-core/emoji-groups';
groupName('people_body'); // 'People & body'
groupName('hand_signs'); // 'Hand signs'
searchEmojis([{ name: 'fire' }], 'FIR'); // [{ name: 'fire' }]A date range's presets and times: /date-range
The pure half of both libraries' DateRangeField (presets, withTime /
with-time): the built-in presets' ranges, whether a field's rules rule a
range out, and the time of day on each end. Plain Date maths in the local
time zone; nothing reads the clock unless you leave now out. Both libraries
re-export DATE_RANGE_PRESET_IDS and the preset types.
import {
clampRangeEnd,
isRangeDisabled,
presetRange,
resolvePresetRange,
} from '@nxgt/material-core/date-range';
const now = new Date(2026, 2, 18, 15, 30); // Wednesday 18 March 2026
presetRange('last-7-days', now); // 12 → 18 March, both at 00:00 (today counts)
presetRange('this-month', now); // 1 → 18 March
presetRange('last-month', now); // 1 → 28 February
resolvePresetRange('today', now, { withTime: true }); // 18 March 00:00 → 23:59
resolvePresetRange({ label: 'Q1', range: (n) => ({ from: new Date(n.getFullYear(), 0, 1), to: new Date(n.getFullYear(), 2, 31) }) }, now);
// Ruled out when its end comes before its start, it starts before `min`,
// ends after `max`, or either end is a disabled day; the days between are
// not checked.
isRangeDisabled(presetRange('yesterday', now), { min: now }); // true
// On the same day, an end before the start becomes the start.
clampRangeEnd({ from: new Date(2026, 2, 18, 14), to: new Date(2026, 2, 18, 9) }); // to: 14:00keepRangeTimes(next, previous) gives days picked on a calendar the times
previous had at each end (a new start 00:00, a new end 23:59), then clamps
the end;
withTimeOf(day, time) and endOfDayTime(day) set one end's time;
isSameDayRange(a, b) compares two ranges by their days.
The page's CSP nonce: /csp-nonce
Under a strict Content Security Policy (style-src-elem 'nonce-X'), a
<style> element created at runtime is blocked unless it carries the page's
nonce. getCspNonce() finds it, from the first of: the value given to
setCspNonce, <meta property="csp-nonce" nonce="…"> (what Vite's
html.cspNonce writes) or <meta name="csp-nonce" content="…">, the first
<script nonce> or <style nonce> of the page, then
globalThis.__webpack_nonce__. It reads an element's nonce property,
since browsers hide the attribute's value once the page is parsed.
createNoncedStyle() returns a new, unattached <style> that carries it.
import { createNoncedStyle, setCspNonce } from '@nxgt/material-core/csp-nonce';
// Nothing to set when the page carries Vite's tag (html.cspNonce):
// <meta property="csp-nonce" nonce="…">
// With a server-rendered nonce, set it in entry.client.ts before the first
// component renders:
setCspNonce(nonceFromTheServer);
const style = createNoncedStyle();
style.textContent = '.spin { animation: spin 1s linear infinite }';
document.head.append(style);- A
contentattribute is not hidden from CSS selectors, so prefer thenonceattribute. - Browser only. Without a
document(a server),setCspNoncedoes nothing andgetCspNoncereturnsundefined: a nonce belongs to one request, and a module global would hand it to the next. Pass it down the request instead.createNoncedStyle()with nodocumentneeds an explicitdocargument, otherwise it throws. An SSR setup that installs a globaldocument(jsdom or domino) makessetCspNoncea global shared by every request, so do not call it on the server. - The value is kept on
globalThisunderSymbol.for('nxgt.cspNonce'), so two copies of this package in one bundle share it. - For a provider component (the libraries'
NonceProvider): callclaimCspNonce(nonce, depth)once mounted, with how many providers enclose it, and the function it returns on unmount. The deepest claim held is the stored nonce, so nested providers stay right, and releasing the last one puts back what the app had set withsetCspNonce. getNonceandsetNonceare the same two functions underget-nonce's names, so a bundler alias can point that package here.
The CSP for a server or a build: /csp
/csp-nonce reads the nonce in a browser; /csp is for the code that
chooses it: an SSR request handler, a build script, a Vite plugin. It uses
only globalThis.crypto, btoa and strings, so it runs on Node, Bun, Deno,
workers and browsers, and keeps no state.
import {
applyCspNonce,
createCspNonce,
cs