@quario/viewer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/check.js ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * What the host got wrong, named. The element's own mistakes — a report that
3
+ * is not compiled, a missing html target, a malformed option property — are
4
+ * definition-shaped: they describe the embedding, not the report, so each
5
+ * check here throws a `TypeError` naming the property and the expectation.
6
+ * The element rethrows them from its render task, which is how one channel
7
+ * (the error event and the panel) carries every failure a host can cause.
8
+ *
9
+ * Pure policy, no DOM: this module is what the Node suite pins.
10
+ */
11
+
12
+ import { STEPS } from "./zoom.js";
13
+
14
+ /** @type {(message: string) => never} */
15
+ let fail = (message) => {
16
+ throw new TypeError("quario-viewer: " + message);
17
+ };
18
+
19
+ // The PdfPage vocabulary and defaults, mirrored from the pdf target: the
20
+ // sheet is a visual stand-in for the page the host configured there, so the
21
+ // same values must mean the same geometry.
22
+ /** @type {Record<string, [number, number]>} */
23
+ let SIZES = { A4: [595.28, 841.89], letter: [612, 792] };
24
+
25
+ let REPORT = [
26
+ (/** @type {any} */ report) => report && typeof report === "object",
27
+ (/** @type {any} */ report) => typeof report.stream === "function",
28
+ (/** @type {any} */ report) => typeof report.render === "function",
29
+ ];
30
+
31
+ /** @type {(report: any) => boolean} */
32
+ let compiled = (report) => REPORT.every((check) => check(report));
33
+
34
+ /** @type {(target: any) => boolean} */
35
+ let htmlTarget = (target) => target?.name === "html";
36
+
37
+ /**
38
+ * The target the sheet displays: the one named `"html"`, checked along with
39
+ * the shapes around it.
40
+ *
41
+ * @param {any} report
42
+ * @param {any} targets
43
+ * @returns {any} The html target.
44
+ */
45
+ export let display = (report, targets) => {
46
+ if (!compiled(report)) fail("report: expected a compiled report from a quario instance");
47
+ if (!Array.isArray(targets)) fail("targets: expected an array of render targets");
48
+ let found = targets.find(htmlTarget);
49
+ if (!found) fail('targets: no "html" target — include html() to display');
50
+ return found;
51
+ };
52
+
53
+ /** @type {(size: unknown) => [number, number] | false} */
54
+ let named = (size) =>
55
+ Object.hasOwn(SIZES, /** @type {string} */ (size)) && SIZES[/** @type {string} */ (size)];
56
+
57
+ /** @type {(size: unknown) => any} */
58
+ let measure = (size) => (Array.isArray(size) ? size : named(size));
59
+
60
+ /** @type {(n: number) => boolean} */
61
+ let positive = (n) => Number.isFinite(n) && n > 0;
62
+
63
+ /** @type {(dimensions: any) => { width: number, height: number }} */
64
+ let extents = (dimensions) => {
65
+ let width = +dimensions[0];
66
+ let height = +dimensions[1];
67
+ if (!positive(width) || !positive(height)) fail("page.size: expected finite positive dimensions");
68
+ return { width, height };
69
+ };
70
+
71
+ /** @type {(margin: number, width: number, height: number) => boolean} */
72
+ let fits = (margin, width, height) =>
73
+ [Number.isFinite(margin), margin >= 0, 2 * margin < Math.min(width, height)].every(Boolean);
74
+
75
+ /** @type {(page: any, width: number, height: number) => number} */
76
+ let inset = (page, width, height) => {
77
+ let margin = page?.margin ?? 54;
78
+ if (!fits(margin, width, height))
79
+ fail("page.margin: expected a non-negative number smaller than half the page");
80
+ return margin;
81
+ };
82
+
83
+ /**
84
+ * The sheet's geometry from the `page` property. Width and margin size the
85
+ * sheet; height is the stamp period for the unlicensed watermark (full page
86
+ * box, same as the PDF target — margin does not shrink it).
87
+ *
88
+ * @param {any} page
89
+ * @returns {{ width: number, height: number, margin: number }}
90
+ */
91
+ export let geometry = (page) => {
92
+ let size = page?.size ?? "A4";
93
+ let dimensions = measure(size);
94
+ if (!dimensions) fail('page.size: unknown page size "' + size + '"');
95
+ let { width, height } = extents(dimensions);
96
+ return { width, height, margin: inset(page, width, height) };
97
+ };
98
+
99
+ /** @type {(mode: any, floor: number, ceiling: number) => boolean} */
100
+ let inRange = (mode, floor, ceiling) =>
101
+ [Number.isFinite(mode), mode >= floor, mode <= ceiling].every(Boolean);
102
+
103
+ /**
104
+ * The zoom mode from the `zoom` property. Fit is the default: the host owns
105
+ * the box, and a report nobody can read until they touch a control is not
106
+ * much of a preview. A host may author any percentage from 25 to 200 —
107
+ * continuous, not one of the menu's own stops; fit itself computes anything
108
+ * at or below 100, so this deliberately validates less than the element can
109
+ * display.
110
+ *
111
+ * @param {any} zoom
112
+ * @returns {"fit" | number}
113
+ */
114
+ export let level = (zoom) => {
115
+ let mode = zoom ?? "fit";
116
+ // The range is the menu's own ends, so the two cannot drift apart.
117
+ let [floor, ceiling] = [STEPS[0], STEPS[STEPS.length - 1]];
118
+ if (mode !== "fit" && !inRange(mode, floor, ceiling))
119
+ fail('zoom: expected "fit" or a percentage between ' + floor + " and " + ceiling);
120
+ return mode;
121
+ };
122
+
123
+ /** @type {(base: unknown) => boolean} */
124
+ let nonempty = (base) => typeof base === "string" && Boolean(base);
125
+
126
+ /**
127
+ * The export base name from the `filename` property, extensionless.
128
+ *
129
+ * @param {any} filename
130
+ * @returns {string}
131
+ */
132
+ export let name = (filename) => {
133
+ let base = filename ?? "report";
134
+ if (!nonempty(base)) fail("filename: expected a non-empty string");
135
+ return base;
136
+ };
137
+
138
+ /** @type {Record<string, string>} */
139
+ let SCHEME = { light: "light", dark: "dark", auto: "light dark" };
140
+
141
+ /**
142
+ * The used `color-scheme` from the `colorScheme` property. Light is the
143
+ * default: embedding is the product, and following the OS would paint a light
144
+ * toolbar in a host that already chose dark. `"auto"` is the opt-in that
145
+ * follows, as CSS `light dark`.
146
+ *
147
+ * @param {any} colorScheme
148
+ * @returns {string}
149
+ */
150
+ export let scheme = (colorScheme) => {
151
+ let pin = colorScheme ?? "light";
152
+ if (!Object.hasOwn(SCHEME, pin)) fail('colorScheme: expected "light", "dark", or "auto"');
153
+ return SCHEME[pin];
154
+ };
package/lib/chrome.js ADDED
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The element's chrome: the backdrop, the bar, and the render indicator on
3
+ * its edge, under stable `qv-*` class names. The sheet is not here —
4
+ * `stage.js` keeps it paper.
5
+ *
6
+ * This module also owns the **default palette**: the `:host` block below is
7
+ * the one place a `--qv-*` fallback is written, and every sheet — this one
8
+ * included — paints from the `--_*` alias it declares. That is what makes a
9
+ * color one edit rather than a hunt, and `viewer.test.js` holds both halves:
10
+ * `--qv-` appears in no other module, and the aliases read are exactly the
11
+ * ones declared. The fallbacks are `light-dark()`, so a `color-scheme` pin on
12
+ * the host paints chrome without a class.
13
+ *
14
+ * The cost is that the other sheets no longer stand alone — they paint only
15
+ * where this one is adopted too. It is presence, not order: a custom property
16
+ * resolves down the inherited chain, so where CHROME sits in `static styles`
17
+ * does not matter, only that it is there.
18
+ *
19
+ * Host tokens always win, and `:host` is what makes that survive here. A
20
+ * `:host { --qv-border: … }` *default* would carry pseudo-class specificity
21
+ * and beat a host's own `quario-viewer` rule — the seam ADR 0018 declines to
22
+ * make a lie. A `var()` fallback applies only when the token is unset, so it
23
+ * loses to a host token at any specificity. The private half inverts that on
24
+ * purpose: `:host` outranks that same host rule, so nothing outside can
25
+ * shadow an alias by accident.
26
+ *
27
+ * Two of the sixteen are the sheet's rather than the chrome's: a color scheme
28
+ * paints chrome only (ADR 0018), so `--qv-sheet-shadow` is the sheet's edge
29
+ * against the backdrop and `--qv-mark` is scheme-free by design. They are in
30
+ * the block because it has no exceptions, not because they follow the pin.
31
+ * `--qv-focus` and `--qv-progress` repeat the accent rather than chain a third
32
+ * alias — a host is free to move one alone.
33
+ *
34
+ * What is not here is what another module owns: `stage.js` the surface a
35
+ * report is scaled on, `panel.js` the error panel, `button.js` the controls
36
+ * every group in the bar is built from. The element composes their templates
37
+ * and stylesheets; the imports run one way only.
38
+ */
39
+ import { css, html, nothing } from "lit";
40
+
41
+ export let CHROME = css`
42
+ /* The default palette. */
43
+ :host {
44
+ display: block;
45
+ color-scheme: light;
46
+
47
+ --_backdrop: var(--qv-backdrop, light-dark(#e9e9eb, #1c1c1e));
48
+ --_text: var(--qv-text, light-dark(#454548, #c7c7cc));
49
+ --_bar: var(--qv-bar, light-dark(#f8f8f9, #2c2c2e));
50
+ --_border: var(--qv-border, light-dark(#dededf, #3a3a3c));
51
+
52
+ --_icon: var(--qv-icon, light-dark(#58585c, #8e8e93));
53
+ --_icon-active: var(--qv-icon-active, light-dark(#212124, #f2f2f7));
54
+ --_hover: var(--qv-hover, light-dark(#ebebec, #3a3a3c));
55
+ --_active: var(--qv-active, light-dark(#e2e2e4, #48484a));
56
+ --_focus: var(--qv-focus, #4a90d9);
57
+
58
+ --_progress: var(--qv-progress, #4a90d9);
59
+
60
+ --_error: var(--qv-error, light-dark(#fdeded, #3b1c1c));
61
+ --_error-text: var(--qv-error-text, light-dark(#5f2120, #f5c6c6));
62
+ --_error-border: var(--qv-error-border, light-dark(#f1c5c5, #6b3434));
63
+ --_error-hover: var(--qv-error-hover, light-dark(#f7dcdc, #4a2626));
64
+
65
+ /* The sheet's, not the chrome's. */
66
+ --_sheet-shadow: var(
67
+ --qv-sheet-shadow,
68
+ 0 1px 2px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5)),
69
+ 0 6px 24px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.45))
70
+ );
71
+ --_mark: var(--qv-mark, #999);
72
+ }
73
+
74
+ .qv-viewer {
75
+ display: flex;
76
+ flex-direction: column;
77
+ width: 100%;
78
+ height: 100%;
79
+ background: var(--_backdrop);
80
+ color: var(--_text);
81
+ font: 13px/1.4 system-ui, sans-serif;
82
+ }
83
+
84
+ /* Everything below the bar. It exists to be the box the error panel is
85
+ positioned against: anchored to the viewer instead, the panel would cover
86
+ the bar's own controls. */
87
+ .qv-body {
88
+ position: relative;
89
+ display: flex;
90
+ flex-direction: column;
91
+ flex: 1;
92
+ min-height: 0;
93
+ }
94
+
95
+ .qv-bar {
96
+ position: relative;
97
+ display: flex;
98
+ justify-content: flex-end;
99
+ gap: 2px;
100
+ flex: none;
101
+ padding: 5px 10px;
102
+ background: var(--_bar);
103
+ border-bottom: 1px solid var(--_border);
104
+ }
105
+
106
+ /* The render indicator, lying over the bar's bottom border. It is positioned
107
+ rather than laid out for two reasons: nothing under it moves as renders come
108
+ and go, and it stays out of the width .qv-scroll reports, which the stage
109
+ fits against.
110
+ Showing is instant and hiding fades, which is the whole timing rule: a render
111
+ short enough for the fade to matter is one the reader never waited for. The
112
+ transition lives on the idle state alone, so the busy state has none to run
113
+ and the fade starts only on the way back. */
114
+ .qv-progress {
115
+ position: absolute;
116
+ inset: auto 0 -1px 0;
117
+ height: 2px;
118
+ overflow: hidden;
119
+ opacity: 0;
120
+ transition: opacity 200ms ease;
121
+ }
122
+
123
+ .qv-viewer[aria-busy="true"] .qv-progress {
124
+ opacity: 1;
125
+ transition: none;
126
+ }
127
+
128
+ /* The accent is the same in both schemes: it has to read on the light bar
129
+ and the dark one, and a second hue would be a second opinion. */
130
+ .qv-progress::after {
131
+ content: "";
132
+ display: block;
133
+ width: 35%;
134
+ height: 100%;
135
+ background: var(--_progress);
136
+ animation: qv-slide 1.1s ease-in-out infinite;
137
+ }
138
+
139
+ @keyframes qv-slide {
140
+ from {
141
+ transform: translateX(-100%);
142
+ }
143
+ to {
144
+ transform: translateX(385%);
145
+ }
146
+ }
147
+
148
+ /* Motion is the only thing the strip says; without it, say the same with a
149
+ filled bar rather than nothing at all. */
150
+ @media (prefers-reduced-motion: reduce) {
151
+ .qv-progress::after {
152
+ width: 100%;
153
+ opacity: 0.55;
154
+ animation: none;
155
+ }
156
+ }
157
+
158
+ /* The zoom group holds the left of the bar; the exports stay flush right.
159
+ Layout only: the menu inside it places itself against its own trigger, not
160
+ against this element. */
161
+ .qv-zoom {
162
+ display: flex;
163
+ margin-right: auto;
164
+ }
165
+
166
+ .qv-exports {
167
+ display: flex;
168
+ gap: 2px;
169
+ }
170
+ `;
171
+
172
+ /**
173
+ * The render indicator. Idle it stays out of the accessibility tree — an
174
+ * unstarted progressbar has nothing to announce — and the stylesheet above
175
+ * hooks on the viewer's `aria-busy` to show it.
176
+ *
177
+ * @param {boolean} busy
178
+ * @returns {import('lit').TemplateResult}
179
+ */
180
+ export let progress = (busy) => html`
181
+ <div
182
+ class="qv-progress"
183
+ role="progressbar"
184
+ aria-label="Rendering report"
185
+ aria-hidden=${busy ? nothing : "true"}
186
+ ></div>
187
+ `;
package/lib/index.d.ts ADDED
@@ -0,0 +1,130 @@
1
+ import type { LitElement } from "lit";
2
+ import type { CompiledReport, Target } from "quario";
3
+
4
+ /**
5
+ * Sheet geometry, in PostScript points — the pdf target's `PdfPage`
6
+ * vocabulary, because the sheet stands in visually for the page configured
7
+ * there. Purely visual: pass the same values to `pdf({ page })` so preview
8
+ * and export agree.
9
+ */
10
+ export interface ViewerPage {
11
+ /** Named size or `[width, height]` in points. Default `'A4'`. */
12
+ size?: "A4" | "letter" | [number, number];
13
+ /** Margin on all four sides, in points. Default `54` (0.75 in). */
14
+ margin?: number;
15
+ }
16
+
17
+ /**
18
+ * A compiled report, from a quario instance's `report()`. The instance —
19
+ * and with it the license and the registry — stays the host's concern: the
20
+ * element takes the compiled artifact, never a schema.
21
+ */
22
+ export type ViewableReport = CompiledReport;
23
+
24
+ /** Which failure an `error` event names. */
25
+ export type ViewerErrorKind = "mount-render" | "update-render" | "export";
26
+
27
+ /** The `error` event's payload. */
28
+ export interface ViewerErrorDetail {
29
+ /** The caught value, exactly as thrown. */
30
+ error: unknown;
31
+ /**
32
+ * Which failure occurred: `mount-render` until a render has ever reached
33
+ * the sheet, `update-render` after, `export` for a download that could not
34
+ * be produced.
35
+ */
36
+ kind: ViewerErrorKind;
37
+ }
38
+
39
+ /**
40
+ * The embeddable report viewer, `<quario-viewer>`. Assign `report`,
41
+ * `targets` and `data` as properties (they are values no attribute could
42
+ * carry) and listen for `rendered` and `error`; rapid successive writes
43
+ * render only the newest state. Importing this module defines nothing —
44
+ * import `@quario/viewer/register` for the one-line define, or call
45
+ * `customElements.define` with a tag of your own.
46
+ *
47
+ * Failures split along one line: the host's own mistakes (a report that is
48
+ * not compiled, a missing `"html"` target, a malformed option property)
49
+ * throw a `TypeError` naming the property, delivered through the `error`
50
+ * event and the error panel like every other failure; a report that could
51
+ * not be rendered leaves the element up with the panel saying why. A
52
+ * superseded render's failure is reported to no one.
53
+ *
54
+ * Removing the element abandons in-flight work and releases its observers;
55
+ * the properties persist, and reconnecting re-renders from them. There is no
56
+ * `destroy()` — removal is destruction only in the garbage-collection sense,
57
+ * and reparenting is safe. An export settling after removal downloads
58
+ * nothing.
59
+ */
60
+ export class QuarioViewer extends LitElement {
61
+ /** The compiled report to display and export. */
62
+ report: ViewableReport | undefined;
63
+ /**
64
+ * The configured targets, as passed to `report.render` — one must be named
65
+ * `"html"` to display; ones named `"pdf"`/`"xlsx"`/`"csv"` become
66
+ * export buttons, in array order.
67
+ */
68
+ targets: readonly Target[] | undefined;
69
+ /** The render document. Assigning re-renders; the newest write wins. */
70
+ data: unknown;
71
+ /**
72
+ * The zoom to open at: `'fit'` (the default) or a percentage. Fit sizes
73
+ * the sheet to the viewer's width and is shrink-only — it never enlarges
74
+ * past 100%, so a viewer with room to spare shows the report at its true
75
+ * point size. An authored percentage must be between 25 and 200, and is
76
+ * continuous in that range rather than one of the zoom menu's own stops;
77
+ * fit itself is unbounded below, so the viewer can display percentages
78
+ * this property would not accept. That asymmetry is deliberate: only fit
79
+ * computes them.
80
+ */
81
+ zoom: "fit" | number | undefined;
82
+ /** Sheet geometry. The preview approximates; exports are exact. */
83
+ page: ViewerPage | undefined;
84
+ /** Export download name, without extension. Default `'report'`. */
85
+ filename: string | undefined;
86
+ /**
87
+ * Chrome color scheme. `'light'` (the default) and `'dark'` pin; `'auto'`
88
+ * follows the OS via CSS `color-scheme`. The sheet stays white.
89
+ */
90
+ colorScheme: "light" | "dark" | "auto" | undefined;
91
+ /**
92
+ * The newest render settling: `true` when it reached the sheet, `false`
93
+ * when it failed or there was nothing to render. Never rejects — failures
94
+ * are the `error` event's — and like every outcome here it answers for
95
+ * the newest render only.
96
+ */
97
+ get renderComplete(): Promise<boolean>;
98
+
99
+ addEventListener<K extends keyof QuarioViewerEventMap>(
100
+ type: K,
101
+ listener: (this: QuarioViewer, event: QuarioViewerEventMap[K]) => void,
102
+ options?: boolean | AddEventListenerOptions,
103
+ ): void;
104
+ addEventListener(
105
+ type: string,
106
+ listener: EventListenerOrEventListenerObject,
107
+ options?: boolean | AddEventListenerOptions,
108
+ ): void;
109
+ removeEventListener<K extends keyof QuarioViewerEventMap>(
110
+ type: K,
111
+ listener: (this: QuarioViewer, event: QuarioViewerEventMap[K]) => void,
112
+ options?: boolean | EventListenerOptions,
113
+ ): void;
114
+ removeEventListener(
115
+ type: string,
116
+ listener: EventListenerOrEventListenerObject,
117
+ options?: boolean | EventListenerOptions,
118
+ ): void;
119
+ }
120
+
121
+ /** The element's events. Both are non-bubbling, like `<img>`'s. */
122
+ export interface QuarioViewerEventMap {
123
+ /** A render reached the sheet — the mount and every update. */
124
+ rendered: CustomEvent<undefined>;
125
+ /** A failure the host should know about; `detail.kind` says which. */
126
+ error: CustomEvent<ViewerErrorDetail>;
127
+ }
128
+
129
+ /** The tag `@quario/viewer/register` defines the element under. */
130
+ export const TAG: "quario-viewer";