@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/menu.js ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * The zoom menu: the magnifier trigger in the bar and the popover it opens —
3
+ * "Fit sheet" above a separator, then the percentages `zoom.js` offers. It is
4
+ * the whole zoom control; the live percentage is never drawn, it is what the
5
+ * trigger is named after ("Zoom, 62%"), so the bar carries one icon rather
6
+ * than a readout that changes width under the reader.
7
+ *
8
+ * Native Popover API, so the top layer and light dismiss are the platform's:
9
+ * the menu escapes the bar without any ancestor having to allow it, and
10
+ * Escape or a click outside closes it. Three things the platform leaves to
11
+ * the author, each handled once here rather than per dismissal: placing the
12
+ * popover against its invoker (`place()` — anchor positioning would decide it
13
+ * in CSS, but not yet everywhere the viewer embeds); dismissing it when the
14
+ * viewport moves under it (`follow()` — a top-layer element does not travel
15
+ * with the invoker it was placed against, and a menu hanging off nothing is
16
+ * worse than one that closed); and handing focus back off a row that is about
17
+ * to stop being displayed (`leaving()`, on `beforetoggle`, the last moment
18
+ * that row is still focusable).
19
+ *
20
+ * Radio semantics, not toggles: exactly one row is the current mode, and
21
+ * choosing the one already checked settles as a no-op in the element's own
22
+ * `#setMode`, so this module reports every row alike. The check follows
23
+ * the mode rather than the measurement — "Fit sheet" is checked whenever the
24
+ * viewer is fitting, whatever percentage that came out at, and a host-set
25
+ * percentage the menu does not offer checks nothing.
26
+ *
27
+ * Labels go in as template values, never as markup, so this module has no
28
+ * markup edge to escape at (hard constraint 4).
29
+ */
30
+ import { css, html } from "lit";
31
+ import { button } from "./button.js";
32
+ import { STEPS } from "./zoom.js";
33
+
34
+ /** The popover's id, scoped to the shadow root, so two viewers never collide. */
35
+ let ID = "qv-zoom-menu";
36
+
37
+ /** The gap between the trigger and the menu it opens, in px. */
38
+ let OFFSET = 6;
39
+
40
+ export let MENU = css`
41
+ /* Top-layer, so it is placed against the viewport: the UA's centring inset
42
+ is cleared here and \`place()\` writes the corner. Clearing it is also what
43
+ keeps the opening honest — \`place()\` runs from the queued toggle task, and
44
+ until it does, a fixed box with auto insets sits at its static position,
45
+ which is exactly where this element stands in the bar. \`display\` is
46
+ deliberately left alone: an author \`display\` would beat the UA rule that
47
+ hides a closed popover. */
48
+ .qv-menu {
49
+ position: fixed;
50
+ inset: auto;
51
+ margin: 0;
52
+ padding: 4px;
53
+ min-width: 150px;
54
+ border: 1px solid var(--_border);
55
+ border-radius: 8px;
56
+ background: var(--_bar);
57
+ box-shadow: 0 6px 20px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5));
58
+ }
59
+
60
+ .qv-menuitem {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: 7px;
64
+ width: 100%;
65
+ padding: 5px 12px 5px 6px;
66
+ border: none;
67
+ border-radius: 5px;
68
+ background: transparent;
69
+ color: inherit;
70
+ font: inherit;
71
+ font-variant-numeric: tabular-nums;
72
+ text-align: left;
73
+ cursor: pointer;
74
+ }
75
+
76
+ .qv-menuitem:hover {
77
+ background: var(--_hover);
78
+ color: var(--_icon-active);
79
+ }
80
+
81
+ /* Inset, so the ring stays inside the menu's own padding. */
82
+ .qv-menuitem:focus-visible {
83
+ outline: 2px solid var(--_focus);
84
+ outline-offset: -2px;
85
+ }
86
+
87
+ /* The check's slot is held whether or not a check is in it, so the labels
88
+ line up down the menu. */
89
+ .qv-tick {
90
+ display: inline-flex;
91
+ flex: none;
92
+ width: 13px;
93
+ height: 13px;
94
+ }
95
+
96
+ .qv-separator {
97
+ margin: 4px 2px;
98
+ border: none;
99
+ border-top: 1px solid var(--_border);
100
+ }
101
+ `;
102
+
103
+ /** @type {import('lit').TemplateResult} */
104
+ let magnifier = html`
105
+ <svg viewBox="0 0 20 20" width="19" height="19" aria-hidden="true">
106
+ <g fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round">
107
+ <circle cx="8.75" cy="8.75" r="5.25"></circle>
108
+ <path d="M12.6 12.6 16.4 16.4"></path>
109
+ </g>
110
+ </svg>
111
+ `;
112
+
113
+ /** @type {import('lit').TemplateResult} */
114
+ let tick = html`
115
+ <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true">
116
+ <path
117
+ d="M3.4 8.4 6.4 11.4 12.6 4.8"
118
+ fill="none"
119
+ stroke="currentColor"
120
+ stroke-width="1.9"
121
+ stroke-linecap="round"
122
+ stroke-linejoin="round"
123
+ ></path>
124
+ </svg>
125
+ `;
126
+
127
+ /**
128
+ * The button that opens this menu, found by the invoker relationship itself
129
+ * rather than by where it sits: a wrapper added for layout, or a second
130
+ * control in the group, must not be able to repoint placement or the focus
131
+ * hand-back.
132
+ *
133
+ * @type {(menu: Element) => HTMLElement}
134
+ */
135
+ let trigger = (menu) =>
136
+ /** @type {any} */ (menu.getRootNode()).querySelector('[popovertarget="' + ID + '"]');
137
+
138
+ /** What has focus in the viewer's shadow root — not necessarily in the menu. */
139
+ /** @type {(menu: Element) => Element | null} */
140
+ let focused = (menu) => /** @type {ShadowRoot} */ (menu.getRootNode()).activeElement;
141
+
142
+ /** @type {(menu: Element) => HTMLElement[]} */
143
+ let items = (menu) => /** @type {HTMLElement[]} */ ([...menu.querySelectorAll(".qv-menuitem")]);
144
+
145
+ /**
146
+ * Place the open menu under its trigger, or over it when there is no room
147
+ * below — the viewer is embedded, and a host is free to put its bar near the
148
+ * bottom of the window.
149
+ *
150
+ * @param {HTMLElement} menu
151
+ */
152
+ let place = (menu) => {
153
+ let box = trigger(menu).getBoundingClientRect();
154
+ let below = box.bottom + OFFSET + menu.offsetHeight <= innerHeight;
155
+ menu.style.left = box.left + "px";
156
+ menu.style.top = (below ? box.bottom + OFFSET : box.top - OFFSET - menu.offsetHeight) + "px";
157
+ };
158
+
159
+ /**
160
+ * Where each key sends focus, from the row that has it.
161
+ *
162
+ * @type {Record<string, (list: HTMLElement[], at: number) => HTMLElement>}
163
+ */
164
+ let MOVE = {
165
+ ArrowDown: (list, at) => list[(at + 1) % list.length],
166
+ ArrowUp: (list, at) => list[(at - 1 + list.length) % list.length],
167
+ Home: (list) => list[0],
168
+ End: (list) => list[list.length - 1],
169
+ };
170
+
171
+ /**
172
+ * Tab is the one key that closes rather than moves: a menu left open behind
173
+ * the focus ring is the state light dismiss does not cover, and closing it
174
+ * is what puts focus back on the trigger to carry on from.
175
+ *
176
+ * @param {KeyboardEvent} event
177
+ */
178
+ let navigate = (event) => {
179
+ let menu = /** @type {HTMLElement} */ (event.currentTarget);
180
+ let move = MOVE[event.key];
181
+ if (!move && event.key !== "Tab") return;
182
+ event.preventDefault();
183
+ if (!move) return menu.hidePopover();
184
+ let list = items(menu);
185
+ move(list, list.indexOf(/** @type {any} */ (focused(menu)))).focus();
186
+ };
187
+
188
+ /**
189
+ * What is watching the viewport for each open menu, so closing can stop it.
190
+ * Keyed weakly on the menu, which is the element the listeners close.
191
+ *
192
+ * @type {WeakMap<Element, AbortController>}
193
+ */
194
+ let watching = new WeakMap();
195
+
196
+ /**
197
+ * Placement is written once, at opening, against the trigger's viewport box —
198
+ * so anything that moves that box while the menu is open leaves it pointing
199
+ * at nothing. Scrolling (in capture, so a scroll anywhere counts) and
200
+ * resizing dismiss it instead of dragging it along: the reader has already
201
+ * looked away from the control.
202
+ *
203
+ * @param {HTMLElement} menu
204
+ */
205
+ let follow = (menu) => {
206
+ let stop = new AbortController();
207
+ // Guarded and `once`, not merely aborted on close: removing an open popover
208
+ // from the document hides it without firing `beforetoggle`, so a discarded
209
+ // viewer never reaches `leaving()`. `once` is what stops these listeners —
210
+ // and the menu they hold — outliving the first scroll or resize after that.
211
+ let close = () => menu.matches(":popover-open") && menu.hidePopover();
212
+ let once = { capture: true, once: true, signal: stop.signal };
213
+ addEventListener("scroll", close, once);
214
+ addEventListener("resize", close, once);
215
+ watching.set(menu, stop);
216
+ };
217
+
218
+ /**
219
+ * Opening puts focus on the row that is checked, so the arrow keys move from
220
+ * where the reader already is. Nothing is checked at an off-list percentage;
221
+ * then the first row takes it.
222
+ *
223
+ * @param {ToggleEvent} event
224
+ */
225
+ let opened = (event) => {
226
+ if (event.newState !== "open") return;
227
+ let menu = /** @type {HTMLElement} */ (event.currentTarget);
228
+ place(menu);
229
+ follow(menu);
230
+ (/** @type {HTMLElement | null} */
231
+ (menu.querySelector('[aria-checked="true"]')) ?? items(menu)[0]).focus();
232
+ };
233
+
234
+ /**
235
+ * Whatever dismissed the menu — Escape, Tab, a chosen row — focus must not be
236
+ * left on a row that is about to stop being displayed. `beforetoggle` is
237
+ * where this belongs: by the time `toggle` runs the rows are gone and the
238
+ * browser has already put focus back on the document.
239
+ *
240
+ * @param {ToggleEvent} event
241
+ */
242
+ let leaving = (event) => {
243
+ let menu = /** @type {HTMLElement} */ (event.currentTarget);
244
+ if (event.newState !== "closed") return;
245
+ watching.get(menu)?.abort();
246
+ if (menu.contains(focused(menu))) trigger(menu).focus();
247
+ };
248
+
249
+ /**
250
+ * Choosing a row closes the menu and reports the row. Whether that is a
251
+ * change is the element's to know, not this module's: `#setMode` is where the
252
+ * mode lives, and re-choosing the one already current settles there.
253
+ *
254
+ * @type {(value: "fit" | number, choose: (value: "fit" | number) => void)
255
+ * => (event: Event) => void}
256
+ */
257
+ let pick = (value, choose) => (event) => {
258
+ let target = /** @type {HTMLElement} */ (event.currentTarget);
259
+ /** @type {HTMLElement} */ (target.closest("[popover]")).hidePopover();
260
+ choose(value);
261
+ };
262
+
263
+ /**
264
+ * One menu row.
265
+ *
266
+ * @type {(label: string, value: "fit" | number, checked: boolean,
267
+ * choose: (value: "fit" | number) => void) => import('lit').TemplateResult}
268
+ */
269
+ let row = (label, value, checked, choose) => html`
270
+ <button
271
+ type="button"
272
+ class="qv-menuitem"
273
+ role="menuitemradio"
274
+ aria-checked=${String(checked)}
275
+ tabindex="-1"
276
+ data-zoom=${String(value)}
277
+ @click=${pick(value, choose)}
278
+ >
279
+ <span class="qv-tick">${checked ? tick : ""}</span>
280
+ ${label}
281
+ </button>
282
+ `;
283
+
284
+ /**
285
+ * The zoom group: the trigger and its menu. The trigger's name carries the
286
+ * percentage on screen, because that is the one control whose state a reader
287
+ * would otherwise have to open the menu to learn — and at an off-list fit
288
+ * percentage the menu could not tell them anyway.
289
+ *
290
+ * @param {{ mode: "fit" | number, percent: number,
291
+ * choose: (value: "fit" | number) => void }} zoom
292
+ * The live mode, the percentage actually on screen, and the element's own
293
+ * handler for a chosen row.
294
+ * @returns {import('lit').TemplateResult}
295
+ */
296
+ export let zoomMenu = ({ mode, percent, choose }) => html`
297
+ <div class="qv-zoom">
298
+ ${button({
299
+ title: "Zoom, " + percent + "%",
300
+ popover: ID,
301
+ content: magnifier,
302
+ })}
303
+ <div
304
+ id=${ID}
305
+ popover
306
+ class="qv-menu"
307
+ role="menu"
308
+ aria-label="Zoom"
309
+ @toggle=${opened}
310
+ @beforetoggle=${leaving}
311
+ @keydown=${navigate}
312
+ >
313
+ ${row("Fit sheet", "fit", mode === "fit", choose)}
314
+ <hr class="qv-separator" role="separator" />
315
+ ${STEPS.map((step) => row(step + "%", step, mode === step, choose))}
316
+ </div>
317
+ </div>
318
+ `;
package/lib/panel.js ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The error panel: what a failed render or export looks like to the reader,
3
+ * rather than only to whoever is listening for the event. One panel serves
4
+ * every kind of failure, so the label is what tells them apart.
5
+ *
6
+ * Always the compound — "the error panel", never a bare "panel", which stays
7
+ * the word for the host's own UI regions around the viewer (see CONTEXT.md).
8
+ *
9
+ * It is the one place the viewer states wording of its own. Everything else it
10
+ * shows comes from the report: the fragment is the html target's, and even the
11
+ * unlicensed marking's wording arrives inside it. A label cannot, because no
12
+ * error knows whether it was a mount, an update or an export that failed —
13
+ * which is exactly what the error event's `kind` names.
14
+ *
15
+ * What it prints beside that label is the error's own message, as a template
16
+ * value. That is a markup edge, and it stays one: a message can carry report
17
+ * data, because a host's registry function is free to interpolate a row into
18
+ * whatever it throws (hard constraint 4).
19
+ */
20
+ import { css, html } from "lit";
21
+ import { button } from "./button.js";
22
+
23
+ export let PANEL = css`
24
+ /* Positioned rather than laid out, for the reason the progress strip is: a
25
+ panel that took layout would push the stage down and shorten it, so raising
26
+ and clearing it would rescale a fitted sheet every time an export failed.
27
+ Over the top of the stage, above the marking watermark's z-index. */
28
+ .qv-error {
29
+ position: absolute;
30
+ inset: 0 0 auto 0;
31
+ z-index: 2;
32
+ display: flex;
33
+ align-items: flex-start;
34
+ gap: 6px;
35
+ max-height: 45%;
36
+ padding: 9px 8px 11px 12px;
37
+ background: var(--_error);
38
+ color: var(--_error-text);
39
+ border-bottom: 1px solid var(--_error-border);
40
+ }
41
+
42
+ /* The wording scrolls; the dismiss control does not go with it. */
43
+ .qv-error-body {
44
+ flex: 1;
45
+ min-width: 0;
46
+ overflow: auto;
47
+ }
48
+
49
+ .qv-error-label {
50
+ margin: 0;
51
+ font-weight: 600;
52
+ }
53
+
54
+ /* The error's own words, which for a located diagnostic carry the schema path
55
+ and the offending source. It wraps rather than truncates: the path is the
56
+ most useful part and it is at the front, but the message is what says why. */
57
+ .qv-error-message {
58
+ margin: 3px 0 0;
59
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
60
+ white-space: pre-wrap;
61
+ overflow-wrap: anywhere;
62
+ }
63
+
64
+ /* The shared button is styled for the bar, where it sits on the chrome's own
65
+ palette. In here it takes the panel's. */
66
+ .qv-error .qv-button {
67
+ flex: none;
68
+ color: inherit;
69
+ }
70
+
71
+ .qv-error .qv-button:hover {
72
+ background: var(--_error-hover);
73
+ color: inherit;
74
+ }
75
+ `;
76
+
77
+ /**
78
+ * Which failure this was, in the panel's own wording. The update label earns
79
+ * its length: that failure leaves the previous report on the sheet, and
80
+ * silently stale content is the thing the panel exists to prevent.
81
+ *
82
+ * @param {"mount-render" | "update-render" | "export"} kind
83
+ * @param {string} [format] The export format's name, for that kind alone.
84
+ * @returns {string}
85
+ */
86
+ export let label = (kind, format) =>
87
+ kind === "export"
88
+ ? "Could not export " + format
89
+ : kind === "update-render"
90
+ ? "Could not update the report — showing the previous version"
91
+ : "Could not render the report";
92
+
93
+ /**
94
+ * What an error says. Not every throw is an `Error` — a host's registry
95
+ * function may throw anything, which `locate()` in the engine allows for too —
96
+ * so the value itself answers when there is no message to read, and an `Error`
97
+ * with an empty one falls through to its own `toString`.
98
+ *
99
+ * @param {unknown} error The caught value.
100
+ * @returns {string}
101
+ */
102
+ let wording = (error) => String(/** @type {any} */ (error)?.message || error);
103
+
104
+ /**
105
+ * The error panel, rendered only while there is a failure worth looking at:
106
+ * it is a state, not a log — the error event is the log, and it takes every
107
+ * failure whether or not one is drawn. Assertive rather than polite: a
108
+ * failure means the reader is looking at stale or empty content, which is
109
+ * worth interrupting for. What keeps the announcement rate down is that the
110
+ * panel replaces rather than stacks, and that a superseded render's failure
111
+ * is never drawn.
112
+ *
113
+ * @param {{ label: string, error: unknown }} failure The viewer's own label
114
+ * and the caught value, printed as text and never markup.
115
+ * @param {() => void} dismiss Takes the panel down until the next failure.
116
+ * @returns {import('lit').TemplateResult}
117
+ */
118
+ export let panel = (failure, dismiss) => html`
119
+ <div class="qv-error" role="alert">
120
+ <div class="qv-error-body">
121
+ <p class="qv-error-label">${failure.label}</p>
122
+ <p class="qv-error-message">${wording(failure.error)}</p>
123
+ </div>
124
+ ${button({ title: "Dismiss", label: "×", click: dismiss })}
125
+ </div>
126
+ `;
@@ -0,0 +1,9 @@
1
+ import { QuarioViewer } from "./index.js";
2
+
3
+ declare global {
4
+ interface HTMLElementTagNameMap {
5
+ "quario-viewer": QuarioViewer;
6
+ }
7
+ }
8
+
9
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The one-line define, as its own entry so the main module stays
3
+ * side-effect-free: importing `@quario/viewer` gives you the class and
4
+ * defines nothing, importing `@quario/viewer/register` gives the tag to
5
+ * hosts that want the platform default. A host that wants its own tag calls
6
+ * `customElements.define` itself (docs/adr/0005-the-surfaces-are-custom-elements.md).
7
+ */
8
+ import { QuarioViewer, TAG } from "./index.js";
9
+
10
+ customElements.define(TAG, QuarioViewer);