---
title: "The viewer — quario"
description: "Embedding the quario viewer so people can page through a report on screen and export it themselves."
url: "https://getquario.com/docs/diving-deeper/viewer"
---

# The viewer

## Intent

`<quario-viewer>` puts a compiled report on screen as pages, and wires the
export menu that turns those pages into files. Use it when your users
should see a document before they download it, or should choose the format
themselves.

## Design decisions

**The viewer compiles nothing.** It takes a compiled report as a property and
never touches the event stream. Compiling stays your application’s job, on your
instance, with your registry.

**It paints the same layout the PDF target writes.** `@quario/layout` lays
out preview and document against the same faces, which is why the preview
breaks its pages where the document does.

**The preview approximates rather than imitating.** It’s a faithful page
break, not a pixel-faithful print simulation. Treat it as the document’s
structure on screen, not as a proof of the final raster.

**Export targets are yours to choose, in your order.** Give it the targets you
want, and one **Export** button opens a menu listing them in array order. A
viewer given no targets exports nothing. It can give a reader only the formats
it knows a content type for: `pdf`, `docx`, `xlsx` and `csv`. A target of your
own gets no row. The sheet is the layout’s either way. Targets buy downloads,
not the render.

One button rather than one per format, because the bar used to widen with every
target you passed. Four needed 343px and pushed the view control off the left
edge. Now the bar keeps one width whatever you pass: 303px for a report of up
to nine pages. Below 384px it drops the first-page and last-page controls, so it
fits a 320px phone. While an export renders,
the button shows a spinner and disables that format’s row. The button itself
stays live, so a reader can start a second format before the first finishes.

**A failed render resolves the mount.** The element doesn’t hang or throw into
your render tree. It reports through an `error` event and stays mounted.

**A viewer you insert again renders once more, unless its last render landed on
the sheet.** Moving the element, hiding a tab, or re-parenting it can take it
out of the document mid-render. Whatever the newest render owed the sheet is
still owed when it comes back. Three cases leave that debt standing: a failure
that reached nobody, a property written while the element was out, and a host
option it rejected. None of them quietly leaves the previous pages up. A failure
the panel already showed re-runs and reports once, not twice.

**The colour scheme paints chrome only.** The sheet stays the document’s,
because a document that changed colour with the reader’s theme would stop being
the document.

**The viewer draws no parameter form.** It passes `params` to the preview and
to every export, so a downloaded file shows the same rows as the screen. Which
values a person may choose, and how, stays your page’s decision.

**Search reads the whole report, not the pages on screen.** The browser’s own
find sees only the pages the viewer has painted. The viewer’s search sees every
page and matches the text as drawn, so a formatted amount matches as
`1,234.50`. Ctrl+F or Cmd+F opens it while the focus is in the viewer, and the
browser’s find stays everywhere else.

**Print goes through the PDF, never the page.** Printing the host page would
print the chrome at whatever zoom the reader left. Print renders your `pdf`
target into a hidden frame and opens the browser’s dialog there. That frame
needs `frame-src blob:` in your Content Security Policy, which is why Print is
off until you say the grant is there.

**The marking goes on the sheet.** An unlicensed report carries the marking in
the preview just as it does in an export. The viewer never shows an unmarked
page for a render that would export marked.

**A reader can read and copy the pages.** The viewer lays the report’s text
over each page it paints. A screen reader reads it, a magnifier follows it, and
a reader selects and copies it. Nothing needs declaring. A screen reader meets
each page as a group named “Page n of N” and reads its header, then its body,
then its footer. The first line of a group header is a heading at that group’s
depth. An image with an `alt` carries that name, and a screen reader passes
over one without an `alt` as decoration. A table reads as a table, with its
column headers as headings. A table that continues onto the next page starts
again with its headings there, as the paint does. The unlicensed marking reads
on every page and never copies.

A copy takes the text as the report wrote it. A paragraph that wraps on the
page copies as one line, and so does a table row, with a space between its
cells. The text covers the pages near the view, the same ones the paint
covers, so select-all takes those pages. The PDF export is the whole report.

## API walkthrough

### Mounting

Import `@quario/viewer/register` to define the element, or import
`QuarioViewer` and define it yourself. Set properties as element properties,
not attributes, because a compiled report and a font record aren’t strings.

```ts title="viewer.ts"
import '@quario/viewer/register'
import type { ViewerPage } from '@quario/viewer'
import { pdf } from '@quario/pdf'

const el = document.createElement('quario-viewer')

// One decision, read by the preview and by the export.
const page: ViewerPage = { size: 'A4', margin: 54 }

el.report = report
el.data = data
el.params = { region: 'EU' }
el.page = page
el.targets = [pdf({ page })]
el.filename = 'invoice-2043'

el.addEventListener('error', (event) => {
  console.error(event.detail.kind, event.detail.error)
})

document.body.append(el)
```

The element and the export target are two consumers of one decision, and
nothing reconciles them for you. Declare the geometry once and give it to both,
as above.

### Letting the reader choose what the report shows

`params` is the record you’d pass as the last argument to `render`. The viewer
passes it to the preview and to each export, and writing it renders again. A
render needs a value for every parameter declared without a `default`. Until
`params` holds one, every render fails and the error panel names it.

`report.params` lists each declaration as `{ name, required, type?, default?,
label? }`. A `label` marks a parameter a person supplies, so your page can draw
its form from that list.

```ts title="params-form.ts"
const form = document.querySelector<HTMLFormElement>('#params')!
const inputTypes: Record<string, string> = { string: 'text', number: 'number', boolean: 'checkbox' }

for (const param of viewer.report?.params ?? []) {
  if (param.label === undefined || param.type === undefined || !(param.type in inputTypes)) continue
  const input = document.createElement('input')
  input.name = param.name
  input.type = inputTypes[param.type]
  if (param.type === 'boolean') input.checked = param.default === true
  else if (param.default != null) input.value = String(param.default)
  form.append(param.label, input)
}

form.addEventListener('submit', (event) => {
  event.preventDefault()
  const params: Record<string, string | number | boolean> = {}
  for (const param of viewer.report?.params ?? []) {
    const input = form.elements.namedItem(param.name)
    if (!(input instanceof HTMLInputElement)) continue
    if (input.type === 'checkbox') params[param.name] = input.checked
    else if (input.value !== '') params[param.name] = param.type === 'number' ? input.valueAsNumber : input.value
  }
  viewer.params = params
})
```

An empty field adds no key, so that parameter keeps its default. A boolean
whose default is `null` takes three values, so give it a select rather than a
checkbox.

A parameter can change the rows themselves, not only a heading. A sort key may
read one, so a select on your page orders the report.

```json title="a sort the reader chooses"
{
  "params": { "sortBy": { "type": "string", "default": "date", "label": "Sort by" } },
  "data": "$.orders[*]",
  "sort": [{ "by": "=$.params.sortBy == 'amount' ? -@.amount : @.date", "dir": "asc" }],
  "detail": {
    "columns": [
      { "header": "Date", "value": "{{ @.date }}", "style": { "format": "date" } },
      { "header": "Amount", "value": "{{ @.amount }}", "style": { "format": "currency" } }
    ]
  }
}
```

`dir` is a literal, so an expression can’t flip it. Negating the key, as
`-@.amount` does, sorts a number from the largest.

```ts title="sort.ts"
const sort = document.querySelector<HTMLSelectElement>('#sort')!

sort.addEventListener('change', () => {
  viewer.params = { ...viewer.params, sortBy: sort.value }
})
```

A row’s `visible` may read one too, as `"row": { "visible": "=$.params.detailed" }`
does. Hiding the detail rows that way keeps the group headers, footers and
subtotals, because a hidden row still counts. Use `where` when a row should
leave the totals as well.

### Fonts

`fonts` takes the same record `pdf({ fonts })` takes. Pass the identical object
to both so the preview measures against the faces the document will embed, and
hold it across renders. The record and its buffers are the identity the viewer
remembers a face by, so a fresh buffer means parsing the file again.

### Reacting

**`rendered`** fires when a render lands on the sheet. **`error`** carries `{
error, kind }`. `kind` is `mount-render` before any render lands, and
`update-render` after. It’s `export` for a download or a print the viewer
couldn’t produce, and `host-option` for a property it rejected before rendering
anything. The mounting sample above listens for it.

**`pagechange`** fires with `{ pageNumber, pageCount }` whenever either
changes, the first render included. **`searchchange`** fires with `{ query,
match, matches }`. **`link`** fires before a URL link opens, and it’s
cancelable.

`renderComplete` resolves once the pages within reach have painted, not every
page, which is what a test awaits rather than a timer. A host that reads pixels
off a page far down the document finds it blank until it’s scrolled to. It
answers for the newest render at the moment it settles. A host awaiting it
while properties keep changing waits for the last change, not for whichever
render happened to be in flight when it asked.

A `targets` entry naming something other than `pdf`, `xlsx`, `csv`, `docx`, or
`html` is a host mistake like any other malformed property. The error panel
names the entry by its index and the name it carried, and the last good render
stays up. The viewer accepts an `html` entry and doesn’t read it, so one list
can serve both `render` and the viewer.

### Driving it from your page

`pageNumber` reads the current page, counted from 1, and writing it goes there.
A number past the end goes to the last page. One written before a render lands
waits for it. `pageCount` reads the length. Both read `0` until a render lands.

`query` reads and writes the search. Writing it opens the find row and goes to
the first match on or after the current page.

```ts title="status.ts"
viewer.pageNumber = 12

viewer.addEventListener('pagechange', ({ detail }) => {
  console.log(`page ${detail.pageNumber} of ${detail.pageCount}`)
})

viewer.query = 'laptop'

viewer.addEventListener('searchchange', ({ detail: { match, matches } }) => {
  console.log(matches ? `${match} of ${matches}` : 'No matches')
})
```

`bar: false` removes the bar and its controls. The page keys, the progress
strip and the outline panel stay, so your own controls drive the viewer through
these properties. To keep the bar and add to it, give the element children with
`slot="bar"`. They sit between the page controls and the search, view,
and export controls.

### Following a link

A run carrying an `href` is a link in the text the viewer lays over the page.
A reader clicks it, or reaches it with Tab and opens it with Enter. A URL opens
in a tab of its own. An `href` naming a group’s `label` steps the sheet to that
instance, the way an outline row does. A label no instance carried leaves plain
text a reader can still select. Links follow the reach as the pages do, and
move with the zoom.

A host page can catch the `link` event to drill through. Add a scheme of your
own to the instance’s `schemes`, write it in the `href`, and call
`preventDefault()`. Then assign the next report and its `params`. A `#label`
link moves within the report and fires no event.

```ts title="drill-through.ts"
import { quario } from 'quario'

const q = quario({ schemes: ['https:', 'app:'] })

viewer.addEventListener('link', (event) => {
  const url = new URL(event.detail.href)
  if (url.protocol !== 'app:') return
  event.preventDefault()
  viewer.report = q.report(definitions[url.pathname])
  viewer.params = { region: url.searchParams.get('region') ?? '' }
})
```

### Zoom, view, and chrome

`zoom` sets what the viewer opens at: `'width'` (the default), `'page'`, or a
percentage from 25 to 200. Both fits only shrink, so a viewer with room to spare
shows the report at its true size. `view` takes `'continuous'` (the default) or
`'single'`, one page at a time. A single page turns by the page controls and
keys, never by the wheel. A reader zooms by pinch, ctrl+wheel, or `+`, `-` and
`0` with the sheet focused. Zooming repaints the pages, and every line breaks where it did.

`colorScheme` takes `'light'`, `'dark'` or
`'auto'` and reaches the surrounding chrome, never the sheet.

`outline` takes `true` and adds one more control to the bar, opening a panel
beside the sheet. The panel lists the report’s group instances in document order,
nested by depth. A row scrolls the sheet to where that instance starts.
The list is the same group tree the PDF target writes as bookmarks, so a report
declares nothing for it. An instance whose bands all resolve hidden isn’t in
it. The module is off by default, and the reader opens and closes the
panel.

### Printing

Set `print: true` to add Print to the export menu. The row shows only with a
`pdf` target and in a browser that shows a PDF in a frame. The printed pages are
the PDF export’s, marking included. Without the `frame-src blob:` grant, Print fails as
an `export` error rather than printing a blank page. A page that can’t grant it
leaves `print` off, and the reader prints the downloaded PDF.

### Speaking your readers’ language

`strings` replaces the chrome’s English key by key, and a key you leave out
stays English. Fixed text takes a string. A message that carries a value takes
a function of it. A wrong key or shape is a `host-option` error naming
`strings.<key>`.

```ts title="strings.ts"
viewer.strings = {
  firstPage: 'Erste Seite',
  pageCount: (count) => `von ${count}`,
}
viewer.lang = 'de'
```

The instance’s `locale` doesn’t set the chrome’s language. Set the element’s
`lang` to match your strings, so a screen reader pronounces them right.

### When not to use it

A server rendering a PDF into a response needs none of this. The viewer is for
a browser where someone is looking. If your users only ever receive a file,
render [the PDF target](https://getquario.com/docs/diving-deeper/pdf-target.md) directly and skip the
element and its layout dependency.

```json
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://getquario.com/"},{"@type":"ListItem","position":2,"name":"Docs","item":"https://getquario.com/docs"},{"@type":"ListItem","position":3,"name":"The viewer","item":"https://getquario.com/docs/diving-deeper/viewer"}]}
```
