@schnsrw/casual-sheets 0.2.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.
@@ -0,0 +1,374 @@
1
+ /**
2
+ * SigningPane — the floating sidebar that walks the signer through
3
+ * fields. Lives inside <SigningProvider>; uses the controller via
4
+ * `useSigning()`.
5
+ *
6
+ * Layout: a right-anchored panel showing
7
+ * - Banner (optional, from session config)
8
+ * - Field list with state markers (active / signed / pending)
9
+ * - Method picker for the active field
10
+ * - Capture surface (Drawn / Typed / Uploaded) depending on method
11
+ * - Footer: Cancel + Complete
12
+ *
13
+ * The pane is presentation-only — every action routes through the
14
+ * SigningProvider's helpers (signField, completeIfReady, cancel)
15
+ * so the controller stays the single source of truth.
16
+ */
17
+
18
+ import { useEffect, useState, type CSSProperties } from 'react';
19
+
20
+ import { useSigning } from './SigningProvider';
21
+ import type { SignatureField, SignatureMethod, SignedFieldPayload } from './types';
22
+ import {
23
+ DrawnSignaturePad,
24
+ TypedSignatureField,
25
+ UploadedSignatureField,
26
+ type CapturedSignature,
27
+ } from './captures';
28
+
29
+ export interface SigningPaneProps {
30
+ /** Optional banner override; falls back to session.banner. */
31
+ banner?: string;
32
+ /** Optional data-testid root. */
33
+ testId?: string;
34
+ }
35
+
36
+ export function SigningPane({ banner, testId = 'signing-pane' }: SigningPaneProps) {
37
+ const ctx = useSigning();
38
+ if (!ctx) return null;
39
+
40
+ const { snapshot, signField, completeIfReady, cancel } = ctx;
41
+ if (snapshot.isComplete || snapshot.isCancelled) return null;
42
+
43
+ const active: SignatureField | null =
44
+ snapshot.activeFieldIndex >= 0 ? snapshot.fields[snapshot.activeFieldIndex] : null;
45
+
46
+ return (
47
+ <aside style={paneStyle} role="region" aria-label="Signing pane" data-testid={testId}>
48
+ {banner && (
49
+ <div style={bannerStyle} data-testid={`${testId}-banner`}>
50
+ {banner}
51
+ </div>
52
+ )}
53
+ <div style={listStyle} data-testid={`${testId}-fields`}>
54
+ {snapshot.fields.map((f, i) => {
55
+ const isSigned = !!snapshot.signed[f.fieldId];
56
+ const isActive = i === snapshot.activeFieldIndex;
57
+ return (
58
+ <div
59
+ key={f.fieldId}
60
+ style={listItemStyle(isActive, isSigned)}
61
+ data-testid={`${testId}-field-${f.fieldId}`}
62
+ data-state={isSigned ? 'signed' : isActive ? 'active' : 'pending'}
63
+ >
64
+ <span style={listIconStyle(isSigned)} aria-hidden="true">
65
+ {isSigned ? '✓' : i + 1}
66
+ </span>
67
+ <span style={listLabelStyle}>{f.label}</span>
68
+ {!f.required && (
69
+ <span style={optionalChipStyle} aria-label="Optional">
70
+ optional
71
+ </span>
72
+ )}
73
+ </div>
74
+ );
75
+ })}
76
+ </div>
77
+
78
+ {active && (
79
+ <ActiveFieldEditor
80
+ field={active}
81
+ testId={testId}
82
+ onCapture={async (cap, method) => {
83
+ const payload: SignedFieldPayload = {
84
+ fieldId: active.fieldId,
85
+ method,
86
+ bytes: cap.bytes,
87
+ mime: cap.mime,
88
+ signedAt: new Date().toISOString(),
89
+ };
90
+ await signField(payload);
91
+ }}
92
+ />
93
+ )}
94
+
95
+ {!active && snapshot.canComplete && (
96
+ <div style={completeBlockStyle} data-testid={`${testId}-complete-block`}>
97
+ All required signatures collected. Ready to finalise.
98
+ </div>
99
+ )}
100
+
101
+ <footer style={footerStyle}>
102
+ <button
103
+ type="button"
104
+ onClick={() => cancel('signer_cancelled')}
105
+ style={secondaryBtnStyle()}
106
+ data-testid={`${testId}-cancel`}
107
+ >
108
+ Cancel
109
+ </button>
110
+ <button
111
+ type="button"
112
+ onClick={() => void completeIfReady()}
113
+ disabled={!snapshot.canComplete}
114
+ style={primaryBtnStyle(!snapshot.canComplete)}
115
+ data-testid={`${testId}-complete`}
116
+ >
117
+ Complete
118
+ </button>
119
+ </footer>
120
+ </aside>
121
+ );
122
+ }
123
+
124
+ // ---------------------------------------------------------------
125
+ // Active field editor — method picker + capture surface
126
+ // ---------------------------------------------------------------
127
+
128
+ function ActiveFieldEditor({
129
+ field,
130
+ testId,
131
+ onCapture,
132
+ }: {
133
+ field: SignatureField;
134
+ testId: string;
135
+ onCapture: (cap: CapturedSignature, method: SignatureMethod) => void | Promise<void>;
136
+ }) {
137
+ const [method, setMethod] = useState<SignatureMethod>(field.methods[0]);
138
+
139
+ // Reset the method picker whenever the active field changes — a
140
+ // method that was valid for the previous field may not be in this
141
+ // field's list.
142
+ useEffect(() => {
143
+ setMethod(field.methods[0]);
144
+ }, [field]);
145
+
146
+ return (
147
+ <div style={editorStyle} data-testid={`${testId}-editor`}>
148
+ <div style={editorHeaderStyle}>
149
+ <div style={editorLabelStyle}>{field.label}</div>
150
+ {field.signer?.name && <div style={editorSignerStyle}>{field.signer.name}</div>}
151
+ </div>
152
+ {field.methods.length > 1 && (
153
+ <div style={methodTabsStyle} role="tablist" data-testid={`${testId}-methods`}>
154
+ {field.methods.map((m) => (
155
+ <button
156
+ key={m}
157
+ type="button"
158
+ role="tab"
159
+ aria-selected={method === m}
160
+ onClick={() => setMethod(m)}
161
+ style={methodTabStyle(method === m)}
162
+ data-testid={`${testId}-method-${m}`}
163
+ >
164
+ {methodLabel(m)}
165
+ </button>
166
+ ))}
167
+ </div>
168
+ )}
169
+ <div style={captureWrapStyle}>
170
+ {method === 'drawn' && <DrawnSignaturePad onCapture={(c) => onCapture(c, 'drawn')} />}
171
+ {method === 'typed' && (
172
+ <TypedSignatureField
173
+ defaultText={field.signer?.name ?? ''}
174
+ onCapture={(c) => onCapture(c, 'typed')}
175
+ />
176
+ )}
177
+ {method === 'uploaded' && (
178
+ <UploadedSignatureField onCapture={(c) => onCapture(c, 'uploaded')} />
179
+ )}
180
+ </div>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ function methodLabel(m: SignatureMethod): string {
186
+ switch (m) {
187
+ case 'drawn':
188
+ return 'Draw';
189
+ case 'typed':
190
+ return 'Type';
191
+ case 'uploaded':
192
+ return 'Upload';
193
+ }
194
+ }
195
+
196
+ // ---------------------------------------------------------------
197
+ // Styles
198
+ // ---------------------------------------------------------------
199
+
200
+ const paneStyle: CSSProperties = {
201
+ position: 'fixed',
202
+ top: 16,
203
+ right: 16,
204
+ bottom: 16,
205
+ width: 360,
206
+ maxWidth: '100vw',
207
+ display: 'flex',
208
+ flexDirection: 'column',
209
+ gap: 14,
210
+ padding: 16,
211
+ background: 'var(--doc-surface, #fff)',
212
+ border: '1px solid var(--doc-border, #cbd5e1)',
213
+ borderRadius: 12,
214
+ boxShadow: '0 1px 1px rgba(0, 0, 0, 0.04), 0 6px 24px rgba(15, 23, 42, 0.12)',
215
+ fontFamily: 'inherit',
216
+ zIndex: 9000,
217
+ };
218
+
219
+ const bannerStyle: CSSProperties = {
220
+ padding: '8px 10px',
221
+ background: 'var(--doc-surface-2, #f1f5f9)',
222
+ border: '1px solid var(--doc-border-light, #e2e8f0)',
223
+ borderRadius: 6,
224
+ fontSize: 12,
225
+ color: 'var(--doc-text-muted, #475569)',
226
+ };
227
+
228
+ const listStyle: CSSProperties = {
229
+ display: 'flex',
230
+ flexDirection: 'column',
231
+ gap: 4,
232
+ };
233
+
234
+ function listItemStyle(active: boolean, signed: boolean): CSSProperties {
235
+ return {
236
+ display: 'flex',
237
+ alignItems: 'center',
238
+ gap: 10,
239
+ padding: '8px 10px',
240
+ borderRadius: 6,
241
+ background: active ? 'var(--doc-surface-2, #f1f5f9)' : signed ? 'transparent' : 'transparent',
242
+ border: active ? '1px solid var(--doc-border, #cbd5e1)' : '1px solid transparent',
243
+ opacity: signed && !active ? 0.7 : 1,
244
+ };
245
+ }
246
+
247
+ function listIconStyle(signed: boolean): CSSProperties {
248
+ return {
249
+ display: 'inline-flex',
250
+ alignItems: 'center',
251
+ justifyContent: 'center',
252
+ width: 22,
253
+ height: 22,
254
+ borderRadius: '50%',
255
+ background: signed ? 'var(--doc-accent, #2563eb)' : 'var(--doc-surface-2, #f1f5f9)',
256
+ color: signed ? '#fff' : 'var(--doc-text-muted, #475569)',
257
+ fontSize: 12,
258
+ fontWeight: 600,
259
+ flexShrink: 0,
260
+ };
261
+ }
262
+
263
+ const listLabelStyle: CSSProperties = {
264
+ flex: 1,
265
+ fontSize: 13,
266
+ color: 'var(--doc-text, #0f172a)',
267
+ fontWeight: 500,
268
+ };
269
+
270
+ const optionalChipStyle: CSSProperties = {
271
+ fontSize: 11,
272
+ color: 'var(--doc-text-muted, #64748b)',
273
+ padding: '2px 6px',
274
+ background: 'var(--doc-surface-2, #f1f5f9)',
275
+ borderRadius: 4,
276
+ };
277
+
278
+ const editorStyle: CSSProperties = {
279
+ display: 'flex',
280
+ flexDirection: 'column',
281
+ gap: 12,
282
+ };
283
+
284
+ const editorHeaderStyle: CSSProperties = {
285
+ display: 'flex',
286
+ flexDirection: 'column',
287
+ gap: 2,
288
+ };
289
+
290
+ const editorLabelStyle: CSSProperties = {
291
+ fontSize: 13,
292
+ fontWeight: 600,
293
+ color: 'var(--doc-text, #0f172a)',
294
+ };
295
+
296
+ const editorSignerStyle: CSSProperties = {
297
+ fontSize: 12,
298
+ color: 'var(--doc-text-muted, #64748b)',
299
+ };
300
+
301
+ const methodTabsStyle: CSSProperties = {
302
+ display: 'flex',
303
+ gap: 4,
304
+ padding: 2,
305
+ background: 'var(--doc-surface-2, #f1f5f9)',
306
+ borderRadius: 6,
307
+ };
308
+
309
+ function methodTabStyle(selected: boolean): CSSProperties {
310
+ return {
311
+ flex: 1,
312
+ padding: '6px 10px',
313
+ background: selected ? 'var(--doc-surface, #fff)' : 'transparent',
314
+ border: 'none',
315
+ borderRadius: 4,
316
+ fontSize: 12,
317
+ fontWeight: 500,
318
+ color: selected ? 'var(--doc-text, #0f172a)' : 'var(--doc-text-muted, #475569)',
319
+ cursor: 'pointer',
320
+ fontFamily: 'inherit',
321
+ };
322
+ }
323
+
324
+ const captureWrapStyle: CSSProperties = {
325
+ display: 'flex',
326
+ flexDirection: 'column',
327
+ gap: 8,
328
+ };
329
+
330
+ const completeBlockStyle: CSSProperties = {
331
+ padding: '10px 12px',
332
+ background: 'rgba(34, 197, 94, 0.08)',
333
+ border: '1px solid rgba(34, 197, 94, 0.28)',
334
+ borderRadius: 6,
335
+ fontSize: 12,
336
+ color: 'rgb(20, 83, 45)',
337
+ };
338
+
339
+ const footerStyle: CSSProperties = {
340
+ display: 'flex',
341
+ justifyContent: 'flex-end',
342
+ gap: 8,
343
+ marginTop: 'auto',
344
+ paddingTop: 12,
345
+ borderTop: '1px solid var(--doc-border-light, #e2e8f0)',
346
+ };
347
+
348
+ function primaryBtnStyle(disabled: boolean): CSSProperties {
349
+ return {
350
+ padding: '8px 16px',
351
+ borderRadius: 6,
352
+ border: '1px solid transparent',
353
+ background: disabled ? 'var(--doc-border, #cbd5e1)' : 'var(--doc-accent, #2563eb)',
354
+ color: disabled ? 'var(--doc-text-muted, #64748b)' : '#fff',
355
+ fontSize: 13,
356
+ fontWeight: 600,
357
+ cursor: disabled ? 'not-allowed' : 'pointer',
358
+ fontFamily: 'inherit',
359
+ };
360
+ }
361
+
362
+ function secondaryBtnStyle(): CSSProperties {
363
+ return {
364
+ padding: '8px 16px',
365
+ borderRadius: 6,
366
+ border: '1px solid var(--doc-border, #cbd5e1)',
367
+ background: 'transparent',
368
+ color: 'var(--doc-text, #0f172a)',
369
+ fontSize: 13,
370
+ fontWeight: 500,
371
+ cursor: 'pointer',
372
+ fontFamily: 'inherit',
373
+ };
374
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * SigningProvider — wraps a signing session in React context so
3
+ * descendant components (SigningPane, capture surfaces, and the
4
+ * editor's field-highlight decorations) all see the same snapshot.
5
+ *
6
+ * The pure state machine lives in `./controller.ts`; this file
7
+ * just bridges it to React.
8
+ */
9
+
10
+ import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
11
+
12
+ import {
13
+ createSigningController,
14
+ type SigningController,
15
+ type SigningSnapshot,
16
+ } from './controller';
17
+ import type {
18
+ CancelReason,
19
+ SignatureCompletePayload,
20
+ SignedFieldPayload,
21
+ SigningSessionConfig,
22
+ } from './types';
23
+
24
+ interface SigningContextValue {
25
+ controller: SigningController;
26
+ snapshot: SigningSnapshot;
27
+ /** Convenience wrapper around controller.signField that also fires the host's onFieldSigned. */
28
+ signField: (payload: SignedFieldPayload) => Promise<void>;
29
+ /** Convenience wrapper around controller.complete + host's onComplete. */
30
+ completeIfReady: () => Promise<void>;
31
+ /** Convenience wrapper around controller.cancel + host's onCancel. */
32
+ cancel: (reason: CancelReason) => void;
33
+ /** Source-of-truth document bytes the editor renders. Captured at
34
+ * signing-session open; persists for the lifetime of the session. */
35
+ baseDocumentBytes: ArrayBuffer | null;
36
+ }
37
+
38
+ const SigningContext = createContext<SigningContextValue | null>(null);
39
+
40
+ export interface SigningProviderProps {
41
+ /** Active signing session config. When null, signing is off and
42
+ * children render unchanged. */
43
+ session: SigningSessionConfig | null;
44
+ /** Current document bytes the editor is rendering. Captured into
45
+ * the context so the eventual `complete` payload carries the
46
+ * right base buffer. */
47
+ documentBytes: ArrayBuffer | null;
48
+ children: ReactNode;
49
+ }
50
+
51
+ export function SigningProvider({ session, documentBytes, children }: SigningProviderProps) {
52
+ if (!session) {
53
+ return <>{children}</>;
54
+ }
55
+ return (
56
+ <SigningProviderInner session={session} documentBytes={documentBytes}>
57
+ {children}
58
+ </SigningProviderInner>
59
+ );
60
+ }
61
+
62
+ function SigningProviderInner({
63
+ session,
64
+ documentBytes,
65
+ children,
66
+ }: {
67
+ session: SigningSessionConfig;
68
+ documentBytes: ArrayBuffer | null;
69
+ children: ReactNode;
70
+ }) {
71
+ // Controller is constructed once per session-config identity.
72
+ // Hosts that swap sessions mid-tree must change the React `key`
73
+ // on the provider — otherwise stale state would leak.
74
+ const controller = useMemo(
75
+ () => createSigningController(session.fields, session.mode),
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ [session.fields, session.mode],
78
+ );
79
+
80
+ const [snapshot, setSnapshot] = useState<SigningSnapshot>(() => controller.snapshot());
81
+
82
+ useEffect(() => {
83
+ const unsub = controller.subscribe(setSnapshot);
84
+ return unsub;
85
+ }, [controller]);
86
+
87
+ const value = useMemo<SigningContextValue>(
88
+ () => ({
89
+ controller,
90
+ snapshot,
91
+ signField: async (payload) => {
92
+ controller.signField(payload);
93
+ await session.onFieldSigned?.(payload);
94
+ },
95
+ completeIfReady: async () => {
96
+ if (!controller.snapshot().canComplete) return;
97
+ const final = controller.complete();
98
+ const completePayload: SignatureCompletePayload = {
99
+ fieldIds: final.fields
100
+ .map((f) => f.fieldId)
101
+ .filter((id) => final.signed[id] !== undefined),
102
+ bytes: documentBytes ?? new ArrayBuffer(0),
103
+ fields: final.signed,
104
+ };
105
+ await session.onComplete?.(completePayload);
106
+ },
107
+ cancel: (reason) => {
108
+ controller.cancel();
109
+ session.onCancel?.({ reason });
110
+ },
111
+ baseDocumentBytes: documentBytes,
112
+ }),
113
+ [controller, snapshot, session, documentBytes],
114
+ );
115
+
116
+ return <SigningContext.Provider value={value}>{children}</SigningContext.Provider>;
117
+ }
118
+
119
+ /**
120
+ * Hook for descendants of <SigningProvider>. Returns null when
121
+ * no signing session is active — caller renders its non-signing
122
+ * shape.
123
+ */
124
+ export function useSigning(): SigningContextValue | null {
125
+ return useContext(SigningContext);
126
+ }