@happyvertical/smrt-ui 0.57.20 → 0.57.22

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.
Files changed (42) hide show
  1. package/AGENTS.md +12 -1
  2. package/README.md +91 -0
  3. package/dist/analytics/index.d.ts +17 -0
  4. package/dist/analytics/index.d.ts.map +1 -0
  5. package/dist/analytics/index.js +43 -0
  6. package/dist/components/display/DocumentViewer.svelte +443 -0
  7. package/dist/components/display/DocumentViewer.svelte.d.ts +24 -0
  8. package/dist/components/display/DocumentViewer.svelte.d.ts.map +1 -0
  9. package/dist/components/display/document-viewer-logic.d.ts +35 -0
  10. package/dist/components/display/document-viewer-logic.d.ts.map +1 -0
  11. package/dist/components/display/document-viewer-logic.js +56 -0
  12. package/dist/components/display/index.d.ts +5 -1
  13. package/dist/components/display/index.d.ts.map +1 -1
  14. package/dist/components/display/index.js +4 -0
  15. package/dist/components/display/pdf-opener.d.ts +44 -0
  16. package/dist/components/display/pdf-opener.d.ts.map +1 -0
  17. package/dist/components/display/pdf-opener.js +121 -0
  18. package/dist/components/display/pdf-types.d.ts +19 -0
  19. package/dist/components/display/pdf-types.d.ts.map +1 -0
  20. package/dist/components/display/pdf-types.js +7 -0
  21. package/dist/components/display/types.d.ts +19 -0
  22. package/dist/components/display/types.d.ts.map +1 -1
  23. package/dist/components/forms/NumericKeypad.svelte +511 -0
  24. package/dist/components/forms/NumericKeypad.svelte.d.ts +51 -0
  25. package/dist/components/forms/NumericKeypad.svelte.d.ts.map +1 -0
  26. package/dist/components/forms/index.d.ts +3 -1
  27. package/dist/components/forms/index.d.ts.map +1 -1
  28. package/dist/components/forms/index.js +2 -0
  29. package/dist/components/forms/numeric-keypad-logic.d.ts +68 -0
  30. package/dist/components/forms/numeric-keypad-logic.d.ts.map +1 -0
  31. package/dist/components/forms/numeric-keypad-logic.js +157 -0
  32. package/dist/components/forms/types.d.ts +17 -0
  33. package/dist/components/forms/types.d.ts.map +1 -1
  34. package/dist/i18n/strings.ui.d.ts +23 -0
  35. package/dist/i18n/strings.ui.d.ts.map +1 -1
  36. package/dist/i18n/strings.ui.js +25 -0
  37. package/dist/svelte/playground/TouchToolsPreview.svelte +47 -0
  38. package/dist/svelte/playground/TouchToolsPreview.svelte.d.ts +4 -0
  39. package/dist/svelte/playground/TouchToolsPreview.svelte.d.ts.map +1 -0
  40. package/dist/svelte/playground.d.ts.map +1 -1
  41. package/dist/svelte/playground.js +10 -0
  42. package/package.json +18 -2
package/AGENTS.md CHANGED
@@ -49,14 +49,16 @@ components are exempt — they *are* the primitives.
49
49
  | `./feedback` | `Alert`, `ToastViewport`, `Modal`, `Drawer`/`Sheet`, `ConfirmDialog`, `LoadingOverlay`, `Progress`, `Meter`, `Spinner`, phone surfaces `PhoneSheet` / `WorkingStrip` and the `swipeDismiss` action |
50
50
  | `./data` | `CollectionToolbar`, `CollectionList`/`ContentList`, `DataTable` (footer rows and column `summaries`: [agents/data-table.md](agents/data-table.md)) and their types — a **component barrel**: it re-exports `.svelte` files, so a plain Node `import()` of it throws `ERR_UNKNOWN_FILE_EXTENSION` |
51
51
  | `./data-surface` | **Svelte-free**: registry contracts and shared protocol limits (`DATA_SURFACE_MAX_REQUEST_BYTES`, `DATA_SURFACE_IDENTIFIER_MAX_LENGTH`, …). Server adapters must import these from here, not from `./data` — a server entry that reaches the barrel breaks the `smrt` CLI's plain-Node `.smrt/register.js` load (issue #2924) |
52
+ | `./display` | `DocumentViewer` (touch PDF/image viewer reading through an injected `fetch`; pdf.js is an optional peer loaded lazily by `createPdfJsOpener()`), `CurrencyDisplay`, `DateDisplay`, `StatusBadge`, `Icon`; also re-exported from `.` |
52
53
  | `./layout` | `Container`, `Grid`, `Header`, `Footer`, `PageHeader`, `EmptyState`, … |
53
54
  | `./charts` | Pure `Chart` renderer (`bar`, `line`, `table`, `pie`, `stat`), structural `ChartData`, and minor-unit number formatting; `./charts/format` is Svelte-free |
54
55
  | `./calendar` | `CalendarView` (time-zone-aware month/week grids + phone agenda) and its Svelte-free date model (`toEntries`, `layoutMonth`, `layoutWeek`, `dateKeyInZone`, …); deprecated `Calendar`, `DayView` |
55
56
  | `./chat` | `MessageBubble`, `ReactionPicker`, `TypingIndicator` |
56
- | `./forms` | Provider-free fields, choice controls, sliders/ranges, combobox/listbox/multiselect/tags, date/time/file controls, `CameraCapture`/`SignaturePad` capture, plus the transport-neutral control interaction registry |
57
+ | `./forms` | Provider-free fields, choice controls, sliders/ranges, combobox/listbox/multiselect/tags, date/time/file controls, `CameraCapture`/`SignaturePad` capture, `NumericKeypad` (quantity/money keypad; money as integer minor units via the Svelte-free `numeric-keypad-logic.ts`), plus the transport-neutral control interaction registry |
57
58
  | `.` permissions | `PermissionCheck` and the domain-agnostic `PermissionPicker` for grouped, searchable native role-grant forms over host-supplied catalog records |
58
59
  | `./form-retry` | **Svelte-free** form retry (#3291): submission key, in-flight refusal, conditional reset, opt-in restore-after-reload, browser `runOnce()` digest. Also re-exported from `./forms`. Framework-free TS typed structurally against SvelteKit's `SubmitFunction` — never import `@sveltejs/kit` here. Guide: `docs/content/form-retry.md` |
59
60
  | `./i18n` | i18n **client**: `useI18n`, `<Trans>`, `defineMessages`, `renderTemplate` (no `smrt-languages` import — the server resolver stays in `smrt-svelte/i18n/server`) |
61
+ | `./analytics` | Optional provider-neutral Svelte context for static shared component outcomes; noop without a host sink. No Matomo dependency. Browser provider lives in smrt-analytics. |
60
62
  | `./registry` | `ModuleUIRegistry` for cross-package component discovery |
61
63
  | `./theme` | deprecated compatibility path forwarding to the canonical theme system |
62
64
  | `./themes` | canonical `ThemeProvider`, context, preset token system (material/glass/studio/smrt/happyvertical), and CSS generation |
@@ -181,6 +183,15 @@ other text pairing clears WCAG AA in both schemes.
181
183
  their form's reset (`attachFormResetListener`), even while `disabled`;
182
184
  otherwise the UI shows a file the next submit no longer posts.
183
185
 
186
+ - **Touch viewers and keypads (#3463).** `DocumentViewer` never puts the API
187
+ URL in an `<img>`/`<iframe>`: it reads through `fetch` and shows blobs, and
188
+ revokes them on cleanup. `pdfjs-dist` is an *optional peer*: only
189
+ `display/pdf-opener.ts` imports it (dynamically, itself loaded lazily), so a
190
+ missing engine shows the `unsupported` state and nothing else breaks. Keep
191
+ zoom/scale rules in `document-viewer-logic.ts`. `NumericKeypad` money mode is
192
+ integer minor units end to end (string math, never floats); entry rules stay
193
+ in `numeric-keypad-logic.ts`. Keys use `max(4rem, --smrt-touch-target-min)`.
194
+
184
195
  - **i18n split**: the client (here) is dependency-free; the Node-only server
185
196
  resolver (`buildI18nSnapshot`, → `@happyvertical/smrt-languages`) stays in
186
197
  `@happyvertical/smrt-svelte/i18n/server`. Package string catalogs register via
package/README.md CHANGED
@@ -36,6 +36,7 @@ pnpm add @happyvertical/smrt-ui
36
36
  | Choices | `Checkbox`, `RadioGroup`/`Radio`, `Switch`, `Toggle`, `ToggleButton`, `SegmentedControl` |
37
37
  | Values and files | `Slider`, `RangeSlider`, `DatePicker`, `TimePicker`, `FilePicker` |
38
38
  | Capture | `CameraCapture`, `SignaturePad` |
39
+ | Touch entry and viewing | `NumericKeypad` (`/forms`), `DocumentViewer` (`/display`) |
39
40
  | Actions and display | `Button`, `Dropdown`/`Menu`, `Badge`, `Chip`, `Avatar`, `Card`, `Skeleton`, `Tooltip`, `Tree` |
40
41
  | Disclosure and overlays | `Popover`, `Disclosure`, `Accordion`/`AccordionItem`, `Modal`, `Drawer`/`Sheet`, `PhoneSheet`, `ConfirmDialog` |
41
42
  | Feedback | `Alert`, `ToastViewport`, `Progress`, `Meter`, `Spinner`, `LoadingOverlay`, `WorkingStrip` |
@@ -657,6 +658,96 @@ least `--smrt-touch-target-min` (48px by default; see
657
658
  Both register with an enclosing `Form`'s interaction registry as non-readable,
658
659
  non-writable `file` controls.
659
660
 
661
+ ## Touch keypad and document viewer
662
+
663
+ Two controls for gloved, tablet-first use. Every key and control is at least
664
+ 4rem square (or `--smrt-touch-target-min`, whichever is larger).
665
+
666
+ ### NumericKeypad
667
+
668
+ ```svelte
669
+ <script lang="ts">
670
+ import { NumericKeypad } from '@happyvertical/smrt-ui/forms';
671
+ let price = $state('');
672
+ </script>
673
+
674
+ <NumericKeypad
675
+ bind:value={price}
676
+ mode="money"
677
+ currency="CAD"
678
+ prefix="$"
679
+ min={0.01}
680
+ onSubmit={({ minorUnits }) => save(minorUnits)}
681
+ name="price"
682
+ />
683
+ ```
684
+
685
+ `value` is the typed text (`"12.5"`; a number is accepted). A 3x4 grid holds
686
+ the digits, a decimal point (only when `maxDecimals` > 0) and backspace; Clear
687
+ sits beside the display and the confirm key (text `submitLabel`) appears when
688
+ `onSubmit` is given. `onChange` / `onSubmit` receive `{ text, number,
689
+ minorUnits, valid }`. A value outside `min` / `max` shows an error and blocks
690
+ confirm; `clampToMax` refuses a press that would exceed `max`. `maxLength`
691
+ caps the typed text (default 12).
692
+
693
+ `mode="money"` keeps integer minor units: the decimals allowed follow the
694
+ currency's ISO 4217 exponent (`currency`, or `minorUnitExponent`), `minorUnits`
695
+ is computed by string arithmetic and never a float, and a `name` posts the
696
+ integer minor units (in `number` mode it posts the typed text).
697
+ Money amounts beyond JavaScript's safe integer minor-unit range are invalid
698
+ and cannot be confirmed, including when `maxLength` is increased. The pure
699
+ helpers `textToMinorUnits()`, `minorUnitsToText()`, `minorUnitExponent()`,
700
+ `applyKeypadKey()` and `validateKeypadEntry()` are exported from `/forms`.
701
+
702
+ Hardware keys work while focus is inside the keypad: digits, `.` or `,` for the
703
+ point, Backspace, Delete (clear) and Enter (confirm, when the keypad itself has
704
+ focus). The root is a `group`, the display is a polite live `output`, and every
705
+ key has an `aria-label`. This is a quantity/money pad, not a PIN pad: the typed
706
+ value is shown as text. Text comes from `ui.numeric_keypad.*` and `labels`.
707
+
708
+ ### DocumentViewer
709
+
710
+ ```svelte
711
+ <script lang="ts">
712
+ import { DocumentViewer, createPdfJsOpener } from '@happyvertical/smrt-ui/display';
713
+ import workerSrc from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url';
714
+ // Literal imports here let YOUR bundler include pdf.js.
715
+ const openPdf = createPdfJsOpener({
716
+ workerSrc,
717
+ loadLibrary: () => import('pdfjs-dist/legacy/build/pdf.mjs'),
718
+ });
719
+ </script>
720
+
721
+ <DocumentViewer src="/api/prints/42/file" mimeType="application/pdf"
722
+ label="Bracket, rev C" fetch={authedFetch} {openPdf} />
723
+ ```
724
+
725
+ The file is read through `fetch` (default: the global one; inject the app's
726
+ authenticated fetch) and shown from a blob. It never sets `<img src>` or
727
+ `<iframe src>` to `src`, so it works in an embedded WebView that only
728
+ authenticates through the app's own fetch and has no built-in PDF viewer. Blob
729
+ URLs are revoked on cleanup. `mimeType` may be omitted, then the response's
730
+ Content-Type decides. Controls: previous / next with "Page N of M", zoom out /
731
+ Fit / zoom in (1, 1.5, 2, 3, 4, 5), and a two-finger pinch; a zoomed document
732
+ scrolls inside its sheet. States (the root's `data-state`): `loading`,
733
+ `ready`, `failed` (with "Try again"), and `unsupported` (an unknown type, or no
734
+ PDF engine). Text comes from `ui.document_viewer.*` and `labels`.
735
+
736
+ **PDFs** are drawn by an `OpenPdf` (`(bytes) => Promise<PdfDocument>` where a
737
+ `PdfDocument` is `{ pages, renderPage(page, canvas, cssWidth), destroy() }`).
738
+ Inject your own, or omit it for the default, `createPdfJsOpener()`, which
739
+ uses `pdfjs-dist`'s legacy build. `pdfjs-dist` is an **optional peer
740
+ dependency**: install it only if you show PDFs; images need nothing. It is
741
+ loaded on the first PDF, and if it is missing the viewer shows `unsupported`.
742
+ The default opener loads it through a variable specifier so an app without
743
+ pdfjs-dist still builds; that means a bundler does not include pdf.js by default.
744
+ An app that wants it bundled passes `loadLibrary` (as above) and `workerSrc`.
745
+ Pass `workerSrc` (a URL such as Vite's `?url` import of
746
+ `pdf.worker.min.mjs`) to render off the main thread; without it the worker
747
+ module is imported and runs on the main thread, which needs no bundler URL
748
+ handling. The canvas is capped at 4096 x 4096 device pixels; zooming past it
749
+ enlarges on screen instead.
750
+
660
751
  ## DataTable controller
661
752
 
662
753
  `DataTable` can share one headless `DataTableController` between rendered
@@ -0,0 +1,17 @@
1
+ /** Static standard outcomes and engagement; never includes component data. */
2
+ export type StandardAnalyticsEvent = 'planner_opened' | 'planner_cookbook_applied' | 'project_question_answered' | 'project_decision_resolved' | 'project_preview_opened' | 'project_preview_approved' | 'project_preview_rejected';
3
+ /** Optional private receipt identity, used only for in-memory duplicate suppression. */
4
+ export interface AnalyticsEmissionOptions {
5
+ dedupeKey?: string;
6
+ }
7
+ export type AnalyticsSink = (name: string, options?: AnalyticsEmissionOptions) => unknown;
8
+ /** Install once during the host root component's initialization. No SSR globals. */
9
+ export declare function setAnalyticsContext(sink: AnalyticsSink): void;
10
+ /** Capture during component initialization; absent configuration is a noop. */
11
+ export declare function getAnalyticsContext(): AnalyticsSink;
12
+ /** Observe only persisted status changes; initial/historical rows are not events. */
13
+ export declare function createAnalyticsTransitions(emit: (name: StandardAnalyticsEvent) => unknown): (rows: readonly {
14
+ id: string;
15
+ status: string;
16
+ }[], from: string, outcomes: Readonly<Record<string, StandardAnalyticsEvent>>) => void;
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/analytics/index.ts"],"names":[],"mappings":"AAEA,8EAA8E;AAC9E,MAAM,MAAM,sBAAsB,GAC9B,gBAAgB,GAChB,0BAA0B,GAC1B,2BAA2B,GAC3B,2BAA2B,GAC3B,wBAAwB,GACxB,0BAA0B,GAC1B,0BAA0B,CAAC;AAC/B,wFAAwF;AACxF,MAAM,WAAW,wBAAwB;IACvC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AACD,MAAM,MAAM,aAAa,GAAG,CAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,wBAAwB,KAC/B,OAAO,CAAC;AAIb,oFAAoF;AACpF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAE7D;AACD,+EAA+E;AAC/E,wBAAgB,mBAAmB,IAAI,aAAa,CASnD;AAED,qFAAqF;AACrF,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,CAAC,IAAI,EAAE,sBAAsB,KAAK,OAAO,IAI7C,MAAM,SAAS;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,EAAE,EAC/C,MAAM,MAAM,EACZ,UAAU,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC,UAmB7D"}
@@ -0,0 +1,43 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const KEY = Symbol.for('@happyvertical/smrt-ui/analytics');
3
+ const noop = () => { };
4
+ /** Install once during the host root component's initialization. No SSR globals. */
5
+ export function setAnalyticsContext(sink) {
6
+ setContext(KEY, sink);
7
+ }
8
+ /** Capture during component initialization; absent configuration is a noop. */
9
+ export function getAnalyticsContext() {
10
+ const sink = getContext(KEY) ?? noop;
11
+ return (name, options) => {
12
+ try {
13
+ Promise.resolve(sink(name, options)).catch(() => { });
14
+ }
15
+ catch {
16
+ /* analytics never breaks UI */
17
+ }
18
+ };
19
+ }
20
+ /** Observe only persisted status changes; initial/historical rows are not events. */
21
+ export function createAnalyticsTransitions(emit) {
22
+ const seen = new Map();
23
+ return (rows, from, outcomes) => {
24
+ const present = new Set(rows.map((row) => row.id));
25
+ for (const id of seen.keys()) {
26
+ if (!present.has(id))
27
+ seen.delete(id);
28
+ }
29
+ for (const row of rows) {
30
+ const previous = seen.get(row.id);
31
+ seen.set(row.id, row.status);
32
+ const outcome = outcomes[row.status];
33
+ if (previous === from && outcome) {
34
+ try {
35
+ Promise.resolve(emit(outcome)).catch(() => { });
36
+ }
37
+ catch {
38
+ /* no UI failures */
39
+ }
40
+ }
41
+ }
42
+ };
43
+ }
@@ -0,0 +1,443 @@
1
+ <!--
2
+ A PDF or picture, readable on a touch screen (smrt#3463).
3
+
4
+ The file is read through `fetch` (inject the app's own authenticated one) and
5
+ shown from a blob: never an <img src> or <iframe src> on the API, because an
6
+ embedded WebView may only authenticate through the app's fetch and often has
7
+ no built-in PDF viewer. A PDF is drawn page by page onto a canvas by an
8
+ `OpenPdf` (default: pdf.js through `createPdfJsOpener()`, an optional peer
9
+ dependency; images need nothing).
10
+
11
+ Controls are at least 4rem: previous / next with "Page N of M", zoom out /
12
+ Fit / zoom in (1, 1.5, 2, 3, 4, 5), and a two-finger pinch does the same. A
13
+ zoomed document scrolls inside its sheet. States: loading, failed (with
14
+ Try again), unsupported type, and unsupported engine.
15
+
16
+ Ported from teamworks-os `PrintViewer.svelte`.
17
+ -->
18
+ <script lang="ts">
19
+ import { M, useI18n } from '../../i18n/index.js';
20
+ import { renderTemplate } from '../../i18n/render.js';
21
+ import {
22
+ clampPage,
23
+ clampPinchScale,
24
+ commitPinchZoom,
25
+ isImageType,
26
+ isPdfType,
27
+ MAX_ZOOM,
28
+ MIN_ZOOM,
29
+ stepZoom,
30
+ } from './document-viewer-logic.js';
31
+ import {
32
+ type OpenPdf,
33
+ type PdfDocument,
34
+ PdfEngineUnavailableError,
35
+ } from './pdf-types.js';
36
+ import type { DocumentViewerLabels } from './types.js';
37
+
38
+ export interface Props {
39
+ /** URL of the file; read with `fetch`. */
40
+ src: string;
41
+ /** `application/pdf` or `image/*`. Omitted, the response's Content-Type decides. */
42
+ mimeType?: string;
43
+ /** Names the document for assistive technology (the image alt, the page canvas). */
44
+ label: string;
45
+ /** Reads the file; inject the app's authenticated fetch. Default: global `fetch`. */
46
+ fetch?: typeof globalThis.fetch;
47
+ /** Opens PDF bytes. Default: pdf.js via `createPdfJsOpener()`. */
48
+ openPdf?: OpenPdf;
49
+ /** Text overrides; defaults come from the `ui` i18n catalog. */
50
+ labels?: Partial<DocumentViewerLabels>;
51
+ /** DOM id of the component root. */
52
+ id?: string;
53
+ /** CSS class to apply to the component root. */
54
+ class?: string;
55
+ }
56
+
57
+ let {
58
+ src,
59
+ mimeType,
60
+ label,
61
+ fetch: fetchImpl,
62
+ openPdf,
63
+ labels,
64
+ id,
65
+ class: className = '',
66
+ }: Props = $props();
67
+
68
+ const { t } = useI18n();
69
+ const text = $derived<DocumentViewerLabels>({
70
+ region: t(M['ui.document_viewer.region']),
71
+ pages: t(M['ui.document_viewer.pages']),
72
+ zoom: t(M['ui.document_viewer.zoom']),
73
+ previousPage: t(M['ui.document_viewer.previous_page']),
74
+ nextPage: t(M['ui.document_viewer.next_page']),
75
+ pageOf: t(M['ui.document_viewer.page_of']),
76
+ pageLabel: t(M['ui.document_viewer.page_label']),
77
+ zoomOut: t(M['ui.document_viewer.zoom_out']),
78
+ zoomIn: t(M['ui.document_viewer.zoom_in']),
79
+ fit: t(M['ui.document_viewer.fit']),
80
+ loading: t(M['ui.document_viewer.loading']),
81
+ failed: t(M['ui.document_viewer.failed']),
82
+ retry: t(M['ui.document_viewer.retry']),
83
+ unsupported: t(M['ui.document_viewer.unsupported']),
84
+ ...labels,
85
+ });
86
+
87
+ type Status = 'loading' | 'ready' | 'failed' | 'unsupported';
88
+
89
+ let status = $state<Status>('loading');
90
+ let kind = $state<'pdf' | 'image' | null>(null);
91
+ let imageUrl = $state<string | null>(null);
92
+ let pdf = $state.raw<PdfDocument | null>(null);
93
+ let page = $state(1);
94
+ let zoom = $state(MIN_ZOOM);
95
+ /** Live scale while two fingers pinch; committed to `zoom` when they lift. */
96
+ let pinchScale = $state(1);
97
+ let attempt = $state(0);
98
+ let width = $state(0);
99
+ let canvas = $state<HTMLCanvasElement | null>(null);
100
+
101
+ function kindOf(type: string | null | undefined): 'pdf' | 'image' | null {
102
+ if (!type) return null;
103
+ if (isPdfType(type)) return 'pdf';
104
+ if (isImageType(type)) return 'image';
105
+ return null;
106
+ }
107
+
108
+ async function defaultOpenPdf(bytes: Uint8Array): Promise<PdfDocument> {
109
+ // Loaded on demand so a consumer without pdfjs-dist never pulls it in.
110
+ try {
111
+ const { createPdfJsOpener } = await import('./pdf-opener.js');
112
+ openerFallback ??= createPdfJsOpener();
113
+ } catch (error) {
114
+ throw new PdfEngineUnavailableError(
115
+ error instanceof Error ? error.message : undefined,
116
+ );
117
+ }
118
+ return openerFallback(bytes);
119
+ }
120
+ let openerFallback: OpenPdf | undefined;
121
+
122
+ // Read the file whenever the source (or a retry) changes; release the last one.
123
+ $effect(() => {
124
+ const href = src;
125
+ const declared = mimeType;
126
+ const reader = fetchImpl ?? globalThis.fetch.bind(globalThis);
127
+ const open = openPdf ?? defaultOpenPdf;
128
+ void attempt;
129
+ let live = true;
130
+ let url: string | null = null;
131
+ let opened: PdfDocument | null = null;
132
+ status = 'loading';
133
+ imageUrl = null;
134
+ pdf = null;
135
+ kind = kindOf(declared);
136
+ page = 1;
137
+ zoom = MIN_ZOOM;
138
+ pinchScale = 1;
139
+ if (declared !== undefined && kindOf(declared) === null) {
140
+ status = 'unsupported';
141
+ return;
142
+ }
143
+ void (async () => {
144
+ try {
145
+ const response = await reader(href, {
146
+ credentials: 'same-origin',
147
+ cache: 'no-store',
148
+ });
149
+ if (!response.ok) throw new Error(String(response.status));
150
+ const resolved =
151
+ kindOf(declared) ?? kindOf(response.headers?.get('content-type'));
152
+ if (!live) return;
153
+ if (resolved === null) {
154
+ status = 'unsupported';
155
+ return;
156
+ }
157
+ kind = resolved;
158
+ if (resolved === 'pdf') {
159
+ const bytes = new Uint8Array(await response.arrayBuffer());
160
+ if (!live) return;
161
+ opened = await open(bytes);
162
+ if (!live) {
163
+ void opened.destroy();
164
+ opened = null;
165
+ return;
166
+ }
167
+ pdf = opened;
168
+ } else {
169
+ const blob = await response.blob();
170
+ if (!live) return;
171
+ url = URL.createObjectURL(blob);
172
+ imageUrl = url;
173
+ }
174
+ status = 'ready';
175
+ } catch (error) {
176
+ if (!live) return;
177
+ status =
178
+ error instanceof PdfEngineUnavailableError ? 'unsupported' : 'failed';
179
+ }
180
+ })();
181
+ return () => {
182
+ live = false;
183
+ if (url) URL.revokeObjectURL(url);
184
+ if (opened) void opened.destroy();
185
+ };
186
+ });
187
+
188
+ // Draw the page at the sheet's width times the zoom.
189
+ $effect(() => {
190
+ const doc = pdf;
191
+ const target = canvas;
192
+ const at = page;
193
+ const cssWidth = (width || 800) * zoom;
194
+ if (!doc || !target) return;
195
+ doc.renderPage(at, target, cssWidth).catch(() => {
196
+ if (pdf === doc) status = 'failed';
197
+ });
198
+ });
199
+
200
+ function step(delta: number): void {
201
+ if (!pdf) return;
202
+ page = clampPage(page, delta, pdf.pages);
203
+ }
204
+
205
+ // Pinch: two pointers on the sheet; the distance between them scales the file.
206
+ const pointers = new Map<number, { x: number; y: number }>();
207
+ let pinchStart = 0;
208
+ function distance(): number {
209
+ const [a, b] = [...pointers.values()];
210
+ return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
211
+ }
212
+ function pointerDown(event: PointerEvent): void {
213
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
214
+ // Keep receiving moves when a finger drifts outside the sheet.
215
+ (event.currentTarget as HTMLElement | null)?.setPointerCapture?.(
216
+ event.pointerId,
217
+ );
218
+ if (pointers.size === 2) pinchStart = distance();
219
+ }
220
+ function pointerMove(event: PointerEvent): void {
221
+ if (!pointers.has(event.pointerId)) return;
222
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
223
+ if (pointers.size === 2 && pinchStart > 0) {
224
+ pinchScale = clampPinchScale(zoom, distance() / pinchStart);
225
+ }
226
+ }
227
+ function pointerUp(event: PointerEvent): void {
228
+ pointers.delete(event.pointerId);
229
+ if (pointers.size < 2 && pinchStart > 0) {
230
+ zoom = commitPinchZoom(zoom, pinchScale);
231
+ pinchScale = 1;
232
+ pinchStart = 0;
233
+ }
234
+ }
235
+ </script>
236
+
237
+ <div
238
+ id={id}
239
+ class="document-viewer {className}"
240
+ role="group"
241
+ aria-label={label}
242
+ data-state={status}
243
+ data-kind={kind ?? undefined}
244
+ data-smrt-component="DocumentViewer"
245
+ >
246
+ <div class="controls" data-smrt-part="controls">
247
+ {#if pdf && pdf.pages > 1}
248
+ <div class="group" role="group" aria-label={text.pages}>
249
+ <button
250
+ type="button"
251
+ class="ctl"
252
+ data-smrt-part="control"
253
+ data-control="previous"
254
+ aria-label={text.previousPage}
255
+ disabled={page <= 1}
256
+ onclick={() => step(-1)}
257
+ >
258
+ <span aria-hidden="true">◀</span>
259
+ </button>
260
+ <span class="where" data-smrt-part="page-indicator" aria-live="polite">
261
+ {renderTemplate(text.pageOf, { page, pages: pdf.pages })}
262
+ </span>
263
+ <button
264
+ type="button"
265
+ class="ctl"
266
+ data-smrt-part="control"
267
+ data-control="next"
268
+ aria-label={text.nextPage}
269
+ disabled={page >= pdf.pages}
270
+ onclick={() => step(1)}
271
+ >
272
+ <span aria-hidden="true">▶</span>
273
+ </button>
274
+ </div>
275
+ {/if}
276
+ {#if status === 'ready'}
277
+ <div class="group" role="group" aria-label={text.zoom}>
278
+ <button
279
+ type="button"
280
+ class="ctl"
281
+ data-smrt-part="control"
282
+ data-control="zoom-out"
283
+ aria-label={text.zoomOut}
284
+ disabled={zoom <= MIN_ZOOM}
285
+ onclick={() => (zoom = stepZoom(zoom, -1))}
286
+ >
287
+ <span aria-hidden="true">−</span>
288
+ </button>
289
+ <button
290
+ type="button"
291
+ class="ctl fit"
292
+ data-smrt-part="control"
293
+ data-control="fit"
294
+ disabled={zoom === MIN_ZOOM}
295
+ onclick={() => (zoom = MIN_ZOOM)}
296
+ >
297
+ {text.fit}
298
+ </button>
299
+ <button
300
+ type="button"
301
+ class="ctl"
302
+ data-smrt-part="control"
303
+ data-control="zoom-in"
304
+ aria-label={text.zoomIn}
305
+ disabled={zoom >= MAX_ZOOM}
306
+ onclick={() => (zoom = stepZoom(zoom, 1))}
307
+ >
308
+ <span aria-hidden="true">+</span>
309
+ </button>
310
+ </div>
311
+ {/if}
312
+ </div>
313
+
314
+ <!-- svelte-ignore a11y_no_static_element_interactions (pinch is a shortcut; zoom out / Fit / zoom in are the controls) -->
315
+ <div
316
+ class="sheet"
317
+ data-smrt-part="sheet"
318
+ data-zoom={zoom}
319
+ bind:clientWidth={width}
320
+ onpointerdown={pointerDown}
321
+ onpointermove={pointerMove}
322
+ onpointerup={pointerUp}
323
+ onpointercancel={pointerUp}
324
+ >
325
+ {#if status === 'loading'}
326
+ <p class="hold" data-smrt-part="loading" role="status">{text.loading}</p>
327
+ {:else if status === 'failed'}
328
+ <div class="hold" data-smrt-part="failed">
329
+ <p role="alert">{text.failed}</p>
330
+ <button
331
+ type="button"
332
+ class="ctl retry"
333
+ data-smrt-part="control"
334
+ data-control="retry"
335
+ onclick={() => (attempt += 1)}
336
+ >
337
+ {text.retry}
338
+ </button>
339
+ </div>
340
+ {:else if status === 'unsupported'}
341
+ <p class="hold" data-smrt-part="unsupported" role="status">{text.unsupported}</p>
342
+ {:else if kind === 'pdf'}
343
+ <canvas
344
+ bind:this={canvas}
345
+ aria-label={renderTemplate(text.pageLabel, { label, page })}
346
+ style:transform={pinchScale === 1 ? null : `scale(${pinchScale})`}
347
+ ></canvas>
348
+ {:else if kind === 'image' && imageUrl}
349
+ <img
350
+ src={imageUrl}
351
+ alt={label}
352
+ style:width="{zoom * 100}%"
353
+ style:transform={pinchScale === 1 ? null : `scale(${pinchScale})`}
354
+ />
355
+ {/if}
356
+ </div>
357
+ </div>
358
+
359
+ <style>
360
+ @layer smrt.reset, smrt.structure, smrt.theme;
361
+ @layer smrt.theme {
362
+ .document-viewer {
363
+ display: grid;
364
+ gap: var(--smrt-spacing-3);
365
+ color: var(--smrt-color-on-surface);
366
+ }
367
+ .controls {
368
+ display: flex;
369
+ flex-wrap: wrap;
370
+ justify-content: space-between;
371
+ gap: var(--smrt-spacing-3);
372
+ }
373
+ .group {
374
+ display: flex;
375
+ align-items: center;
376
+ gap: var(--smrt-spacing-2);
377
+ }
378
+ /* Glove-size: at least 4rem square, whatever the density token says. */
379
+ .ctl {
380
+ min-width: max(4rem, var(--smrt-touch-target-min));
381
+ min-height: max(4rem, var(--smrt-touch-target-min));
382
+ padding: 0 var(--smrt-spacing-4);
383
+ border: var(--smrt-hairline-strong);
384
+ border-radius: var(--smrt-radius-medium);
385
+ background: var(--smrt-color-surface-container-highest);
386
+ color: var(--smrt-color-on-surface);
387
+ font: var(--smrt-typography-headline-large-font);
388
+ cursor: pointer;
389
+ touch-action: manipulation;
390
+ }
391
+ .ctl.fit,
392
+ .ctl.retry {
393
+ font: var(--smrt-typography-title-large-font);
394
+ }
395
+ .ctl:focus-visible {
396
+ outline: var(--smrt-focus-ring-width) solid var(--smrt-focus-ring-color);
397
+ outline-offset: var(--smrt-focus-ring-offset);
398
+ }
399
+ .ctl:disabled {
400
+ opacity: 0.5;
401
+ cursor: not-allowed;
402
+ }
403
+ .where {
404
+ min-width: 8rem;
405
+ text-align: center;
406
+ font: var(--smrt-typography-title-large-font);
407
+ }
408
+ /* The document's own sheet: scrolls when zoomed past its width. Paper stays
409
+ light in every scheme (a document is content, not chrome). */
410
+ .sheet {
411
+ display: grid;
412
+ justify-items: start;
413
+ align-items: start;
414
+ min-height: 24rem;
415
+ max-height: 70vh;
416
+ overflow: auto;
417
+ border: var(--smrt-hairline-strong);
418
+ border-radius: var(--smrt-radius-small);
419
+ background: var(--smrt-color-surface-container-lowest);
420
+ touch-action: pan-x pan-y;
421
+ }
422
+ canvas,
423
+ img {
424
+ display: block;
425
+ transform-origin: 0 0;
426
+ }
427
+ img {
428
+ max-width: none;
429
+ height: auto;
430
+ }
431
+ .hold {
432
+ display: grid;
433
+ gap: var(--smrt-spacing-4);
434
+ place-items: center;
435
+ align-self: center;
436
+ justify-self: center;
437
+ margin: 0;
438
+ padding: var(--smrt-spacing-8);
439
+ text-align: center;
440
+ font: var(--smrt-typography-title-large-font);
441
+ }
442
+ }
443
+ </style>
@@ -0,0 +1,24 @@
1
+ import { type OpenPdf } from './pdf-types.js';
2
+ import type { DocumentViewerLabels } from './types.js';
3
+ export interface Props {
4
+ /** URL of the file; read with `fetch`. */
5
+ src: string;
6
+ /** `application/pdf` or `image/*`. Omitted, the response's Content-Type decides. */
7
+ mimeType?: string;
8
+ /** Names the document for assistive technology (the image alt, the page canvas). */
9
+ label: string;
10
+ /** Reads the file; inject the app's authenticated fetch. Default: global `fetch`. */
11
+ fetch?: typeof globalThis.fetch;
12
+ /** Opens PDF bytes. Default: pdf.js via `createPdfJsOpener()`. */
13
+ openPdf?: OpenPdf;
14
+ /** Text overrides; defaults come from the `ui` i18n catalog. */
15
+ labels?: Partial<DocumentViewerLabels>;
16
+ /** DOM id of the component root. */
17
+ id?: string;
18
+ /** CSS class to apply to the component root. */
19
+ class?: string;
20
+ }
21
+ declare const DocumentViewer: import("svelte").Component<Props, {}, "">;
22
+ type DocumentViewer = ReturnType<typeof DocumentViewer>;
23
+ export default DocumentViewer;
24
+ //# sourceMappingURL=DocumentViewer.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DocumentViewer.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/display/DocumentViewer.svelte.ts"],"names":[],"mappings":"AAeA,OAAO,EACL,KAAK,OAAO,EAGb,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAGvD,MAAM,WAAW,KAAK;IACpB,0CAA0C;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oFAAoF;IACpF,KAAK,EAAE,MAAM,CAAC;IACd,qFAAqF;IACrF,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,kEAAkE;IAClE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,MAAM,CAAC,EAAE,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACvC,oCAAoC;IACpC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAsPD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}