rvlib-mantine
v0.2.0
Published
Mantine app baseline for Bun+React projects: X/H/I globals (core, hooks, Material Symbols icon proxy), a house theme factory, an AppRoot wrapper (MantineProvider + modals + notifications + css), chart export (svg to PNG with styles inlined, a copy/downloa
Readme
rvlib-mantine
Mantine app baseline for Bun+React projects: install once, build screens immediately.
// main.tsx
import "rvlib-mantine/setup" // X/H/I globals — FIRST import
import { AppRoot, makeHouseTheme } from "rvlib-mantine"
import { createRoot } from "react-dom/client"
const theme = makeHouseTheme({ colors: { brand: myBrandRamp }, primaryColor: "brand" })
createRoot(document.getElementById("root")!).render(
<AppRoot theme={theme}>{/* router */}</AppRoot>,
)- Globals: after the setup import, every screen reads
X.Button(@mantine/core),H.useDisclosure(@mantine/hooks),I.settings(Material Symbols proxy — components materialize on demand, cached), and theMaybe<T>type — zero per-file imports. - Icons need the Material Symbols font in
index.html(link tag is insrc/icons.tsx's header comment). - Theme:
makeHouseTheme= house defaults (radius md, Inter, striped tables), project brings its brand ramps. Anything is overridable. - AppRoot: MantineProvider + modals + notifications + css imports.
- Chart data:
groupSum/groupCount(rows to[label, value]pairs, sorted desc),toChart(pairs to chart data with the cyclingCHART_PALETTE).
Chart export
A chart the user can take away: copied, downloaded as PNG, or handed to the app's PDF writer. Wrap each card:
import { ChartContextMenu, configureMantineI18n } from "rvlib-mantine"
configureMantineI18n({ lang: "fr" }) // once, in the app entry; English by default
<ChartContextMenu title="Revenue per site" empty={data.length === 0} onPdf={savePdf}>
<DonutChart data={data} />
</ChartContextMenu>Right click opens the menu where the pointer is, the kebab at the top right opens it for a finger. The PDF entry shows only when onPdf(card, title) is given: this package never imports a PDF writer, the app builds its document from cardToPngBlob(card, { type: "image/jpeg" }) and pngDataUrl.
svgToPngBlob(svg, { scale?, background?, type? })andcardToPngBlob(card, opts): the svg with every computed style inlined on a clone (Mantine paints through CSS variables andcurrentColor, which a standalone svg cannot resolve), drawn on a canvas at scale 2. The card variant draws the title above and redraws the legend below the chart.ImageType:"image/png"for a file the user keeps,"image/jpeg"for an image inside a PDF (jsPDF stores PNG pixels raw, and a report of twenty charts weighs tens of MB that way).legendEntries(card): the{ label, color }rows of a card's legend, from the ColorSwatch rows Mantine renders.copyPng(blob | Promise<Blob>): takes the PENDING blob, since Safari only honours a clipboard write started inside the click.downloadBlob(blob, filename),pngDataUrl(blob),slug(title, fallback?)for the filename.- Every failure is a
ChartImageErrorwith acode, the offscreen timeout included.chartErrorText(e, mantineI18n.t)words it in the active locale. - Strings:
configureMantineI18n({ lang })picks the locale for everything this package renders (EN and FR ship, the choice persists underrv-langlike every other holder),configureMantineI18n({ dict: { fr: { copy: "Copier" } } })overlays single strings and merges across calls, and alabelsprop overrides one component instance. The lib never reads your app's i18n singleton. - The wrapper carries
data-chart-titleanddata-chart-empty(CHART_TITLE_ATTR,CHART_EMPTY_ATTR).chartCardsIn(root),cardTitle(card),isSettled(card)andchartsSettled(root)read them: they are how a page export finds every chart.
For a document, render the FULL view off screen at one width, whatever tab or phone the export was started from:
import { renderOffscreen, useStaticCharts } from "rvlib-mantine"
// every chart reads the flag: no animation under an export, or the first frame is empty
const isStatic = useStaticCharts()
<PieChart data={data} pieProps={{ isAnimationActive: !isStatic }} />
const { cards, dispose } = await renderOffscreen({ width: 1100, render: <ExportView data={data} />, theme })
try {
for (const card of cards) doc.addImage(await pngDataUrl(await cardToPngBlob(card, { type: "image/jpeg" })), ...)
} finally {
dispose()
}renderOffscreen({ width, render, theme?, settled?, timeoutMs? }) mounts the view under StaticCharts in a laid out host placed where no reader looks (a display: none node has no size and a chart in it never draws), waits until every card is painted or marked empty (settled defaults to chartsSettled), and returns { host, cards, dispose }. A render that does not settle in timeoutMs (8 s) is disposed and thrown. A chart must read THIS package's useStaticCharts: an app local context is invisible to the offscreen render, the chart animates, isSettled sees the svg on its first empty frame, and the document carries blank charts with no error.
Page state in the URL (rvlib-mantine/router)
The URL is the persisted state: a refresh, a bookmark and a pasted link show the same view. The tab is a route segment, the filters are the search string.
import { useRouteTab, useSearchState, field, text, oneOf, list } from "rvlib-mantine/router"
// /settings/:tab, an absent or unknown segment shows "sites", a change keeps the search string
const [tab, setTab] = useRouteTab({ tabs: ["sites", "users"] as const, fallback: "sites", base: "/settings" })
const spec = {
period: oneOf(["7 d", "30 d", "1 year"] as const, "30 d", { slug: (p) => p.replace(/\s+/g, "") }), // ?period=7d
from: field("", (raw) => (/^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : undefined), { key: "du" }), // ?du=…, garbage falls back
site: list((raw) => raw || undefined), // ?site=a&site=b, [] when absent
q: text(),
}
const [state, patch] = useSearchState(spec) // state.period is typed "7 d" | "30 d" | "1 year"
patch({ site: ["north"] }) // a replace navigation: back walks pages, never filter clicks- A value at its fallback is absent from the URL, so a bare path is the default view and a shared link carries only what the user changed.
keyin a helper's options names the query key when it differs from the state key. Keys the spec does not name survive a write.encodeSearch(spec, state, base?)anddecodeSearch(spec, params)are the codec under the hook, for a link builder or a test.
Peer deps: @mantine/* v8, react and react-dom ≥18, mobx ^7 and rvlib-mobx ^1 (the locale is observable), react-router ^7 for the router entry only. Ships as TypeScript source (Bun, Vite, and modern bundlers consume it directly).
