@quario/viewer
v0.12.0
Published
Tiny, embeddable report viewer for quario. A custom element that pages on screen and exports what you hand it.
Maintainers
Readme
@quario/viewer
The embeddable report viewer for quario, as a custom
element. Write <quario-viewer>, assign it a compiled report, the export targets you want, and
data. It lays the report out on pages — the same pages the PDF target writes — paints them on a
white sheet in its shadow root, and offers export downloads for the targets you passed.
The viewer is not a render target and compiles nothing. The sheet is @quario/layout's display
list, painted page by page. You pass the export targets you want, and the viewer wires a button
to each ("pdf", "xlsx", "csv", "docx"). The bar always carries the zoom control. With no
exportable target it carries nothing else.
Contents
- Install
- Quick start
- Properties
- Events and
renderComplete - Lifecycle
- Using with React, Vue and Svelte
- Zoom
- Outline
- Links
- What the preview is
- Errors
- Color scheme
- Documentation
- License
Install
npm install quario @quario/viewerThe engine is a peer. The viewer itself depends on no target package. Install the targets you want
to export with (@quario/pdf, @quario/xlsx, @quario/csv, @quario/docx) and pass them in. Its
own runtime dependencies are Lit (lit + @lit/task) and @quario/layout, the
paged layout it paints, plain ESM like everything else here. ESM-only, and
browser-only by nature: the element needs a DOM. CSP-safe like the rest of quario. No
string-to-code paths, chrome styled through constructed stylesheets, so your style-src never
sees a style tag.
Quick start
import { csv } from "@quario/csv";
import { docx } from "@quario/docx";
import { pdf } from "@quario/pdf";
import { xlsx } from "@quario/xlsx";
import { quario } from "quario";
import "@quario/viewer/register";
const view = document.querySelector("quario-viewer");
view.report = quario().report(schema, funcs);
view.targets = [pdf(), xlsx(), csv(), docx()];
view.data = data;
view.filename = "sales";with <quario-viewer></quario-viewer> in your markup, sized by your own CSS. The element is
display: block. Its bar carries the zoom control and one export trigger. The width the bar needs
therefore stays the same as you pass more export targets. The bar needs 82px. It needs
50px when you pass no export target. A report that carries an outline adds the outline
toggle, which is one more 30px control beside a 2px gap. Give the element more width than the bar
needs. A narrower element pushes the zoom control off the left edge of the bar. The bar keeps the
control on one line and adds no scrollbar, so the reader sees no sign that the control is gone. Importing
@quario/viewer defines nothing: the main entry exports the QuarioViewer class and is
side-effect-free, and @quario/viewer/register performs the one-line
customElements.define("quario-viewer", QuarioViewer). A host that wants its own tag imports the
class and defines it itself. The repository ships this wiring as a runnable page at
example/viewer.js.
React 19, Vue and Svelte set properties on custom elements directly, so there is no wrapper package to install. See Using with React, Vue and Svelte.
Properties
| Property | Takes | Default |
| ------------- | ----------------------------------------------- | ----------- |
| report | A compiled report from a quario instance | - |
| targets | Export targets, possibly none | - |
| data | The render document | undefined |
| zoom | "fit" or a percentage between 25 and 200 | "fit" |
| page | Page geometry, as passed to pdf({ page }) | A4, 54pt |
| fonts | The font mapping, as passed to pdf({ fonts }) | none |
| filename | Export download name, without extension | "report" |
| colorScheme | "light", "dark", or "auto" | "light" |
| outline | true to offer the outline panel | false |
Properties, not attributes: report, targets, data, page and fonts are values no attribute
could carry. targets mirrors report.render(target, data) for the exports:
"pdf"/"xlsx"/"csv"/"docx" targets become rows of the bar's export menu, in the order
given, and the sheet needs none of them. An "html" target is accepted and not read, so one list can serve
render and the viewer. Any other name is a host mistake: the viewer reports it on the error panel
with the entry's index, and renders nothing. The quario instance (and with it the license and the registry) stays
yours: the element takes the compiled report, never a schema.
Assigning data (or any of the others) re-renders. Rapid successive writes render only the newest
state: a slow render can never overwrite a newer one, and a superseded render fires no event.
The viewer compares assignments by identity, so to re-render from the same object, assign a fresh one
(view.data = { ...data }).
page and fonts are the pdf target's own options: the viewer lays the report out on them, so
passing the same values to the element and to pdf({ page, fonts }) is what makes the preview
page where the document pages, in the same faces. A page or fonts write re-lays the report
out.
Events and renderComplete
view.addEventListener("rendered", () => {});
view.addEventListener("error", ({ detail: { error, kind } }) => {});
await view.renderComplete; // true when the newest render landed on the sheetrendered fires each time a render lands on the sheet. error fires for every failure you should
know about, with detail.kind naming which: "mount-render" until a render has ever landed,
"update-render" after, "export" for a download that could not be produced. Both events are
non-bubbling, like <img>'s. Listen on the element. (Because the event carries the name error, an
inline onerror attribute on the element would fire too. window.onerror never sees it.)
renderComplete awaits the newest render settling: true when it landed on the sheet with the pages
on screen finished trying to paint, false when it failed or there was nothing to render. It never
rejects. Failures arrive on the error event. rendered fires at that same moment. Like every
outcome here, it answers for the newest render only: the render that is newest when the promise
settles, so a render that starts behind your await is the one it then waits for. A superseded
render's failure reaches no one.
An image the browser cannot decode — pixel data corrupt past the size in its header, which is all
the engine reads — is drawn as nothing, and the page is drawn around it. That is not a render
failure: renderComplete answers true, rendered fires, and no error event follows. A face in
fonts is a different story. The layout parses it while it measures the report, so bytes that will
not parse are a render error with a panel.
Your own mistakes surface on the same channel: a report that is not compiled, or a malformed
option property, becomes a TypeError naming the property, on the error event and the
error panel. The viewer needs no particular target — the sheet is the layout's own.
Lifecycle
There is no destroy(). Removal from the DOM stops the work in flight and releases the
observers. An export that settles after removal downloads nothing. The properties and the pages
persist. The element still owes a render that did not land on the sheet. It therefore renders
once more when you insert it, unless its last render landed. Reparenting is safe, and you
discard a viewer by discarding the element. Two viewers, or a viewer beside your own components,
coexist: each element owns its own shadow root.
Using with React, Vue and Svelte
No wrapper packages: each framework sets properties on custom elements directly, so the samples
below are the whole integration. Each of the three ran before landing here. Import
@quario/viewer/register once, anywhere before the component mounts, and size the element with
your own CSS.
React 19
React 19 assigns non-primitive props on a custom element as properties, and on* props attach
listeners for the element's own events. onrendered and onerror below listen for rendered
and error:
import "@quario/viewer/register";
function Report({ report, targets, data }) {
return (
<quario-viewer
report={report}
targets={targets}
data={data}
filename="sales"
onrendered={() => console.log("landed")}
onerror={(event) => console.error(event.detail.kind, event.detail.error)}
/>
);
}Re-rendering with a new data prop re-renders the report. The newest write wins, as always.
(React 18 and earlier stringify unknown props to attributes. There, hold a ref and assign the
properties and listeners in an effect.)
Vue 3
:prop bindings on a custom element land as properties whenever the element defines them, and
@rendered/@error are plain DOM listeners:
<script setup>
import "@quario/viewer/register";
defineProps(["report", "targets", "data"]);
const onRendered = () => console.log("landed");
const onError = (event) => console.error(event.detail.kind, event.detail.error);
</script>
<template>
<quario-viewer
:report="report"
:targets="targets"
:data="data"
filename="sales"
@rendered="onRendered"
@error="onError"
></quario-viewer>
</template>Tell Vue's template compiler the tag is a custom element, so it does not warn about an unresolved component. In Vite:
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === "quario-viewer" } } });Svelte 5
Svelte sets a property whenever the element defines one, and on<name> attributes are plain DOM
listeners:
<script>
import "@quario/viewer/register";
let { report, targets, data } = $props();
</script>
<quario-viewer
{report}
{targets}
{data}
filename="sales"
onrendered={() => console.log("landed")}
onerror={(event) => console.error(event.detail.kind, event.detail.error)}
></quario-viewer>Zoom
The bar's magnifier opens the zoom menu: Fit page on its own, then 25%, 50%, 75%, 100%, 150%
and 200%. A check marks the current mode — Fit page whenever the viewer is fitting, whatever
percentage that came out at — and the trigger carries the percentage on screen in its name
("Zoom, 62%"), so nothing in the bar changes width as it moves. The zoom property picks the mode
the viewer opens in: a percentage between 25 and 200 — continuous, not one of the menu's stops — or
"fit", the default. A percentage the menu does not offer leaves every row unchecked.
Fit sizes one page to the viewer's width and only ever shrinks: given room to spare it stops at 100%, so the report keeps its true point size rather than growing past it. It has no floor, so a narrow pane fits at whatever percentage that takes — below 25% the menu has no row to return to it, and Fit page is the only way back. A fitted viewer follows its own box, so a collapsing panel or a resized window re-fits on its own.
The preview scales. It never reflows. Zooming repaints the pages larger or smaller, like a photograph that stays sharp. Line breaks, column widths and point sizes stay what they are at 100%, because the layout never changes under a zoom.
The viewer paints only the pages near the viewport — the ones on screen and one screenful either side. Every page keeps its size, so the scrollbar and the scroll extent are the whole report from the start. A page further off is blank paper until you scroll to it, which is why a zoom step costs the same on a thousand-page report as on a five-page one.
Links
A run that carries an href gets a region of its own over the words the page
paints. The reader clicks it, or reaches it with the Tab key and opens it with Enter. You
declare nothing for this and turn nothing on.
A URL opens in a tab of its own, so a reader reading a report never loses it. The engine
admitted that URL against your instance's schemes allowlist before the viewer saw it,
which is where you decide what a report may point at. An href that starts with # names
a group's label and steps the sheet to that instance, the way an outline row does. A
label no instance carries is no region, and the words stay as they are.
What the preview is
The pages on screen are the pages the PDF export writes: both consume one layout, @quario/layout's
display list, laid out on your page and fonts. A report shorter than a page is one page. A
longer one is as many pages as the layout breaks it into, stacked down the sheet. Sizing a
container to a viewer that draws a short report therefore reserves a full page. Set zoom to a
percentage small enough where the box has to stay small.
The reader reads and does not reorder. The viewer paints the layout's pages and offers no sort, because a page is not a grid; a host that wants sorted rows renders the report again with them sorted. The XLSX target's filter option is where a reader gets a control of their own.
Each page is a canvas, and text on it is drawn in the face the document will use. The viewer
registers a TrueType family you pass as fonts from your own bytes and draws it as the browser
shapes it —
the same shaping the PDF gets from the same file, so ligatures, joined scripts and accents look
here the way they will on paper. Text in the built-in families is drawn character by character at
the advances the layout measured, because the font a browser has for Helvetica, Times or Courier
only stands in for the one the PDF writes, and the correction is what makes a line fill the same
width and break in the same place the document does. Inside one run of a family you supply, a
browser may kern by a fraction more than the document. Where lines break and pages end is the
layout's, and identical. The viewer decodes images from their bytes — no data: URIs, so a host page's
Content Security Policy needs no img-src grant for them.
Rendering an unlicensed evaluation, the viewer paints the marking across every page, the way the PDF export marks every page: it rides on the layout, so what you see is what the document carries. A licensed render carries none.
While a render is in flight a thin indeterminate bar sits on the toolbar's bottom edge, and the
viewer's inner container — the .qv-viewer div in its shadow root, not the element itself —
reads aria-busy="true". It reports that the viewer is working, not how far along. The
engine streams events and cannot know how many are still coming.
There is no Print button, because the sheet is the wrong thing to print. Your page's stylesheets do
not cross into the shadow root, so @media print rules never reach the report. The viewer also
paints the pages at whatever zoom the reader happened to leave them, so printing the page puts the viewer's
chrome on paper at that zoom. What a printer wants is the pdf target's
document, the same bytes the PDF export hands over. A host that wants its own Print button owns
two lines:
// The same compiled report and data the element holds, through the pdf()
// target from the quick start — so what prints is exactly the PDF export.
const bytes = await view.report.render(pdf(), view.data);
window.open(URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })));That opens the report in a tab, where the browser's own PDF viewer prints it, paginated as the export is, watermark and all.
Errors
When a render or an export fails, the viewer says so on the error panel. The panel is a strip across the top of the sheet, carrying the error's own message under one of three short labels:
- "Could not render the report"
- "Could not update the report — showing the previous version"
- "Could not export PDF" It replaces rather than stacks, its own button dismisses it, and the next render that lands on the sheet clears it. A successful export leaves it up, because the panel describes what you are looking at and a download says nothing about that.
Every failure the panel draws, and every export failure, also fires the error event. The viewer rethrows
nothing to the platform behind it. Failures after the element leaves the DOM go unreported, and a
render that a newer one has already superseded reports to no one at all.
The message carries quario's located error, the band/item path and the offending source, as in
detail[0] [{{ @.amount.toFixed(2) }}]: .... Drawing it is safe because report definitions are
trusted configuration. Report data never is, and none of it appears in the path. What can carry
data is the message itself, if your own registry functions interpolate a row into what they throw.
That is your call, and the panel puts it on screen as text, never as markup. Compile errors are not
part of this: q.report(schema) raises those before the viewer is ever handed a report.
Outline
Set outline to true and the bar carries one more control. It opens a panel beside the sheet
that lists the report's group instances, nested by depth, in document order. A row scrolls the
sheet to where that instance begins. The list is the group tree the PDF target writes as
bookmarks, and a row reads the group's label where one is declared. A group instance whose bands all resolve
hidden is not in the list. The property turns the module on. The reader opens and closes the
panel, and the panel opens when the module turns on.
view.outline = true;Color scheme
colorScheme paints the chrome — backdrop, bar, controls, progress strip, error
panel — and never the sheet. "light" (the default) and "dark" pin. "auto" follows
the OS via CSS color-scheme. The pages stay white, marking included.
Chrome styles live on --qv-* custom properties under stable qv-* class names. A
token set on the element (or an ancestor) always wins over the pin. The names are a
reference seam, not a frozen vocabulary: backdrop and bar (--qv-backdrop, --qv-bar,
--qv-border, --qv-text), controls (--qv-icon, --qv-icon-active, --qv-hover,
--qv-active, --qv-focus), progress (--qv-progress), the error panel (--qv-error,
--qv-error-text, --qv-error-border, --qv-error-hover), and the pages' edge
(--qv-sheet-shadow).
The report itself is paint, not markup: there is no report stylesheet to restyle, because what you see is the layout the PDF target writes.
Documentation
The quario documentation is the reference.
The report schema is the normative
specification of what a report may declare, and
@quario/viewer is this element's own API.
License
Commercial software with readable source. Evaluation is free, unlimited, and watermarked. Per-developer licenses at getquario.com. See the bundled LICENSE.
Pass your license key once, on the instance. quario verifies it offline:
const q = quario({ license: "quario_..." });
await q.license; // { licensed: true, licensee: "Acme BV", id: "1-ACME" }