--- URL: "https://mono-libs.netlify.app/migration" LLMS_URL: "https://mono-libs.netlify.app/migration.md" description: "READ THIS FIRST. The packages were renamed: mono-helper → @mono-lit/helper, mono-utils → @mono-lit/utility (not utils), mono-devextreme → @mono-lit/devextreme, all public on npmjs. Every other page uses the new names; if the code you are working on still imports mono-*, migrate it with this guide before following any other page." --- # Migration: `mono-*` → `@mono-lit/*` The libraries now live under the company scope **`@mono-lit`** and are published as public packages on [npmjs.com](https://www.npmjs.com/org/mono-lit). The old unscoped names (`mono-helper`, `mono-utils`, `mono-devextreme`) are no longer published, so an app that still installs them has to switch. Only the **package names** change. Every subpath, export, component tag and config option stays the same, so the migration is a rename plus a reinstall. | Old name | New name | | --- | --- | | `mono-helper` | [`@mono-lit/helper`](https://www.npmjs.com/package/@mono-lit/helper) | | `mono-utils` | [`@mono-lit/utility`](https://www.npmjs.com/package/@mono-lit/utility) | | `mono-devextreme` | [`@mono-lit/devextreme`](https://www.npmjs.com/package/@mono-lit/devextreme) | ::: warning `mono-utils` becomes `utility`, not `utils` It is the only package whose short name changes too: `mono-utils/fetching` becomes `@mono-lit/utility/fetching`. A plain "add the scope" replacement produces `@mono-lit/utils`, which does not exist. ::: ## 1. Remove the old registry from `.npmrc` The packages are public on npmjs, the default registry, so no token or registry setting is needed. Delete the old Netlify lines from your project's `.npmrc`, and delete the file if nothing is left in it: ```diff # .npmrc - registry=https://mono-libs.netlify.app/npm/ - //mono-libs.netlify.app/npm/:_authToken= ``` ::: tip Check it resolves `npm view @mono-lit/helper version` should print a version. If it errors, an `.npmrc` (in the project, or in your user folder) still points `@mono-lit` somewhere else. ::: ## 2. Swap the dependencies Remove the old packages and add the new ones. Only add the packages your app used before. ```sh pnpm remove mono-helper mono-utils mono-devextreme pnpm add @mono-lit/helper @mono-lit/utility @mono-lit/devextreme ``` If your workspace pins versions in a pnpm **catalog**, rename the keys there too: ```diff # pnpm-workspace.yaml catalog: - mono-helper: 0.0.2 - mono-utils: 0.0.3 - mono-devextreme: 0.0.1 + '@mono-lit/helper': ^0.0.1 + '@mono-lit/utility': ^0.0.1 + '@mono-lit/devextreme': ^0.0.1 ``` ```diff # package.json "dependencies": { - "mono-helper": "catalog:", - "mono-utils": "catalog:", + "@mono-lit/helper": "catalog:", + "@mono-lit/utility": "catalog:", } ``` ## 3. Rename every import Subpaths are unchanged, so each specifier only needs its prefix swapped: ```diff - import 'mono-helper/index.css' - import 'mono-helper/ui/button' - import { monoDataGrid } from 'mono-helper/ui/table' - import { monoFetch } from 'mono-utils/fetching' - import { DataSource } from 'mono-devextreme' + import '@mono-lit/helper/index.css' + import '@mono-lit/helper/ui/button' + import { monoDataGrid } from '@mono-lit/helper/ui/table' + import { monoFetch } from '@mono-lit/utility/fetching' + import { DataSource } from '@mono-lit/devextreme' ``` The names appear in more places than `import` lines. Check each of these: - `.vue`, `.ts`, `.js` and `.css` imports, including `@import 'mono-helper/...'` in CSS - `nuxt.config.ts` → `modules: ['@mono-lit/utility/nuxt', '@mono-lit/helper/nuxt']` - `vite.config.ts` → `import { monoRepo } from '@mono-lit/utility/vite'` - `optimizeDeps.include` / `exclude`, `ssr.noExternal`, `build.rollupOptions.external` - `mono.config.ts` → `import { defineConfig } from '@mono-lit/utility/config'` - type-only imports such as `import type {} from '@mono-lit/helper/vue'` - `tsconfig.json` `types` / `paths` entries and `/// ` lines ## 4. Reinstall and check ```sh pnpm install ``` Then search for anything left over. Both commands should print nothing: ```sh git grep -nE "['\"\`]mono-(helper|utils|devextreme)['\"\`/]" git grep -n "@mono-lit/utils" ``` Restart the dev server (stop it and run it again) so Vite rebuilds its dependency cache under the new names. If the browser shows an error like `'mono-button' has already been defined`, a stale cache is still loading the old copy: delete `node_modules/.vite` (and `.nuxt` in a Nuxt app) and start again. ## What stays the same None of these are package names, so **do not rename them**: | Kept as is | Example | | --- | --- | | Component tags | ``, ``, `` | | The CLI command | `mono sync`, `pnpm mono:sync` | | The config file | `mono.config.ts` and its options | | The Nuxt config key | `mono: { utils: {...}, helper: {...} }` in `nuxt.config.ts` | | Storage keys and CSS classes | `mono-helper-theme`, `.theme-nova`, `--mono-*` variables | | Subpaths and exports | `/ui/table`, `/fetching`, `monoDataGrid`, `monoRepo` | ### Renamed internals These only matter if your code matched them by string, which is rare: | Old | New | | --- | --- | | Vite plugin names `mono-helper:*` | `@mono-lit/helper:*` (e.g. `@mono-lit/helper:mono-ssr-stub`) | | Log prefixes `[mono-utils/...]` | `[@mono-lit/utility/...]` | | Nuxt module name `mono-helper` | `@mono-lit/helper` | --- URL: "https://mono-libs.netlify.app/" LLMS_URL: "https://mono-libs.netlify.app/index.md" layout: "home" markdownStyles: false pageClass: "mono-cyber" description: "A small toolkit that combines a Host/Remote mono-repo pattern with Lit-powered web components — build large, modular Vue front-ends without rebuilding the same generic pieces every time." --- --- URL: "https://mono-libs.netlify.app/addons/chart" LLMS_URL: "https://mono-libs.netlify.app/addons/chart.md" --- # Chart.js Charts built on [Chart.js](https://www.chartjs.org/). A `controlMonoChart(...)` controller projects your rows into chart data — the same controller-plus-`.prop` shape as `controlMonoTable` — and the `` elements own the canvas. ```ts import '@mono-lit/helper/ui/chart' import { controlMonoChart } from '@mono-lit/helper' const chart = controlMonoChart(rows, { labelField: 'month', series: [ { field: 'sales', label: 'Sales' }, { field: 'costs', label: 'Costs' }, ], }) ``` ```vue ``` ::: tip Install Chart.js is an **optional peer** — it isn't bundled, so nothing pays for it unless you chart something. ```sh pnpm add chart.js@4.5.1 ``` The version is pinned exact. If it's missing, the element renders an inline message naming the package and the command rather than failing silently. ::: ## Elements | Element | Type | | --- | --- | | `` | whatever `type` says — `radar`, `polarArea`, `scatter`, `bubble` … | | `` | `bar` | | `` | `line` | | `` | `pie` | | `` | `doughnut` | The presets are `` with `type` locked, so the markup states its intent. Everything else — props, controller binding, lifecycle — is identical. ## Basic `labelField` names the category axis; each `series` entry becomes one dataset. Field names accept the same **path expressions** as the table (`Job.Budget`, `Items.[0].Total`). Vue demo source: ```vue ``` ## Chart types One controller can feed several elements — the projection is shared, only the type differs. Vue demo source: ```vue ``` ## Aggregating rows Charts almost always want *summarised* data, but a data source hands you raw rows. `groupBy` buckets them and each series' `agg` collapses a bucket to one number — `sum` (default), `avg`, `count`, `min` or `max`. ```ts const chart = controlMonoChart(transactions, { groupBy: 'region', series: [{ field: 'amount', label: 'Revenue', agg: 'sum' }], }) ``` Vue demo source: ```vue ``` ## Reactive data The controller is a subscribable store, exactly like `controlMonoTable`. When the bound source emits `changed`, it re-syncs, re-projects and notifies — and the element updates the **existing** Chart.js instance instead of recreating the canvas, so the change animates rather than flashing. Notifications are coalesced on a microtask, so several mutations in one tick produce a single update. Vue demo source: ```vue ``` ## From a DataSource Bind a devextreme `DataSource` and the controller drains it — a remote source is paged, but a chart wants the whole set, so it reads every row in chunks rather than charting page one. Set `loadAll: false` to chart just the current page. With `groupBy` and explicit `series`, a remote **OData** store is not drained at all: the controller sends ONE `GET ?$apply=filter()/groupby((groupBy),aggregate(…))` through the source's own store — its url, `beforeSend` and auth — and charts the buckets that come back, the same clause the [`odata.aggregate`](#server-side-roll-up-apply) path builds. The clause goes into the url (`urlOverride`), not through devextreme's `customQueryParams` (a quoted literal, and a function call on OData v4). A `CustomStore` gets no such request, a server that answers with plain entities is detected and drained instead, and a backend that rejects the clause (4xx / 501) is remembered and not asked again. `serverApply: false` switches it off; without `series` the fields are unknown up front, so the chart drains as before. Vue demo source: ```vue ``` ### Loading & errors While the controller's **first** load (or a retry) is in flight, the chart shows a spinner and `loading-text` over the still-sized canvas. A later reload only dims the plot. When a load fails, the failure lands on `chart.error` (a `describeError` result). The element shows what the server said (every distinct message, combined like `monoFetch` does; the generic text for the status when it said nothing) with a ↻ (`retry-label`) that calls `chart.retry()`, and outlines the plot in the invalid colour. Restyle it through `--mono-chart-ds-error-border`, `--mono-chart-ds-error-ring` and `--mono-chart-ds-error-text`. This covers drains, `reload()`, `odata` fetches and the source's own `loadError`. Vue demo source: ```vue ``` ## Server-side roll-up (`$apply`) Draining is the wrong shape for a summary chart: a country-total over 100k rows would be ~1000 paged requests carrying every column, to produce a dozen bars. Point the controller at the endpoint with `odata` and give it an `aggregate` — the server groups with OData's `$apply` and returns one row per bucket, in a single request. A filter folds *inside* the clause, so it's applied before the aggregation. The demo below runs against the public TripPin service. It is a small dataset, but the shape is the point: one request, one row per bucket. The measure is `$count` because TripPin has no populated numeric column. Vue demo source: ```vue ``` ::: warning Requires the `@mono-lit/utility` peer The request goes through `monoFetchOdata`. It's an **optional** peer loaded on demand — a chart that never sets `odata` neither needs nor loads it. ::: ## DataSource options The chart takes the same base-query options as [`controlMonoTable`](/ui/table#datasource-options): `dataSourceOptions` (devextreme knobs — `filter`, `select`, `expand`, `sort`, `customQueryParams`) and `odataOptions` (`$filter`, `$select`, `$expand`, `$orderby`, other `$params`). Each takes a value, a **getter**, or a `{ value }` box (a Vue `ref` / `computed`) and is read fresh at every `reload()` — so a getter over reactive state always contributes its current value, and the demo above no longer needs a hand-written `get options()`. Where it lands depends on the path: | Path | What the base does | | --- | --- | | bound DataSource, `loadAll: true` (default) | rides on every drain chunk, AND-ed under the filter the source itself carries; `select` / `sort` / `expand` shape the chunk requests | | bound DataSource, `loadAll: false` | written onto the source before `load()`, adopting whatever filter the consumer set there | | `odata` | merged into `odata.options`; on the `$apply` path the composed filter folds *inside* the clause | | array | `filter` is applied as a predicate to the rows | ```ts const chart = controlMonoChart(null, { groupBy: 'Gender', series: [{ field: 'Rows', agg: 'count' }], odata: { baseUrl, url: '/People', aggregate: ({ apply, filter, withFilter }) => withFilter(apply, filter) }, odataOptions: () => (onlyA.value ? { $filter: "contains(FirstName,'A')" } : {}), }) watch(onlyA, () => chart.refresh()) ``` `refresh()` is an alias of `reload()` here — a chart has no paging to preserve, so the call after a state change is the same either way. `setDataSourceOptions()` / `setOdataOptions()` replace an option and reload; `resolvedDataSourceOptions()` is the merged result of the last query. ## Raw chart.js data Skip the mapping entirely: hand the controller a chart.js `{ labels, datasets }` object and it's used verbatim. A per-dataset `type` gives you mixed charts. Vue demo source: ```vue ``` ## Theme Charts are wired to the theme like every other component — and unlike the rest of the library there is **no attribute-styled markup** here: a chart is a canvas, so the port is the bridge into Chart.js. The **default palette is Basecoat's own chart colours**, `--chart-1` … `--chart-5`, resolved by name at paint time; the legend and title take `--foreground`, the ticks `--muted-foreground`, the grid `--border`, the tooltip is the flavour's popover — an HTML element over the canvas, painted with the select panel's corner, ring and shadow — the type is the page font at text-xs, a pie's slice hairline is `--background`, and a bar's corner is the flavour's field corner (square in lyra / sera, pills in maia / luma). So a chart with no `color` or `colors` follows the active [color preset, flavour and mode](../ui/theme) — nothing is fixed hex. Vue demo source: ```vue ``` ::: warning A canvas doesn't re-cascade Switching theme repaints every other component for free: the class swap re-cascades and CSS redraws. A chart can't — it's a bitmap painted from values resolved once, so it would keep the old palette. The elements listen for the `theme-changed` event `applyTheme()` fires, **and watch the `.dark` class** on `` / `` and the OS colour scheme, so a mode toggle that only swaps the class (VitePress's, or your own) repaints too. If you swap the theme classes **by hand** instead of calling `applyTheme()`, dispatch that event yourself: ```ts window.dispatchEvent(new CustomEvent('theme-changed')) ``` ::: ## Colors Anywhere a colour is accepted you can use a **mono color name** — Basecoat's `chart-1` … `chart-5`, or the roles `primary`, `secondary`, `accent`, `success`, `warning`, `danger`, `info` — or any CSS colour, mixed freely. Use `color` for one accent, `colors` for a palette, or `series[].color` per dataset; the more specific one wins. The default palette is `chart-1` … `chart-5` followed by `success`, `warning`, `danger`, so a sixth series is still its own colour. Vue demo source: ```vue ``` ::: tip Why names have to be resolved A `` can't resolve `var()` — raw `var(--success)` paints transparent. The element reads the token off the DOM and hands Chart.js the real value, which is why names work at all. ::: ### CSS variables Every name resolves through a public `--mono-chart-` override, so a scope can retarget what a name means without touching chart code; the inks and chrome have knobs of the same shape. They inherit, and pierce the shadow boundary. | Variable | Default | Controls | | --- | --- | --- | | `--mono-chart-1` … `--mono-chart-5` | `--chart-1` … `--chart-5` | The default palette, and the `chart-n` names | | `--mono-chart-primary` / `-secondary` / `-accent` | `--primary` / `--muted-foreground` / `--chart-2` | The role names (Basecoat's `--accent` is a surface, so `accent` is the second chart colour) | | `--mono-chart-success` / `-warning` / `-danger` / `-info` | `--success` / `--warning` / `--destructive` / `--info` | The state names | | `--mono-chart-text` / `-muted` / `-grid` | `--foreground` / `--muted-foreground` / `--border` | Legend + title ink, tick ink, grid + axis lines | | `--mono-chart-surface` | `--background` | The hairline between pie / doughnut slices | | `--mono-chart-tooltip-bg` / `-tooltip-color` / `-tooltip-radius` / `-tooltip-ring` / `-tooltip-shadow` | the select panel's popover: `--mono-select-dropdown-*` → `--popover` / `--popover-foreground` / `--mono-radius-md` / a 1px `--foreground` 10% ring / `--mono-shadow-md` | The tooltip — an HTML popover placed over the canvas, so the flavour's corner, ring and shadow reach it (luma's 3xl + shadow-lg, lyra's square, maia's 2xl) | | `--mono-chart-font` / `-font-family` | `--mono-text-xs` / `--font-sans` | Every label's type (read as computed px, so a `rem` works) | | `--mono-chart-radius` | `--mono-radius-md` | The tooltip's corner and the empty / error message box | | `--mono-chart-bar-radius` | the field corner: `--mono-input-outline-radius` → `--mono-input-radius` → `--mono-radius-md` | A bar's free-end corner — square in lyra / sera, a pill in maia / luma, with nothing set (Chart.js clamps it to half a bar) | Deprecated and honoured as no-ops until 2.0: the `--theme-*` bridge (`--theme-primary`, `--theme-text`, `--theme-border` …) — every default reads a Basecoat token now. ## Customising Every layer is live in one demo: `color` / `colors` on the element, `series[].color` per dataset, `--mono-chart-*` to retarget what a *name* means, and raw Chart.js options for everything else. Anything Chart.js supports is reachable through `options` on the controller or `chart-options` on the element — both are **deep-merged over** the generated defaults, so you keep the theming and override only what you name. Vue demo source: ```vue ``` Overriding the variable is the theme-level lever — every chart that asked for `success` follows, with no chart code touched. The same works for the default palette (`--mono-chart-1` … `-5`) and the chrome: ```css .sales-dashboard { --mono-chart-success: #0f9d58; --mono-chart-danger: #d93025; --mono-chart-grid: transparent; /* no grid lines on this dashboard */ } ``` Vue demo source: ```vue ``` ## Controller API ```ts const chart = controlMonoChart(source, options) ``` `source` is an array, a devextreme `DataSource`, a raw chart.js `data` object, or `null`. | Option | Purpose | | --- | --- | | `type` | Default chart type (`'bar'`) | | `labelField` | Row field for the category axis | | `groupBy` | Bucket rows by this field before charting | | `series[]` | `{ field, label?, agg?, color?, type?, dataset? }` — one dataset each | | `colors[]` | Palette — mono color names or CSS colors | | `options` | Raw chart.js options, deep-merged | | `props` | The element's own props, declared centrally — see [Element props](#element-props) | | `odata` | Fetch through `monoFetchOdata` (`configBaseUrl` / `baseUrl` / `url` / `method` / `options`). Add `aggregate` to roll up on the server — see [Server-side roll-up](#server-side-roll-up-apply) | | `loadAll` | Drain a remote source instead of charting one page (default `true`) | | `serverApply` | Let a bound OData store roll the data up with one `$apply` instead of draining (default `true`; needs `groupBy` + `series`) — see [From a DataSource](#from-a-datasource) | | `dataSourceOptions` / `odataOptions` | The sticky base every query goes out on — see [DataSource options](#datasource-options) | | Member | Purpose | | --- | --- | | `items` | Rows currently backing the chart | | `loading` | True while the source loads | | `error` / `hasLoaded` | The last load failure (`{ message, status?, detail? }`, null once a load succeeds) / whether the source has answered yet | | `retry()` | `reload()` that never rejects — the failure lands on `error` | | `data()` / `options()` | The projected chart.js data / merged options | | `props()` / `setProps(patch)` | Read / merge the central element props | | `setType` / `setSeries` / `setColors` / `setData` | Reproject | | `bind(source)` / `reload()` / `refresh()` | Swap or re-read the source (`refresh` is an alias of `reload`) | | `setDataSourceOptions` / `setOdataOptions` / `resolvedDataSourceOptions` | Replace the base and reload / read the merged base | | `onPointClick` | Assignable sink — receives the label, value **and the rows that produced the point** | | `subscribe(cb)` / `dispose()` | Lifecycle | `onPointClick` reports the underlying rows, which is what makes drill-down work: click a bar and you get back the bucket that built it. ```ts chart.onPointClick = ({ label, value, rows }) => { console.log(label, value, rows) // rows = every record in that bucket } ``` ## Element props Declare the element's own props in the controller's `props` block and every bound `` needs nothing but `:control-chart.prop="chart"` — the same arrangement as [`controlMonoTable({ props })`](/ui/table), minus the per-element nesting, since the presets are one element with `type` fixed. ```ts const chart = controlMonoChart(rows, { groupBy: 'month', series: [{ field: 'amount', label: 'Revenue', agg: 'sum' }], props: { height: 320, legend: 'bottom', stacked: true }, }) chart.props() // { height: 320, legend: 'bottom', stacked: true } chart.setProps({ legend: 'right' }) // merges, notifies, every bound element re-applies ``` `props()` returns one object with a stable identity, so a controller can drive several charts and keep them in sync. `setProps` is a **merge** — keys you omit keep their current value. ::: tip Setting props on the element still works Writing `height` / `legend` / `stacked` directly on `` is still supported. Where both declare the same key, the **controller wins**; keys the controller never mentions are left to the template. ::: ## Server rendering A chart can't be server-rendered — Chart.js paints onto a canvas at runtime. The SSR build (`@mono-lit/helper/ui/shadow/chart`) emits a correctly-sized empty `` and draws on hydration, so layout doesn't shift. `controlMonoChart` itself is a pure factory and is safe to import on the server. ## Props `controlMonoChart` is also exported as `monoChart`, and `:control-chart` is also accepted as `:data-chart` — the older spellings still work. ```ts import { ChartProps } from '@mono-lit/helper' ``` | Prop | Value | Default | Description | | --- | --- | --- | --- | | `controlChart` | `MonoChartController` | — | The chart controller. Bind with `.prop`: `:control-chart.prop="chart"`. | | `dataChart` | `MonoChartController` | — | Renamed — `:data-chart` / `:dataChart` alias `controlChart` (both work). | | `loading` | `boolean` | — | Spinner + `loadingText` over the plot — the app is still fetching what it shows. | | `loadingText` | `string` | — | — | | `loadingError` | `unknown` | — | The app's fetch failed: a string or the error itself. Painted with ↻, which fires `retry`. | | `retryLabel` | `string` | — | — | | `type` | `'bar' \| 'line' \| 'pie' \| 'doughnut' \| 'radar' \| 'polarArea' \| 'scatter' \| 'bubble'` | — | Chart type. Ignored by the presets, which fix their own. When a controller is bound this overrides the controller's `type`. | | `data` | `MonoChartData` | — | Raw chart.js data, for use WITHOUT a controller. Bind with `.prop`: `:data.prop="{ labels, datasets }"`. | | `chartOptions` | `Record` | — | Raw chart.js options, merged over the defaults. Bind with `.prop`. | | `width` | `string \| number` | — | Canvas box sizing. A CSS length string or a number (px). | | `height` | `string \| number` | — | — | | `aspectRatio` | `number` | — | `width / height` when no explicit height is set. Default `2`. | | `legend` | `'left' \| 'right' \| 'top' \| 'bottom' \| boolean` | — | Show the legend (default `true`), or place it: `top` / `bottom` / `left` / `right`. | | `title` | `string` | — | Chart title rendered by chart.js. | | `stacked` | `boolean` | — | Stack bar/line datasets. Default `false`. | | `color` | `string` | — | Single accent for every dataset — a mono color name (`primary`, `success`, `danger`, …) or any CSS color. `colors` wins when both are set. | | `colors` | `string \| string[]` | — | Palette. A comma-separated attribute (`colors="success,danger,warning"`) or an array via `.prop`. Entries may be mono color names or CSS colors, mixed. | | `cssClass` | `ChartCssClass` | — | Per-part class overrides. Object, or a JSON string via the `css-class` attribute. | | `cssClassName` | `string` | — | A single class added to the root. | ## Optional dependency `chart.js` is declared as an optional `peerDependency` pinned to `4.5.1`, and is loaded with a dynamic `import('chart.js/auto')` the first time an element renders. It is **externalized** from the build, so `dist/ui/chart.js` stays around 13 kB and resolves against your app's copy at runtime — install it yourself, and nothing else in @mono-lit/helper is affected if you don't. --- URL: "https://mono-libs.netlify.app/addons/notivue" LLMS_URL: "https://mono-libs.netlify.app/addons/notivue.md" --- # Notivue Toast **notifications** for the template apps, built on [notivue](https://notivue.smastrom.io/). You don't call notivue directly — you push through **`notif()`** (from [`useMonoUtility`](../repo/useful-utils)) and render with the **`MonoNotivue`** component. It's an **optional** dependency: skip it if an app doesn't need notifications. ## `notif()` — push a notification `notif` is one unified call for every kind of toast. Non-error toasts auto-dismiss (~3s); `error` sticks until dismissed; `promise` shows a pending spinner and can redirect on resolve. ```ts import { useMonoUtility } from '@mono-lit/utility/runtime' const { notif } = useMonoUtility() notif({ type: 'success', message: 'Saved.' }) notif({ type: 'error', message: 'Could not save — try again.' }) // promise: pending spinner, then (optionally) route away when it resolves notif({ type: 'promise', message: 'Saving…', router, // vue-router instance props: { redirect: '/data', buttons: [] }, }) ``` ## `MonoNotivue` — render them `MonoNotivue` is the drop-in renderer for your app shell — it replaces the hand-wired `…` block. Action notifications (`props.isAction`) render a custom card with buttons; everything else uses notivue's default themed toast. ```vue ``` `MonoNotivue` only **renders**. Each app still registers the notivue plugin + CSS once (below). ## Per-template setup Notivue is wired **differently** in each template — the shell that owns the plugin differs. ### Vue Host — manual plugin The host owns the notification UI. In `main.ts`, import the CSS, create the plugin, and register it: ```ts // main.ts import 'notivue/notifications.css' import 'notivue/animations.css' import { createNotivue } from 'notivue' const notivue = createNotivue({ position: 'top-right', pauseOnHover: true, avoidDuplicates: true, limit: 4, notifications: { global: { duration: 2000 } }, }) app.use(notivue) ``` Then render `` in `App.vue` (it replaces the old hand-wired `` notification block). ### Vue Remote — standalone only A Remote's `main.ts` runs **only when you develop the remote standalone**. Once it's federated into a Host, the Host's app boots it — the remote's `createNotivue`/`app.use` never run, and every `notif()` from remote code surfaces in the **Host's** single notivue instance. So the remote's own setup is minimal (bare ``, CSS `notivue/notification.css` — note the singular filename), and you don't add a second `` inside a federated remote. ### Nuxt Host — the `notivue/nuxt` module (SSR) Nuxt uses notivue's **Nuxt module** instead of a manual `createNotivue`, so notifications are SSR-aware. In `nuxt.config.ts`: ```ts export default defineNuxtConfig({ modules: ['notivue/nuxt'], css: ['notivue/notifications.css', 'notivue/animations.css'], notivue: { position: 'top-right', pauseOnHover: true, avoidDuplicates: true, limit: 4, notifications: { global: { duration: 2000 } }, }, }) ``` There's no `app.use(notivue)` — the module registers it. Render `` in `app.vue`. ## With `monoFetch` The fetch layer integrates with Notivue through a `notif` option: pass `notif: true` and the request routes its success / error through the same `notif()` → notivue, so the call **auto-toasts** without you writing a handler. ```ts import { monoFetch } from '@mono-lit/utility/fetching' await monoFetch({ url: '/Employee', method: 'POST', body, notif: true }) ``` Set it per call, or a default for every request via `monoConfigureFetching({ notif })`. Because it funnels through the same notivue instance, it needs Notivue set up (this addon). See [Data Fetching](../repo/data-fetching). ## Optional dependency `notivue` is an **optional peer** of `@mono-lit/utility` — install it only in apps that show notifications. Without it, `notif()` / `MonoNotivue` / `monoFetch({ notif: true })` simply aren't used; everything else in `@mono-lit/utility` works unchanged. --- URL: "https://mono-libs.netlify.app/addons/rich-text-editor" LLMS_URL: "https://mono-libs.netlify.app/addons/rich-text-editor.md" --- # Rich Text Editor A WYSIWYG field built on [SunEditor](https://suneditor.com) v3. `` is a mono **form field** — label, helper and validation chrome, `size` / `color` / `variant`, `monoForm` binding — whose input is a SunEditor instance. The value is HTML. ```ts import '@mono-lit/helper/ui/rich-text-editor' ``` ```vue ``` ::: tip Install SunEditor is an **optional peer** — it isn't bundled, so nothing pays for it unless a page renders the editor. ```sh pnpm add suneditor@^3.3.3 ``` If it's missing, the element renders an inline message naming the package and the command (and emits `error`) rather than failing silently. The editor's own stylesheet (`suneditor/css/editor`) is loaded by the element the first time one renders; set `load-css="false"` if your app ships it itself. ::: ::: warning Plain JS, not a wrapper There is no published web-component or Vue build of SunEditor — the "Web Components" example on suneditor.com is a fifteen-line custom element around the core package, and `suneditor-vue` is an empty repository. This element wraps the core `suneditor` package the same way that example does (a light-DOM mount, created on connect and destroyed on disconnect) and adds what it lacks: lazy loading, `v-model` and `monoForm`, the mono chrome and theming. ::: ## Basic `model-value` is the HTML. `change` fires when SunEditor commits an edit, `input` on every keystroke — both carry the textarea's detail shape (`modelValue`, `oldValue`, `name`). An emptied editor reports `''`, never SunEditor's `


`, so `required` means what it says. Vue demo source: ```vue ``` CSS/native demo source: ```vue ``` ## Toolbar `toolbar` takes a preset name, a SunEditor `buttonList` (bind with `.prop`), its JSON, or a string of button names where `|` separates groups. | preset | buttons | | --- | --- | | `basic` | undo/redo · bold, underline, italic, strike · bulleted, numbered, link · remove format | | `standard` (default) | + block style, font, size · font & background colour · align, indent · table, link, image, video · blockquote, code block, hr · code view, full screen | | `full` | every built-in that needs no configuration | | `default` | SunEditor's own list — no plugin buttons | Buttons that name a plugin which is not loaded are dropped from the list rather than rejected, so trimming `plugins` never breaks a preset. Vue demo source: ```vue ``` ## Plugins Most toolbar buttons are SunEditor **plugins**. `plugins` defaults to `auto`: every built-in that works without configuration, loaded on demand from `suneditor/plugins` as one chunk your bundler owns. Trim it and the download shrinks: ```vue ``` ::: warning Import plugins on the client SunEditor's modules read `window` when they are imported, so a **static** `import { font } from 'suneditor/plugins'` in a component that is server-rendered (Nuxt, VitePress) throws before the page exists. Import them from `onMounted` (or a client-only module) as above — or use the **name** form, which mono loads on demand and is server-safe as written. ::: Left out of `auto` because each needs configuration SunEditor warns about otherwise: `imageGallery` / `videoGallery` / `audioGallery` / `fileGallery` / `fileBrowser` (a server `url`), `fileUpload` (an `uploadUrl`), `template` and `layout` (a list), `math` (KaTeX / MathJax), `exportPDF` (an API). Ask for them by name and pass their options through `options`. Vue demo source: ```vue ``` ## Form Bind it like any other field — `:control-form` and `key-form` — and the controller owns the value, runs the rules and paints the state. `required` fails on an emptied editor; `min` / `max` measure the HTML string. Vue demo source: ```vue ``` ## Modes `mode` places the toolbar: `classic` (default) above the content, `classic:bottom` below, `inline` / `inline:bottom` shown only while the editor has focus, `balloon` / `balloon-always` floating over the selection. Vue demo source: ```vue ``` ## Sizes `size` scales the toolbar, its icons and the editing font off the `--theme-control-font-*` ladder. The height is the editing area's — `height` (default `auto`), `min-height` (default `10rem`) and `max-height` size it; `width` / `min-width` / `max-width` size the field. Vue demo source: ```vue ``` ## Colors Pick a colour and every variant takes it. The frame is the textarea's: `outlined`, `filled`, `underlined`, and `color` retargets the focus ring, the active toolbar state and links inside the content. Vue demo source: ```vue ``` ## States `disabled` turns SunEditor's toolbar and editing off; `readonly` shows the content and refuses edits. `error-message` / `success-message` / `validation-state` paint the frame like the other fields. `max-length` caps the character count and shows SunEditor's counter (`char-counter` shows it without a cap). Vue demo source: ```vue ``` ## Language and direction `language` takes a SunEditor language code (`ko`, `ja`, `zh-CN`, `pt-BR`, …) and loads that pack on demand, or a language object bound with `.prop`. `text-direction="rtl"` flips the content (the host's own `dir` attribute is left to the page). Vue demo source: ```vue ``` ## Options and the instance `options` (bind with `.prop`) is SunEditor's `InitOptions`, merged **last** over everything the props produce — upload URLs, counters, whitelists, `events`. A handler under `options.events` runs before mono's own for the same event and can still `return false` to cancel SunEditor's default. The element exposes the instance and the operations a form usually needs: `editor` (the SunEditor instance, `null` until `ready`), `getHtml()`, `setHtml(html)`, `insertHtml(html)`, `getText()`, `isEmpty()`, `focus()`, `blur()`, `codeView()`, `fullScreen()`. Everything else is on `editor.$` — `editor.$.html`, `editor.$.viewer`, `editor.$.ui`. Vue demo source: ```vue ``` ## Displaying saved HTML The editor's stylesheet is for the editor. Content shown elsewhere — a detail page, a print view — needs SunEditor's **content** sheet and the `sun-editor-editable` class so tables, alignment and code blocks look as they did when written: ```ts import 'suneditor/css/contents' ``` ```vue
``` Vue demo source: ```vue ``` ## Theming The frame **is** the textarea's box: every knob falls back to the matching [`--mono-textarea-*`](../ui/textarea#css-variables) one, so a flavour that retunes the textarea retunes this field (lyra's square, luma's pill, sera's underline) with nothing set. SunEditor's UI is built on `--se-*` custom properties, and the element remaps the ones a theme is made of onto the Basecoat tokens — the focus colour for its active state and links, `--foreground` / `--muted-foreground` for ink, `--border` for hairlines, the mode's hover washes, and the **select panel's popover** (`--mono-select-dropdown-*`) for its dropdowns and the modal layer it parks on ``. Dark mode is the same tokens flipped. Nothing reaches into SunEditor's selectors except to flatten the frame it paints around itself, which the mono frame replaces, and to give the toolbar its muted band. | variable | default | purpose | | --- | --- | --- | | `--mono-rich-text-editor-primary` … `-info` | `--mono-textarea-*` → `--ring`, `--muted-foreground`, `--success`, `--destructive`, `--warning`, `--info` | the accent per `color` — the focus ring, SunEditor's active state and links | | `--mono-rich-text-editor-text` / `-placeholder` / `-muted` | `--foreground` / `--muted-foreground` / `--muted-foreground` | inks | | `--mono-rich-text-editor-border` (or `-border-color` / `-rest-border`) | `--mono-textarea-*` → `--input` | the frame edge | | `--mono-rich-text-editor-bg` (or `-surface`) | `--mono-textarea-*` → `--mono-mode-surface` | the frame and editing surface | | `--mono-rich-text-editor-shadow` / `-radius` / `-border-width` | `--mono-shadow-xs` / `--mono-radius-md` / `--mono-border-width` | the frame | | `--mono-rich-text-editor-ring-color` / `-ring-width` / `-ring-alpha` | the `color` hue / `--mono-ring-width` / `--mono-ring-alpha` | the focus ring | | `--mono-rich-text-editor-outline-*` / `-filled-bg` / `-readonly-bg` / `-disabled-bg` | the textarea's | per-variant and per-state presets | | `--mono-rich-text-editor-font-size` / `-font-` / `-font-family` / `-icon-size` | `--mono-textarea-font-` → `--mono-text-sm` / inherit / per size | toolbar and content typography | | `--mono-rich-text-editor-toolbar-bg` | `--muted` 60% on the surface | the toolbar band | | `--mono-rich-text-editor-fullscreen-bg` | `--background` | the surface SunEditor paints when it goes full screen — its root lifts out of the frame (`position: fixed`, inline), so the editor takes the page surface itself, opaque, in the mode's colours; the mount carries `mono-fullscreen` while it lasts | | `--mono-rich-text-editor-popover-bg` / `-color` / `-radius` / `-shadow` | `--mono-select-dropdown-*` → `--popover` / `--popover-foreground` / `--mono-radius-md` / `--mono-shadow-md` | SunEditor's dropdowns and dialogs | | `--mono-rich-text-editor-tooltip-bg` / `-tooltip-color` / `-tooltip-radius` | `--foreground` / `--background` / `--mono-tooltip-radius` → `--mono-radius-md` | every tooltip — the toolbar's, and the dropdown items' (SunEditor gives those native `title`s, which no theme can paint; the element rewrites them into `data-tooltip` + `aria-label`) — as Basecoat's `[data-tooltip]`: bg-foreground text-background text-xs in the page font, at the style's corner (luma / rhea xl, maia 2xl, lyra / sera square) | | `--mono-rich-text-editor-min-height` | `10rem` | default editing-area height | | `--mono-rich-text-editor-label-*` / `-message-*` / `-gap` | the textarea's | label and message type, label / field / footer spacing | Deprecated and honoured as no-ops until 2.0: `--mono-rich-text-editor-focus-rgb`, `--mono-rich-text-editor-background`, `--mono-rich-text-editor-underline-glow` (the underline is sera's line now), and the `--theme-*` bridge — every default reads a Basecoat token. The editor DOM itself is light DOM in both builds (SunEditor's sheet is global and its modals live on ``, neither of which a shadow root can adopt), so any `--se-*` override you set on an ancestor reaches it directly. ## Server rendering The chrome (label, frame, messages) renders on the server; the editor is client state and is built on the first client update, exactly like `mono-chart` and `mono-date`. Under `@mono-lit/helper/ui/shadow/rich-text-editor` the frame is Declarative Shadow DOM and the editor mount is projected through ``. ## Optional dependency `suneditor` is declared as an optional `peerDependency` (`^3.3.3`) and loaded with dynamic imports — `suneditor`, `suneditor/plugins`, `suneditor/css/editor`, `suneditor/langs/*` — the first time an element renders. It is **externalized** from the build, so `dist/ui/rich-text-editor.js` stays small and every one of those specifiers resolves against your app's copy, split however your bundler splits them. ## Props ```ts import { RichTextEditorProps } from '@mono-lit/helper' ``` | Prop | Value | Default | Description | | --- | --- | --- | --- | | `modelValue` | `string` | — | Two-way bound HTML. `''` for an empty editor. | | `value` | `string` | — | HTML (kept in sync with modelValue). | | `name` | `string` | — | Form field name, carried on the events. | | `placeholder` | `string` | — | Placeholder shown while the editor is empty. | | `label` | `string` | — | Text label shown above the editor. | | `helperText` | `string` | — | Helper text shown below the editor. | | `validationState` | `'warning' \| 'default' \| 'valid' \| 'invalid'` | — | Validation state controlling the frame styling. | | `validationMessage` | `string` | — | Message shown for the current validation state. | | `errorMessage` | `string` | — | Error message that forces the invalid state. | | `successMessage` | `string` | — | Success message that forces the valid state. | | `disabled` | `string \| boolean` | — | Disables editing and the toolbar. | | `readonly` | `string \| boolean` | — | Read-only: content shows, cannot be edited. | | `required` | `string \| boolean` | — | Marks the label and lets `monoForm`'s `required` rule apply. | | `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'xxl'` | — | Field size — scales the toolbar, icons and editing font. | | `color` | `'primary' \| 'secondary' \| 'success' \| 'danger' \| 'warning' \| 'info' \| 'teal' \| 'purple' \| 'neutral' \| 'dark'` | — | Accent colour for focus and active toolbar state. | | `variant` | `'underlined' \| 'outlined' \| 'filled'` | — | Frame variant. | | `mode` | `'inline' \| 'classic' \| 'balloon' \| 'balloon-always' \| 'classic:bottom' \| 'inline:bottom'` | — | Toolbar placement: `classic` (default), `inline`, `balloon`, `balloon-always`, `classic:bottom`, `inline:bottom`. | | `toolbar` | `string \| RichTextEditorButtonList` | — | Toolbar preset (`basic` \| `standard` \| `full` \| `default`) or a SunEditor `buttonList` (bind with `.prop`, or JSON). | | `plugins` | `string \| any[] \| Record` | — | Plugins: `auto` (all built-ins, default), `none`, an array of plugin classes / names, or a name → class object. | | `language` | `string \| Record` | — | Language: a SunEditor language code (`ko`, `de`, `zh-CN`, …) loaded on demand, or a language object (`.prop`). | | `textDirection` | `'ltr' \| 'rtl'` | — | Text direction of the CONTENT (the host's own `dir` is left to the page). | | `charCounter` | `string \| boolean` | — | Show SunEditor's character counter in the status bar. | | `maxLength` | `string \| number` | — | Maximum number of characters (`charCounter_max`). | | `statusbar` | `string \| boolean` | — | Show the status bar (resize handle, path). Default `true`. | | `sticky` | `string \| number` | — | Sticky toolbar offset in px while the page scrolls (`toolbar_sticky`); unset = not sticky. | | `loadCss` | `string \| boolean` | — | Whether the element loads SunEditor's own stylesheet (`suneditor/css/editor`) the first time it renders. Default `true`. Set `false` when the app ships it. | | `options` | `RichTextEditorOptions` | — | Raw SunEditor `InitOptions`, merged LAST over everything the props produce. Bind with `.prop`. | | `cssClass` | `string \| RichTextEditorCssClass` | — | Class hooks per part. | | `width` | `string \| number` | — | Explicit sizing of the whole field. | | `height` | `string \| number` | — | Editing area height (SunEditor `height`, default `auto`). | | `minWidth` | `string \| number` | — | — | | `maxWidth` | `string \| number` | — | — | | `minHeight` | `string \| number` | — | Editing area minimum height (SunEditor `minHeight`, default `10rem`). | | `maxHeight` | `string \| number` | — | — | | `ariaLabel` | `string` | — | `aria-label` for the editing area (defaults to `label` / `placeholder`). | | `visible` | `boolean` | — | Whether the control is shown. Defaults to `true`. | | `visibleType` | `'none' \| 'invisible'` | — | How it hides when `visible` is `false`. Defaults to `'none'`. | --- URL: "https://mono-libs.netlify.app/addons/skeleton" LLMS_URL: "https://mono-libs.netlify.app/addons/skeleton.md" --- # Skeleton Loading skeletons for **every** mono component, measured from the component's real DOM — no hand-drawn placeholder per screen. Rendering comes from [phantom-ui](https://github.com/Aejkatappaja/phantom-ui), a structure-aware shimmer web component. There is nothing to call and nothing to import in your code: every `` element already has a `pending` prop. Install the peer and it is on. ::: tip Install `@aejkatappaja/phantom-ui` is an **optional peer**. It is not bundled; you install it: ```sh pnpm add @aejkatappaja/phantom-ui ``` - **Nuxt** (`@mono-lit/helper/nuxt`): nothing else. The module sees the package, loads it in a client plugin before the app mounts, and tells mono whether the app renders on the server. - **Vue**: one side-effect import in `main.ts`, before `app.mount()`: ```ts import '@aejkatappaja/phantom-ui' ``` mono activates the moment the `` element is defined — however it got there. Without the package, `pending` degrades to the [CSS-only block skeleton](#css-only-the-mono-pending-attribute) for manual values; the automatic behaviour stays off. ::: ## How it looks While pending, the element's content is rendered inside ``: phantom measures every leaf (text, inputs, buttons, images) and overlays an animated block at the same coordinates, so the skeleton has the component's exact shape. When pending ends, the overlay fades and the real content is already there. The colours and radius come from the **mono theme** — the muted surface for the blocks, a translucent foreground wash for the sweep, the theme radius for the frame — so the skeleton follows every flavor and `.dark` with no extra setup. ## `pending` | Form | Example | Meaning | | --- | --- | --- | | attribute | `` / `pending="true"` | on | | attribute | `pending="false"` | off | | attribute | absent, or `pending="auto"` | **auto** (see below) | | property | `:pending="busy"` | `true` / `false` follow the ref | | property | `:pending.prop="{ active: busy, count: 6 }"` | `active` plays the boolean's part (omit it for auto); the other keys are phantom options | ```vue … ``` A manual value always wins over the automatic one. ### Phantom options Any of phantom-ui's options can be given in the object form, in camelCase; mono applies them to the wrapper as attributes: `animation` (`shimmer` · `pulse` · `breathe` · `solid`), `mode` (`skeleton` · `overlay`), `shimmerDirection`, `shimmerColor`, `backgroundColor`, `duration`, `stagger`, `reveal`, `count`, `countGap`, `fallbackRadius`, `loadingLabel`, `pierceShadow`, `debug`. Resolution for each option: the element's object → the tag default → the global default → phantom's own. ## Automatic mode With no manual value, `pending` resolves itself: | | SSR app (Nuxt `ssr: true`) | SPA | | --- | --- | --- | | light `` | **pending on first connect**: data-driven elements until their first load, everything else until the page's data-driven elements are done | off | | shadow `` | off | off | How a light element releases itself: - **Render-driven** elements (button, input, card, chip, tabs, …) hold their skeleton **while the page is loading** — i.e. while any data-driven element on the page is still waiting for its first load — and release together with the last of them, so the page swaps in as one. On a page with no data-driven element they release in the same microtask as their first update, **before the first paint**: nothing flashes. Held elements are drawn by the **CSS block** (`mono-pending` on the host), not by phantom: they mount once and the release is a single attribute removal, so a page switch costs no extra renders. - **Who draws what**: phantom-ui measures the elements worth measuring — `mono-table-loading`'s placeholder rows, `mono-select` / `mono-tag-input` / `mono-chart` / `mono-dropdown-table`, and any element with a manual `pending`. The small table helpers (`mono-table-th`, `-search`, `-paging`, `-info`, …) use the CSS block (`static monoPendingDraw = 'css'`), because their measured skeleton would be one bar anyway. - **Data-driven** elements stay pending until their **first load** has finished: - every `mono-table-*` helper bound to a `monoDataGrid` controller (`hasLoaded`), - `mono-select` / `mono-tag-input` with a `dataSource` (static `items` release immediately), - `mono-chart` with a controller (or until raw `data` is set), - `mono-dropdown-table` (its grid's first load). A failed first load releases too (the error is the answer), and so does a controller that is **not loading one tick after mount** — a page that fetches on mount has started by then, so that grid is waiting for a user action. `maxWait` (default 15 s) is the safety net for a load that never reports. - `mono-modal` / `mono-drawer` are never automatic (an overlay has nothing to measure while closed); manual `pending` still works. The SSR flag comes from `@mono-lit/helper/nuxt` (`nuxt.options.ssr`). Outside Nuxt, a server-rendered app opts in with `createMonoUI({ skeleton: { ssr: true } })`; otherwise mono sniffs Nuxt's payload marker and falls back to `false`. ::: tip Why "SSR app"? The shell of an SSR app is already on screen when the page's light components mount, so the gap worth covering is each component's first data load — the table rows, the options of a select. In a SPA nothing was painted before the components, and the default stays off so existing apps do not change. ::: ## Tables A mono table is a native `` plus helper elements, so there is no single element to wrap. Instead: - `` has a **pending mode**: for the controller's first load it shows a block of placeholder rows that mirror the header (one bar per `
`, at the header's column widths, as many rows as the page size, 8 for "show all"). Afterwards it is the spinner overlay it always was, for every later reload. - `` shimmers its caption (the sort and filter buttons are left out). ```vue …
``` Tune the placeholder per app with a tag default — `createMonoUI({ 'mono-table-loading': { pending: { count: 6 } } })`. ## Defaults Global phantom defaults and per-tag defaults go through [`createMonoUI`](/ui/global-defaults), so they reach the light and the shadow build alike: ```ts createMonoUI({ // global phantom defaults (+ the ssr flag outside Nuxt); `false` disables the skeleton skeleton: { animation: 'pulse', duration: 1.2 }, // per tag — `pending` inside a tag's config is a DEFAULT, merged under the element's own 'mono-table-loading': { pending: { count: 6 } }, 'mono-chart': { pending: false }, // never automatic for charts }) ``` Nuxt: `mono: { helper: { skeleton: { animation: 'pulse' } } }` is the same thing; `skeleton: false` turns it off even when the package is installed. ## CSS-only: the `mono-pending` attribute The JS-free half of the feature. Put `mono-pending` on **any** element and it paints as a skeleton block — no script — so it works in server HTML, during the hydration window, and anywhere phantom-ui has nothing to measure yet. The paint is phantom-ui's own: the block is `--shimmer-bg`, the sweep is phantom's `.shimmer-block::after` gradient (`90deg`, bg → color → bg, `200%` track, linear, `--shimmer-duration`), `pulse` / `breathe` / `solid` / `overlay` and the reduced-motion fallback are its rules restated, and the three knobs resolve from the same mono tokens the wrapper hands to phantom. One block per element instead of one per measured leaf — otherwise indistinguishable, so the hand-off from the CSS block to phantom's measured blocks is a change of shape, never of colour or rhythm: ```html

Text that is not there yet

… dimmed, swept, still laid out …
``` | Attribute | Values | | --- | --- | | `mono-pending` | present (or anything but `"false"`) = on | | `mono-pending-animation` | `shimmer` (default) · `pulse` · `breathe` · `solid` | | `mono-pending-mode` | `skeleton` (default: one block, content hidden) · `overlay` (content stays, dimmed, swept) | The mono elements use the same vocabulary: while `pending` resolves true, the element sets `mono-pending` (+ `-animation` / `-mode` from the resolved phantom options) on its **host**, so it is a block the instant it exists. Once phantom-ui is defined the host also carries `mono-pending-covered` and the CSS paint steps aside for phantom's measured blocks. **Without phantom-ui installed, this is the skeleton**: a manual `pending` still shows the block; only the automatic page/data-aware behaviour needs the peer. Shadow hosts get the same rules (`:host([mono-pending])`), and a server-rendered `` is a skeleton before hydration through `:host([pending])`. Nuxt apps with client-only pages: render the page-slot placeholder out of `mono-pending` blocks (see the esw-ui host module `esw-routes.ts`, `pageSkeleton`) and the window between the first byte and the page mount is covered too. ## Theming Four hooks, read by the wrapper and inherited into phantom's overlay. Set them on `:root`, a theme class, or one element: ```css :root { --mono-skeleton-bg: var(--muted); /* block colour */ --mono-skeleton-color: rgb(0 0 0 / 8%); /* sweep colour */ --mono-skeleton-duration: 1.2s; --mono-skeleton-radius: var(--radius); /* the wrapper's frame */ } ``` The defaults resolve from the theme tokens (`--muted`, `--foreground`, `--radius`, …), which already change per flavor and under `.dark`. The legacy compile-time bars (`client-skeleton-*`, below) use the same hooks. ## Things to know - **The wrapper exists only while pending.** While `pending` is on, the element renders its content inside ``; the moment it releases, the DOM is exactly what it is without the skeleton (the component's root is the host's direct child again, slotted content re-homed, `controlMono*` refs still valid). The flip re-renders the inner DOM once — content was hidden under the shimmer anyway. - **Load the peer before mounting.** An element that rendered before `phantom-ui` was defined paints its first frame without a skeleton; `getMonoSkeletonStatus().createdBeforeActive` counts them and the console warns once. The Nuxt plugin and a `main.ts` import both run early enough. - **Nested pending.** A card still pending around a table still pending shows one skeleton — the outer one; the inner goes transparent. - **Interaction.** phantom marks the measured content `inert` while loading (and sets `aria-busy`), so a control inside a pending element cannot take focus until it releases. Popups mono portals to `` are not affected. - **Shadow builds.** `pending` works there manually; the wrapper is created the first time it turns on (never on the server-rendered hydrating pass). - **Status.** `getMonoSkeletonStatus()` → `{ active, ssr, createdBeforeActive, createdBeforeActiveTags }`; `resetMonoSkeleton()` clears defaults and counters (tests). ## Legacy: `client-skeleton-*` Before `pending`, `@mono-lit/helper/nuxt` could render a fixed set of gray bars as the `` fallback of a wrapped light element, driven by compile-time attributes (`client-skeleton-type`, `-bar`, `-count`, `-class`). That path still exists when `mono.helper.clientOnly` is on, and its bars now use the theme hooks above. Prefer `pending`: it needs no wrap, no per-element markup, and follows the component's real shape. --- URL: "https://mono-libs.netlify.app/addons/table-export" LLMS_URL: "https://mono-libs.netlify.app/addons/table-export.md" --- # Export Table Turn a `controlMonoTable` into a document with `table.export()`. The report layout is authored as a **Markdown file** — Handlebars fills it from your data, Jexl evaluates inline expressions, and the result renders to Markdown (for preview) or to a real `.xlsx` workbook with styles, formulas and merged cells. Data stays in TypeScript, presentation stays in the template. ```ts import { controlMonoTable } from '@mono-lit/helper' import md from './payroll.md?raw' const table = controlMonoTable(dataSource, { pageSize: 20 }) const result = await table.export({ md, fileName: 'payroll.xlsx', // extension picks the renderer + downloads data: { rows: await table.getData(), // every matching row, not just the page company: 'PT Mono Sejahtera', }, styles: { header: { bg: '#0F172A' } }, }) result.markdown // the rendered markdown, for a preview pane ``` The template sees **exactly** what you put in `data` — nothing is injected behind your back. A report that needs the grid's rows asks for them, visibly, at the call site. Working from a remote DataSource? Pass it straight in and skip the fetch — [From a DataSource](#from-a-datasource): ```ts data: { program: dataSource.value } // drained in chunks, filter/search/sort intact ``` The engine is loaded **on first use** via a dynamic import, and its heavy dependencies are resolved from your app rather than bundled — so importing `controlMonoTable` costs nothing if you never export, and `@mono-lit/helper` stays importable on the server. ::: tip Install Both are **optional peers** — they aren't bundled, so nothing pays for them unless you export something. ```sh pnpm add handlebars # any report pnpm add handlebars exceljs # …and .xlsx output ``` `handlebars` is the template engine, so **every** report needs it — Markdown included. `exceljs` is only reached when an `.xlsx` is actually rendered. If either is missing, the export throws an error naming the package and the command rather than failing obscurely. ::: Going the other way — reading an edited sheet back into the grid — is [**Import Table**](/addons/table-import). ## Basic The grid below is paged **3 rows per page**, but the report covers all six rows — `table.getData()` reads the full result set without disturbing the visible page. Vue demo source: ```vue ``` ## Grouped `table.buildGroups()` returns the full group tree — every group with all its rows, not just the group page on screen — using the `group` field you already configured. Write the header and delimiter row once, then loop: everything lands in one worksheet table. Vue demo source: ```vue ``` ## Styled Named styles, per-row conditional styling, merged cells and column widths. Vue demo source: ```vue ``` ## Rowspan and colspan The layout auditors ask for and Markdown can't spell: a **two-level header**, three **nested vertical bands** (year ⊃ half ⊃ quarter), a subtotal row whose merge starts in the middle of the row because the bands still own the columns to its left, and a grand total spanning four columns — plus an L-shaped block that merges across *and* down at once. Every one of them is a {{merge}} on the cell where the span begins; the cells it swallows are written as empty (`| |`), exactly like a `rowspan` leaves no `
` behind. The spans themselves are arithmetic done in TypeScript — a half owns its six months **plus** its own subtotal row — so the template stays a layout, not a calculator. Vue demo source: ```vue ``` ## Post-processing the workbook Markdown can't describe an auto-filter, a colour scale or a second worksheet. `onWorkbook` hands you the populated ExcelJS workbook **before** anything is written, so you can add whatever the template can't — and it still works on the auto-downloading path. Vue demo source: ```vue ``` ## Template context The context is **exactly** your `data` object — the grid injects nothing, and no names are reserved. Anything you put there is addressable at the top level: an array loops, an object drills in with dots. ```ts data: { rows: await table.getData(), company: { legal: { name: 'PT Mono' }, offices: ['JKT', 'SBY'] }, period: '2026-07', } ``` ```hbs # Payroll {{period}} — {{company.legal.name}} {{#each company.offices}}{{this}} {{/each}} {{#each rows}}| {{Name}} | {{currency Salary}} | {{/each}} ``` A template that references something you didn't pass simply renders nothing — it never triggers a fetch. ## Getting the grid's data The grid is the only thing that knows how to read your **whole** result set: the visible page is a slice, and the rest lives behind paging you'd otherwise have to replay by hand. Two methods hand it over, and you pass the result into `data`. ### `table.getData()` Every row matching the current search, column filters and sort — flattened, in sort order, regardless of paging. ```ts const rows = await table.getData() await table.export({ md, data: { rows } }) ``` It leaves the grid alone. A remote source is read straight off its store, so the bound DataSource's paging is never touched; an array source is read with paging temporarily off and restored afterwards. When the promise resolves, `items` and `pageIndex` are exactly what they were. ::: warning Large tables This is a full read — remote sources fetch in `chunkSize` batches (default 100). That's the point of an export, but it isn't free. Call it once per report and reuse the array rather than calling it per section. ::: ### `table.buildGroups(rows?)` The full group tree, using the group field(s) already configured on `controlMonoTable` — you don't repeat them. Pass rows you already have, or omit them and it calls `getData()` itself. Returns `[]` when the grid isn't grouped. ```ts const rows = await table.getData() // fetch once… const groups = await table.buildGroups(rows) // …and reuse await table.export({ md, data: { rows, groups } }) ``` Each node has `key`, `items`, `count`, `level` and `path`: ```hbs {{#each groups}} | **{{key}}** ({{count}}) | {{currency (sum items "Total")}} | {{#each items}} | {{Product}} | {{currency Total}} | {{/each}} {{/each}} ``` To group by something other than the grid's own fields, use the exported helper directly: ```ts import { buildGroups } from '@mono-lit/helper' const byProduct = buildGroups(rows, ['Product']) ``` ### Columns and totals No new API needed — these are already on the controller: ```ts data: { columns: table.columns(), // registered cells total: table.totalCount, } ``` `columns()` reads the registered header cells, so it's empty unless your headers are `` rather than plain ``. ## From a DataSource You don't have to drain a source yourself. Put a **devextreme DataSource** (or a bare store) straight into `data` and the engine resolves it to rows before rendering: ```ts await table.export({ md, fileName: 'program-transfer.xlsx', data: { program: dataSourceProgramTransfer.value }, // ← not an array }) ``` ```hbs {{#each program}} | {{OrderID}} | {{ShipName}} | {{currency Freight}} | {{/each}} ``` Vue demo source: ```vue ``` ### What gets honoured The source is read through its own store, so the query it already carries comes along: its **filter**, its **sort**, and — importantly — its active **search**. Type something into `` and the export narrows with it. The grid is never disturbed: its page, page index and scroll position are the same after the export as before. ### Chunking A remote source can't report its size up front, so the read walks in fixed chunks until one comes back short — **100 rows per request** by default. The 1073-row table above takes 11 requests. Change it with `chunkSize`: ```ts await table.export({ md, data: { program: dataSource }, chunkSize: 500 }) ``` ### Two details **Top level only.** Values are resolved at the top level of `data`. A source nested deeper (`data: { report: { rows: ds } }`) is left alone — resolve it yourself with `await table.getData()` and pass the array. **Keep the source raw.** Use `shallowRef`, not `ref` — a deep reactive proxy around a DataSource can break devextreme's internals. A `ref` that slips through is unwrapped rather than silently rendering nothing, but raw is the right habit: ```ts const dataSource = shallowRef(null) // … data: { program: dataSource.value } ``` ### Server-grouped sources A single `group` field plus a remote DataSource puts the grid into server-group mode — group keys and counts come from one groupby query, and rows load per group as they're shown. `getData()` and `buildGroups()` still return everything: Vue demo source: ```vue ``` ## Template syntax ### Loops and conditions Standard Handlebars, including `@index` / `@first` / `@last`: ```hbs {{#each rows}} | {{@index}} | {{Name}} | {{Salary}} | {{/each}} {{#if Paid}}Settled{{else}}Outstanding{{/if}} {{#unless Active}}Archived{{/unless}} ``` ### Inline expressions {{= … }} evaluates a [Jexl](https://github.com/TomFrost/Jexl) expression — no `eval()`, so a template can never reach globals, the DOM or the network. ```hbs {{= Salary + Bonus }} {{= Qty * Price }} {{= Amount > 1000000 ? "large" : "standard" }} ``` Jexl's grammar is not quite JavaScript, but `===` and `!==` are rewritten to `==` / `!=` for you. ### Escaping Values interpolated into a table row are escaped automatically, so a name like `Alice | Smith` or a product called `*special*` can't break the table or turn italic. Use a triple-stache to opt out and inject raw Markdown: ```hbs | {{Name}} | | {{{RawMarkdown}}} | ``` ## Helpers ### Formatting These emit readable text **and** the raw value, so Markdown shows `Rp 1.000.000` while the spreadsheet stores `1000000` — summable, sortable, and usable in a formula. ```hbs {{currency Salary}} {{currency Salary code="USD"}} {{number Qty}} {{number Rate decimals=2}} {{date JoinedAt}} {{percent 0.155}} ``` Set the locale and currency once via the `formatting` option: ```ts await table.export({ md, formatting: { locale: 'id-ID', currency: 'IDR', dateFormat: 'dd mmm yyyy' }, }) ``` ::: warning Prose vs. cell A formatting helper only becomes a real number when it is the cell's **entire** content. Rate {{percent 0.155}} this quarter stays text — as it should. ::: ### Aggregation ```hbs {{sum rows "Salary"}} {{avg rows "Salary"}} {{max rows "Salary"}} {{min rows "Salary"}} {{count rows}} ``` They return plain numbers, so they nest: ```hbs {{currency (sum rows "Salary")}} ``` ### Logic `eq` · `ne` · `gt` · `gte` · `lt` · `lte` · `and` · `or` · `not` · `default` ```hbs {{#if (gt Salary 1000)}}Senior{{/if}} {{default Nickname "—"}} ``` ### Your own ```ts await table.export({ md, helpers: { add: (a: number, b: number) => a + b }, partials: { footer: '_Generated automatically._' }, }) ``` ```hbs {{add Salary Bonus}} {{> footer}} ``` ## Named styles Templates reference styles by name; what a name *means* is configured in TypeScript. That keeps templates renderer-independent — the same report can grow a PDF or HTML renderer later without being rewritten. ```hbs | {{style "header"}}Employee | {{style "currency"}}{{currency Salary}} | | {{rowStyle "total"}}**Total** | {{currency (sum rows "Salary")}} | ``` {{style}} paints the cell it sits in; {{rowStyle}} paints the whole row. Built-ins: `title`, `h1`–`h6`, `header`, `groupHeader`, `total`, `currency`, `number`, `date`, `note`, `danger`, `success`, `muted`. Override per key — everything you don't name is kept: ```ts styles: { header: { bg: '#0F172A', color: '#FFFFFF' }, danger: { color: '#991B1B', bg: '#FEE2E2', align: 'center' }, } ``` A style accepts `bold`, `italic`, `underline`, `size`, `font`, `color`, `bg`, `align`, `valign`, `border` (`'all' | 'bottom' | 'top' | 'outline' | 'none'`), `borderColor`, `numFmt`, `wrap`, `indent` and `height`. ## Excel-only directives These render nothing in Markdown and take effect only in the workbook. ### Formulas A template can't know which row it will land on, so formulas use placeholders that are resolved as each cell is written: ```hbs {{formula "SUM({col:Salary}{row}:{col:Bonus}{row})"}} {{formula "SUM(E{firstRow}:E{prevRow})"}} ``` | Placeholder | Resolves to | |-------------|-------------| | `{row}` | The row being written. | | `{prevRow}` | The row above it. | | `{firstRow}` / `{lastRow}` | The current table's first / last body row. | | `{col:Caption}` | The column letter of that header caption. | ::: warning Totals inside the table `{lastRow}` counts **every** body row — including a total row written as part of the same Markdown table. Sum `{firstRow}:{prevRow}` there, or Excel reports a circular reference. ::: ### Merged cells {{merge}} spans the cell it sits in: `cols` to the right (colspan), `rows` downwards (rowspan), or both at once for a rectangle. ```hbs | {{merge cols=3}}**Grand total** | | | {{currency (sum rows "Amount")}} | | {{merge rows=3}}Q1 | Jan | 48,200 | | | Feb | 51,600 | | | Mar | 55,100 | | {{merge cols=2 rows=3}}QC seal | | Shift | Inspector | ``` Four rules cover everything: - **Leave the swallowed cells in place.** A merge only *covers* cells; the pipes that hold them still have to be written, empty. The header row fixes how many columns the table has, so a body row that drops a `|` shifts every column after it. - **The span count is yours to compute.** A vertical band that has to cover its own subtotal row is `rows=7`, not `rows=6` — work it out in TypeScript and pass it in: {{merge rows=halfSpan}}. - **The header row merges too**, which is how a two-level header is written: {{merge cols=2}} groups the columns on the header row, and the level beneath it is an ordinary body row wearing {{rowStyle "header"}}. - **Overlaps are skipped, not fatal.** Excel refuses two merges over the same cell, so a collision is dropped with a console warning and the rest of the report still renders. Nested bands never collide as long as an inner span stays inside its outer one. Vertically merged cells look wrong with the default top alignment — give them a style with `valign: 'middle'`. Headings and paragraphs already span the full sheet width — don't add {{merge}} to those. See [Rowspan and colspan](#rowspan-and-colspan) for all of it in one report. ### Images ```hbs {{image company.logo width=120 height=40}} ``` Accepts a data URL, a bare base64 string, or an `http(s)` URL (fetched at render time). A logo that fails to load warns and is skipped rather than failing the report. ### Links A Markdown link becomes a real hyperlink in the worksheet: ```md | [{{name}}]({{url}}) | {{email}} | ``` A number keeps its type — it is written through Excel's own `HYPERLINK()` so it still sums and still formats. A formula cell is left alone: there is no way to add a link without replacing the formula. ### Detail sheets `detail` gives every row of the master sheet its own worksheet, reached from a link. This is the master-and-lines shape: a document list where each document number opens the lines behind it. The master template links with `sheet:` — the document's **key**, never a sheet name. Names are sanitised for Excel and de-duplicated against the workbook at write time, so the final name is only known once every sheet exists. In `.md` output the same line is an ordinary Markdown link, so one template serves both. ```ts await table.export({ md: ` | Doc | Vendor | Total | | --- | ------ | ----- | {{#each rows}} | [{{no}}](sheet:{{no}}) | {{vendor}} | {{currency total}} | {{/each}} `, data: { rows: await table.getData() }, sheetName: 'Top-ups', fileName: 'top-ups.xlsx', detail: { key: 'no', field: 'items', md: ` | SKU | Qty | | --- | --- | {{#each rows}} | {{sku}} | {{qty}} | {{/each}} `, }, }) ``` The detail template's context is `{ row, rows }` — the master row, and that document's rows. #### Lines already on the row `field` is the no-I/O case: the list endpoint embedded its own lines, so the export costs exactly what a single-sheet one costs. Vue demo source: ```vue ``` #### Where the rows come from `field` reads an array already nested on the master row. When the list endpoint does not embed its lines, `load` fetches them instead. **`load` is batched.** It is handed a chunk of master rows and their keys, and returns the rows for all of them in one array — so a backend that can filter on a set of keys serves a whole chunk per request: ```ts const { filterOrIn } = useMonoUtility() detail: { key: 'OrderID', md: detailTemplate, load: ({ keys, key }) => api.lines({ filter: filterOrIn(key, keys) }), loadChunk: 100, // master keys per request (default) concurrency: 4, // chunks in flight at once (default) } ``` [`filterOrIn`](../repo/useful-utils) builds the key filter, so `load` never hand-rolls an `or` chain. Its third argument returns the compact `Field in (…)` instead — useful when you render the filter yourself with `dxFilterToString`, but **not** something a DevExtreme store accepts: `store().load()` throws `E4003 — Unknown filter operation is used: in`, and passing the rendered string back in as `filter` does not help either — DevExtreme appends ` eq true` to it. See [Useful Utils](../repo/useful-utils) for the full matrix. A 1000-row master therefore costs **10 requests, not 1000**. Lower `loadChunk` when the backend caps how many values a filter may carry, or when the generated URL would outgrow its limit; `concurrency` then decides how many of those chunks are in flight together. The response comes back flat, so the exporter buckets it by **`groupBy`** to work out which rows belong to which document. That defaults to `key`, which is right whenever both sides name the foreign key the same way — pass a field name or a function when they differ. Rows matching no master key are ignored. A rejection is contained: every document in that chunk is left without a sheet and without a link, and the rest of the workbook is still produced. For an endpoint that genuinely only serves one document at a time, set `loadChunk: 1` and read `data[0]`: ```ts load: ({ data }) => api.lines(data[0].OrderID) ``` Nothing is fetched until the export runs. The demo below lists 100 Northwind orders — whose `/Orders` feed carries no line items at all — and fetches their 269 line rows in **4 requests**, producing 101 worksheets. It also shows why `loadChunk` exists rather than being a constant. The keys go out as an `or` chain, so 100 of them make a 3138-character URL and that service answers **404** — IIS refusing a query string past ~2048. Measured against the live endpoint, 60 keys still return 200 and 75 do not, so the demo uses 25. The ceiling is the backend's, not the exporter's — a backend that takes `in (…)` would fit far more keys per request. Vue demo source: ```vue ``` #### What did not get a sheet A document with no rows gets no sheet — an empty worksheet behind a link is worse than no link — and its master cell is left as plain text rather than a link pointing nowhere. Both cases are reported: ```ts const result = await table.export({ /* … */ }) result.sheets // ['Top-ups', 'Detail TU 2026 001', …] result.skipped // [{ key: 'TU/2026/003', reason: 'empty' }] // reason is 'empty' or 'failed'; 'failed' carries `error` ``` #### Sheet names Derived from `key`, or from `sheetName(row)`. Either way the result is made legal before use, because Excel refuses to open a file otherwise: at most 31 characters, none of `* ? : \ / [ ]`, never empty, no leading or trailing apostrophe, and unique. A document number like `TU/2026/001` breaks three of those at once, so it becomes `TU 2026 001`. Note that spaces and hyphens are **kept** — stripping them would turn `TP-001` into `TP 001` and the tab would no longer match what the master sheet shows. ## Output The extension of `fileName` picks the renderer and triggers a download: ```ts await table.export({ md, fileName: 'payroll.xlsx' }) // Excel, downloads await table.export({ md, fileName: 'payroll.md' }) // Markdown, downloads await table.export({ md }) // nothing written ``` Suppress the download with `download: false`, or force the renderer with `format: 'xlsx' | 'md'`. The returned object always carries the rendered Markdown, and can produce the other formats on demand: | Member | Description | |--------|-------------| | `markdown` | Rendered Markdown, directives stripped — ready for a preview pane. | | `format` | The resolved format, or `null`. | | `fileName` | The resolved file name, or `null`. | | `toBlob(format?)` | Render to a `Blob`. | | `toBuffer(format?)` | Render to an `ArrayBuffer` — for uploading instead of downloading. | | `workbook()` | The populated ExcelJS workbook, for last-mile tweaks. | | `download(fileName?)` | Trigger a browser download. | ```ts // Upload instead of downloading const result = await table.export({ md, format: 'xlsx' }) await fetch('/api/reports', { method: 'POST', body: await result.toBlob() }) ``` ## Reaching ExcelJS `result.workbook()` and the `onWorkbook` option both hand you the real `ExcelJS.Workbook` — the full API: extra worksheets, `autoFilter`, conditional formatting, cell notes, sheet protection, print setup, data validation. The workbook is built once and memoised, so anything you change is present in whatever `download()` / `toBlob()` / `toBuffer()` write afterwards. ### `onWorkbook` — preferred Runs after the template renders but **before** any bytes are produced, so it applies even when `fileName` triggers an automatic download. It fires exactly once, however many times the workbook is requested. ```ts await table.export({ md, fileName: 'inventory.xlsx', data: { rows }, onWorkbook: (wb) => { const ws = wb.getWorksheet('Sheet1') ws.autoFilter = 'A2:D2' ws.getCell('A1').note = 'Generated from the live grid' ws.pageSetup.orientation = 'landscape' const raw = wb.addWorksheet('Raw data') raw.columns = [{ header: 'SKU', key: 'Sku', width: 14 }] raw.addRows(rows) }, }) ``` It may be `async` — the write waits for it. ### `result.workbook()` — imperative Useful when the decision to tweak comes later, or you want the workbook for something other than downloading. ```ts // Note: no `fileName`, so nothing is written yet const result = await table.export({ md, format: 'xlsx', data: { rows } }) const wb = await result.workbook() wb.getWorksheet('Sheet1').getCell('A1').note = 'Draft' await result.download('inventory.xlsx') ``` ::: warning `fileName` writes immediately `table.export({ md, fileName: 'x.xlsx' })` has already downloaded by the time it returns, so `workbook()` mutations afterwards land in nothing. Either drop `fileName` and pass it to `download()` instead, add `download: false`, or use `onWorkbook`. ::: ### Row numbers The hook receives a finished sheet, so you need to know where the table landed. Each Markdown block is one row, in order — a `# Heading` is row 1, the table header row 2, the body from row 3. With `n` rows: ```ts const firstRow = 3 const lastRow = 2 + rows.length ``` ## Options | Option | Type | Description | |--------|------|-------------| | `md` | `string` | The Markdown template source. | | `data` | `object` | The **entire** template context. A DataSource at the top level is drained to its rows. | | `chunkSize` | `number` | Rows per request when draining a DataSource (default `100`). | | `fileName` | `string` | Output name; its extension selects the renderer. | | `format` | `'md' \| 'xlsx'` | Force the renderer. | | `download` | `boolean` | Auto-download when `fileName` is set (default `true`). | | `styles` | `Record` | Named-style overrides. | | `columns` | `Array<{ width?, numFmt? }>` | Per-column overrides, left to right. | | `sheetName` | `string` | Worksheet name (default `'Sheet1'`). | | `detail` | `MonoExportDetail` | A sheet per master row, linked from it. See [Detail sheets](#detail-sheets). | | `freezeRows` | `number` | Freeze the first N rows in Excel. | | `onWorkbook` | `(wb) => void \| Promise` | Post-process the ExcelJS workbook before it's written. | | `formatting` | `MonoExportFormatOptions` | Locale, currency and number/date formats. | | `helpers` | `Record` | Extra Handlebars helpers. | | `partials` | `Record` | Partials available as {{> name}}. | ## Without a grid The engine stands alone — useful for a report that isn't backed by a table: ```ts import { exportTable } from '@mono-lit/helper/export' const report = await exportTable({ md, data: { employees, company }, fileName: 'payroll.xlsx', }) ``` `table.export()` is a thin wrapper over exactly this — same options, same result object. It exists so the engine is discoverable from the controller and loaded lazily; the grid contributes data only through `getData()` / `buildGroups()`, which you call yourself. --- URL: "https://mono-libs.netlify.app/addons/table-import" LLMS_URL: "https://mono-libs.netlify.app/addons/table-import.md" --- # Import Table Read an edited spreadsheet back into the grid with `table.import()`. Each sheet row is paired with a table row by composite key, the declared columns are parsed and compared, and the differences land in the grid's **staged changes** — shown optimistically, reviewable, and written to your data only when you call `saveChanges()`. Nothing is touched until then, and `discardChanges()` undoes the whole import. ```ts const result = await table.import({ type: 'excel', // or 'copy-paste' data: file, // File/Blob/ArrayBuffer · or pasted text match: [ // how a sheet row finds its table row { excel: 'CH.', field: 'Channel' }, { excel: 'Brand', field: 'Brand' }, { excel: 'Activity', field: 'Activity' }, ], columns: [ // what the sheet may write { excel: 'JAN', field: 'Jan', type: 'number' }, ], }) if (!result.ok) warn(`${result.unmatched} rows didn't match`) ``` The parser is loaded on first use, so importing costs nothing until you do it. ::: tip Install for `.xlsx` Reading a real workbook uses the optional `exceljs` peer — the same one [Export Table](/addons/table-export) needs. Pasted text needs nothing. ```sh pnpm add exceljs ``` ::: ## Round trip Export the grid, edit it in Excel, upload it back. Imported cells are tinted (blue) separately from hand edits (amber), and stay unsaved until you press Save. Vue demo source: ```vue ``` ## Input One `data` field carries the payload; `type` says how to read it, and is inferred when you leave it out — a string is treated as a paste, anything else as a workbook. | `type` | `data` | Notes | |--------|--------|-------| | `'excel'` | `File \| Blob \| ArrayBuffer \| Uint8Array` | Pick a worksheet with `sheet` (name or index; default: the first). | | `'copy-paste'` | `string` | Tab / `;` / `,` auto-detected; quoted cells (`"Acme, Inc."`) handled. | If the table starts below a title block, say where the headers are: ```ts await table.import({ data: file, headerRow: 3, match, columns }) ``` ## Matching `match` declares a composite key. Rather than demanding all of it, **every subset is tried, strongest first** — so a sheet whose Channel column got cleared still matches on Brand + Activity, and a column dropped entirely doesn't break the import. A subset that resolves to **several** rows is reported as *ambiguous* rather than guessed at — silently picking the first would corrupt data in a way nobody notices for weeks. | Outcome | Meaning | Where it lands | |---------|---------|----------------| | hit | Exactly one table row | staged | | ambiguous | The strongest usable key hit several rows | `ambiguousRows` | | miss | No row at all | `unmatchedRows` | Raise `minKeyFields` (default `2`) when a small subset would produce false matches. Headers are compared leniently — case, spacing, `.` and `_` are ignored, so `No. Dok`, `NO_DOK` and `no dok` are the same column. Accept other spellings with `headerAliases`: ```ts await table.import({ data, match, columns, headerAliases: { JAN: ['Januari', 'Jan-26'] }, minKeyFields: 3, }) ``` Key comparison is trimmed, whitespace-collapsed and case-insensitive. Override it per leg with `transform`, or globally with `normalizeKey`: ```ts match: [ { excel: 'Ship to', field: 'ShipName', transform: (v) => String(v).replace(/^_/, '') }, ] ``` ## Columns `columns` says what may be written and how to read it. **Only cells that differ are staged**, so re-importing an unchanged sheet is a no-op. ```ts columns: [ { excel: 'JAN', field: 'Jan', type: 'number' }, { excel: 'Due', field: 'DueAt', type: 'date' }, { excel: 'Aktif', field: 'Active', type: 'boolean' }, { excel: 'Note', field: 'Note' }, // 'string' (default) ] ``` `transform` post-processes a parsed value; returning `undefined` skips the cell: ```ts { excel: 'JAN', field: 'Jan', type: 'number', transform: (v) => Math.trunc(Number(v)) } ``` ### Numbers The same amount is written `1.234.567,89` in one locale and `1,234,567.89` in another, and guessing wrong is silent. Separators are inferred from each value: with both `.` and `,` present the **rightmost** is the decimal point; with only one, exactly three trailing digits means thousands grouping. So `1.234` is one thousand two hundred, while `1.23` is one and a bit. Force it when you know the source: `numberFormat: 'us' | 'eu'`. Accounting negatives (`(1.500)`) are understood; set `allowNegative: false` to reject them. A cell that isn't a number is left alone rather than staged as `NaN`. ## Excluding rows Real sheets carry totals, blank separators and locked rows. | Option | Excludes | Reported in | |--------|----------|-------------| | `rowFilter(sheetRow, i)` | Sheet rows — summary/total lines | not counted at all | | `targetFilter(row)` | Table rows from being matchable | as `unmatched` | | `readOnly(row, field)` | A specific write; return a string for the reason | `skippedRows` | ```ts await table.import({ data, match, columns, rowFilter: (r) => String(r['Activity']).toLowerCase() !== 'total:', targetFilter: (row) => row._Type === 'Child', readOnly: (row) => isHeld(row) && 'Budget is on hold', }) ``` `readOnly` returning a **string** is the useful form: the reason reaches `result.skippedRows`, so you can tell the user exactly which rows were ignored and why instead of silently losing them. ## Reviewing before committing Imported values are ordinary staged changes, so the existing buffer API applies: ```ts table.pendingCount() // rows with pending edits table.changes() // the patches, for your own bulk request await table.saveChanges() // commit table.discardChanges() // undo the import ``` Imported cells are also marked separately, so they can be tinted apart from hand edits: ```ts table.isCellImported(rowKey, field) // came from a sheet table.isRowImported(rowKey) table.isCellDirty(rowKey, field) // any pending edit ``` A cell stops counting as imported once it's edited by hand, and all marks clear on save or discard. ### Validate first `apply: 'none'` computes everything and stages nothing, handing back `result.merged` — the target rows with patches applied — so you can check the outcome and abandon the import if it's wrong: ```ts const result = await table.import({ data, match, columns, apply: 'none' }) if (result.merged.some((r) => r.Jan < 0)) { if (!(await confirm('Some values are negative. Import anyway?'))) return } result.apply() // stage it now ``` ## Result | Member | Description | |--------|-------------| | `ok` | Every considered sheet row matched exactly one table row. | | `total` / `matched` / `unmatched` / `ambiguous` | Row counts (`total` is after `rowFilter`). | | `changed` | Matched rows that actually differed. | | `skipped` | Cells refused by `targetFilter` / `readOnly`. | | `headers` | Headers found in the sheet. | | `missingHeaders` | Declared in `match`/`columns` but absent — the rest still imports. | | `changes` | `{ rowKey, key, row, patch }` per changed row. | | `unmatchedRows` / `ambiguousRows` / `skippedRows` | The rejects, each with its sheet row and a reason. | | `merged` | Target rows with patches applied — for validation. | | `apply()` | Stage now (used with `apply: 'none'`). | ## Options | Option | Type | Description | |--------|------|-------------| | `data` | `File \| Blob \| ArrayBuffer \| Uint8Array \| string` | The sheet, or text pasted from one. | | `type` | `'excel' \| 'copy-paste'` | Inferred from `data` when omitted. | | `sheet` | `string \| number` | Worksheet name or index (`excel` only). | | `headerRow` | `number` | 1-based header row (default `1`). | | `delimiter` | `string` | Paste only; auto-detected otherwise. | | `match` | `Array<{ excel, field, transform? }>` | **Required.** Composite key. | | `columns` | `Array<{ excel, field, type?, transform? }>` | **Required.** What may be written. | | `headerAliases` | `Record` | Alternate header spellings. | | `minKeyFields` | `number` | Smallest usable key subset (default `2`). | | `normalizeKey` | `(v) => string` | Override key normalisation. | | `target` | `T[]` | Rows to match against (default: `table.getData()` — every row). | | `rowFilter` | `(sheetRow, i) => boolean` | Skip sheet rows. | | `targetFilter` | `(row) => boolean` | Restrict matchable table rows. | | `readOnly` | `(row, field) => boolean \| string` | Veto a write, with a reason. | | `apply` | `'stage' \| 'none'` | Default `'stage'`. | | `numberFormat` | `'auto' \| 'us' \| 'eu'` | Default `'auto'`. | | `allowNegative` | `boolean` | Default `true`. | | `chunkSize` / `yieldMs` | `number` | Batching so a big paste can't freeze the page (`300` / `0`). | | `onProgress` | `(done, total) => void` | | | `debug` | `boolean` | Log the matching decisions. | ## Without a grid The engine is available on its own for a non-table import — you supply the bridge that says how rows are keyed and where changes go: ```ts import { importTable } from '@mono-lit/helper/import' ``` `table.import()` is this, wired to the grid's staged buffer. --- URL: "https://mono-libs.netlify.app/addons/tooltip" LLMS_URL: "https://mono-libs.netlify.app/addons/tooltip.md" --- # Tooltip Tooltips for **any** element: plain HTML, light `` components, and shadow `` hosts. Positioning comes from [Floating UI](https://floating-ui.com/). Turn the addon on once, then write the attribute. No component needs any tooltip code: ```ts // main.ts import { createMonoTooltip } from '@mono-lit/helper' app.use(createMonoTooltip()) ``` ```vue Save ``` For tooltips set up from code (content computed per element, a ref, manual show/hide), use [`controlMonoTooltip`](#controller). ::: tip Install `@floating-ui/dom` and `@floating-ui/core` are **optional peers**. They aren't bundled, and you install them yourself: ```sh pnpm add @floating-ui/dom @floating-ui/core ``` They load the first time a tooltip opens, not on import, so registering tooltips costs nothing until someone hovers. If the packages are missing, the tooltip doesn't open, and the console names the packages and the install command. ::: ## Basic Vue demo source: ```vue ``` `mono-tooltip-content` holds the text; `mono-tooltip-message` is an alias for it. Bind it like any attribute (`:mono-tooltip-content="msg"`). If the value changes while the tooltip is open, the tooltip updates in place. An empty value shows nothing, so `:mono-tooltip-content="''"` turns one element's tooltip off. The attributes only work after `createMonoTooltip()` has run: in `main.ts`, or through `mono.helper.tooltip` in Nuxt (see [Global options](#global-options)). Nothing is looked up in advance. One shared set of `document` listeners checks each hover, focus or click against the attribute, so elements added later by `v-if`, `v-for` or a route change work automatically. ## Attribute options Vue demo source: ```vue ``` Every option has a `mono-tooltip-