@terreno/ui 0.14.2 → 0.15.1

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 (67) hide show
  1. package/dist/Badge.js +1 -0
  2. package/dist/Badge.js.map +1 -1
  3. package/dist/Banner.d.ts +8 -0
  4. package/dist/Banner.js +2 -2
  5. package/dist/Banner.js.map +1 -1
  6. package/dist/Common.d.ts +1 -0
  7. package/dist/Common.js.map +1 -1
  8. package/dist/ConsentFormScreen.js +4 -2
  9. package/dist/ConsentFormScreen.js.map +1 -1
  10. package/dist/DismissButton.js +3 -2
  11. package/dist/DismissButton.js.map +1 -1
  12. package/dist/PickerSelect.js +6 -2
  13. package/dist/PickerSelect.js.map +1 -1
  14. package/dist/Signature.d.ts +9 -1
  15. package/dist/Signature.js +121 -18
  16. package/dist/Signature.js.map +1 -1
  17. package/dist/Signature.native.d.ts +16 -0
  18. package/dist/Signature.native.js +119 -23
  19. package/dist/Signature.native.js.map +1 -1
  20. package/dist/SignatureField.d.ts +1 -1
  21. package/dist/SignatureField.js +2 -2
  22. package/dist/SignatureField.js.map +1 -1
  23. package/dist/SignatureSizing.d.ts +3 -0
  24. package/dist/SignatureSizing.js +9 -0
  25. package/dist/SignatureSizing.js.map +1 -0
  26. package/dist/TapToEdit.js +1 -1
  27. package/dist/TapToEdit.js.map +1 -1
  28. package/dist/Toast.d.ts +4 -4
  29. package/dist/Toast.js.map +1 -1
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +1 -1
  32. package/dist/index.js.map +1 -1
  33. package/package.json +2 -4
  34. package/src/Badge.test.tsx +7 -0
  35. package/src/Badge.tsx +1 -0
  36. package/src/Banner.test.tsx +23 -3
  37. package/src/Banner.tsx +3 -3
  38. package/src/Common.ts +1 -0
  39. package/src/ConsentFormScreen.test.tsx +15 -0
  40. package/src/ConsentFormScreen.tsx +21 -4
  41. package/src/DateTimeField.test.tsx +226 -0
  42. package/src/DismissButton.tsx +4 -3
  43. package/src/Field.test.tsx +23 -0
  44. package/src/IconButton.tsx +2 -2
  45. package/src/PickerSelect.test.tsx +22 -0
  46. package/src/PickerSelect.tsx +24 -8
  47. package/src/Signature.native.test.tsx +9 -0
  48. package/src/Signature.native.tsx +152 -33
  49. package/src/Signature.test.tsx +324 -39
  50. package/src/Signature.tsx +171 -22
  51. package/src/SignatureField.test.tsx +0 -9
  52. package/src/SignatureField.tsx +2 -0
  53. package/src/SignatureSizing.ts +10 -0
  54. package/src/TapToEdit.test.tsx +33 -0
  55. package/src/TapToEdit.tsx +1 -1
  56. package/src/Toast.tsx +5 -3
  57. package/src/ToastNotifications.test.tsx +74 -1
  58. package/src/__snapshots__/CustomSelectField.test.tsx.snap +5 -4
  59. package/src/__snapshots__/DismissButton.test.tsx.snap +9 -3
  60. package/src/__snapshots__/Field.test.tsx.snap +379 -0
  61. package/src/__snapshots__/PickerSelect.test.tsx.snap +5 -4
  62. package/src/__snapshots__/SegmentedControl.test.tsx.snap +9 -0
  63. package/src/__snapshots__/SelectField.test.tsx.snap +5 -4
  64. package/src/__snapshots__/Signature.test.tsx.snap +15 -3
  65. package/src/__snapshots__/SignatureField.test.tsx.snap +12 -3
  66. package/src/bunSetup.ts +0 -15
  67. package/src/index.tsx +1 -1
@@ -1,30 +1,51 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
2
- import {describe, expect, it, mock} from "bun:test";
3
- import {fireEvent} from "@testing-library/react-native";
4
- import {forwardRef, useImperativeHandle} from "react";
1
+ import {afterEach, describe, expect, it, mock} from "bun:test";
2
+ import {act, fireEvent} from "@testing-library/react-native";
5
3
  import {View} from "react-native";
6
4
 
7
5
  import {Signature} from "./Signature";
8
6
  import {renderWithTheme} from "./test-utils";
9
7
 
10
- const clearMock = mock(() => {});
11
- let toDataURLReturn: string = "";
12
- const toDataURLMock = mock(() => toDataURLReturn);
13
- let lastOnEnd: (() => void) | undefined;
14
-
15
- // Mock react-signature-canvas so we can exercise the ref methods and onEnd callback.
16
- mock.module("react-signature-canvas", () => ({
17
- default: forwardRef(({backgroundColor, onEnd}: any, ref) => {
18
- lastOnEnd = onEnd;
19
- useImperativeHandle(ref, () => ({
20
- clear: clearMock,
21
- toDataURL: toDataURLMock,
22
- }));
23
- return <View style={{backgroundColor}} testID="signature-canvas" />;
24
- }),
25
- }));
8
+ const createMockContext = (): Record<string, ReturnType<typeof mock>> => ({
9
+ beginPath: mock(() => {}),
10
+ fillRect: mock(() => {}),
11
+ lineTo: mock(() => {}),
12
+ moveTo: mock(() => {}),
13
+ stroke: mock(() => {}),
14
+ });
15
+
16
+ const patchCanvasRefs = (
17
+ container: {
18
+ findAll: (
19
+ predicate: (node: {type: string}) => boolean
20
+ ) => Array<{props: Record<string, unknown>}>;
21
+ },
22
+ ctx: Record<string, ReturnType<typeof mock>>
23
+ ): HTMLCanvasElement => {
24
+ const canvasNodes = container.findAll((node: {type: string}) => node.type === "canvas");
25
+ const canvasNode = canvasNodes[0];
26
+ const canvas = {
27
+ getContext: mock(() => ctx),
28
+ height: 180,
29
+ releasePointerCapture: mock(() => {}),
30
+ setPointerCapture: mock(() => {}),
31
+ toDataURL: mock(() => "data:image/png;base64,AAAA"),
32
+ width: 300,
33
+ } as unknown as HTMLCanvasElement;
34
+
35
+ const ref = canvasNode.props.ref;
36
+ if (typeof ref === "function") {
37
+ ref(canvas);
38
+ } else if (ref && typeof ref === "object" && "current" in ref) {
39
+ (ref as {current: unknown}).current = canvas;
40
+ }
41
+ return canvas;
42
+ };
26
43
 
27
44
  describe("Signature", () => {
45
+ afterEach(() => {
46
+ mock.restore();
47
+ });
48
+
28
49
  it("renders correctly", () => {
29
50
  const mockOnChange = mock(() => {});
30
51
  const {toJSON} = renderWithTheme(<Signature onChange={mockOnChange} />);
@@ -42,39 +63,303 @@ describe("Signature", () => {
42
63
  expect(getByText("Clear")).toBeTruthy();
43
64
  });
44
65
 
45
- it("calls clear on the signature canvas when Clear is pressed", () => {
46
- clearMock.mockClear();
66
+ it("scales the web canvas to the available container width", () => {
47
67
  const mockOnChange = mock(() => {});
48
- const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
49
- fireEvent.press(getByText("Clear"));
50
- expect(clearMock).toHaveBeenCalledTimes(1);
68
+ const {UNSAFE_getByType} = renderWithTheme(<Signature onChange={mockOnChange} />);
69
+ const canvas = UNSAFE_getByType("canvas");
70
+ expect(canvas.props.style).toMatchObject({maxWidth: "100%", width: "100%"});
71
+ });
72
+
73
+ it("allows the signature box to fill its parent", () => {
74
+ const mockOnChange = mock(() => {});
75
+ const {UNSAFE_getAllByType} = renderWithTheme(<Signature fullWidth onChange={mockOnChange} />);
76
+ const wrapper = UNSAFE_getAllByType(View)[1];
77
+ expect(wrapper.props.style).toMatchObject({maxWidth: undefined, width: "100%"});
78
+ });
79
+
80
+ it("maps scaled canvas pointer coordinates to the canvas buffer", () => {
81
+ const mockOnChange = mock(() => {});
82
+ const moveTo = mock(() => {});
83
+ const lineTo = mock(() => {});
84
+ const {UNSAFE_getByType} = renderWithTheme(<Signature onChange={mockOnChange} />);
85
+ const canvas = UNSAFE_getByType("canvas");
86
+ canvas.props.ref.current = {
87
+ clientHeight: 180,
88
+ clientWidth: 150,
89
+ getBoundingClientRect: () => ({height: 180, width: 150}),
90
+ getContext: () => ({
91
+ beginPath: mock(() => {}),
92
+ fillRect: mock(() => {}),
93
+ lineCap: "round",
94
+ lineJoin: "round",
95
+ lineTo,
96
+ moveTo,
97
+ stroke: mock(() => {}),
98
+ }),
99
+ height: 180,
100
+ setPointerCapture: mock(() => {}),
101
+ width: 300,
102
+ } as unknown as HTMLCanvasElement;
103
+
104
+ canvas.props.onPointerDown({
105
+ nativeEvent: {offsetX: 75, offsetY: 90},
106
+ pointerId: 1,
107
+ });
108
+ canvas.props.onPointerMove({
109
+ nativeEvent: {offsetX: 90, offsetY: 120},
110
+ pointerId: 1,
111
+ });
112
+
113
+ expect(moveTo).toHaveBeenCalledWith(150, 90);
114
+ expect(lineTo).toHaveBeenCalledWith(180, 120);
51
115
  });
52
116
 
53
117
  it("notifies the parent with an empty value when Clear is pressed", () => {
54
- clearMock.mockClear();
55
118
  const mockOnChange = mock(() => {});
56
119
  const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
57
120
  fireEvent.press(getByText("Clear"));
58
- // Without this, "signature required" gating in parents would never reset
59
- // because the underlying canvas clear() does not fire onEnd/onOK.
60
121
  expect(mockOnChange).toHaveBeenCalledWith("");
61
122
  });
62
123
 
63
- it("calls onChange with the data URL when a stroke ends", () => {
64
- toDataURLReturn = "data:image/png;base64,abc";
124
+ it("calls onStart when pointer down is fired on the canvas", () => {
125
+ const mockOnChange = mock(() => {});
126
+ const mockOnStart = mock(() => {});
127
+ const ctx = createMockContext();
128
+ const {UNSAFE_root} = renderWithTheme(
129
+ <Signature onChange={mockOnChange} onStart={mockOnStart} />
130
+ );
131
+
132
+ patchCanvasRefs(UNSAFE_root, ctx);
133
+
134
+ const canvasNodes = UNSAFE_root.findAll((node: {type: string}) => node.type === "canvas");
135
+ const canvasProps = canvasNodes[0].props as Record<string, (...args: unknown[]) => void>;
136
+
137
+ act(() => {
138
+ canvasProps.onPointerDown({
139
+ nativeEvent: {offsetX: 10, offsetY: 20},
140
+ pointerId: 1,
141
+ });
142
+ });
143
+
144
+ expect(mockOnStart).toHaveBeenCalledTimes(1);
145
+ expect(ctx.beginPath).toHaveBeenCalled();
146
+ expect(ctx.moveTo).toHaveBeenCalledWith(10, 20);
147
+ });
148
+
149
+ it("draws a stroke on pointerMove after pointerDown", () => {
65
150
  const mockOnChange = mock(() => {});
66
- renderWithTheme(<Signature onChange={mockOnChange} />);
67
- expect(lastOnEnd).toBeDefined();
68
- lastOnEnd?.();
69
- expect(mockOnChange).toHaveBeenCalledWith("data:image/png;base64,abc");
151
+ const ctx = createMockContext();
152
+ const {UNSAFE_root} = renderWithTheme(<Signature onChange={mockOnChange} />);
153
+
154
+ patchCanvasRefs(UNSAFE_root, ctx);
155
+
156
+ const canvasNodes = UNSAFE_root.findAll((node: {type: string}) => node.type === "canvas");
157
+ const canvasProps = canvasNodes[0].props as Record<string, (...args: unknown[]) => void>;
158
+
159
+ act(() => {
160
+ canvasProps.onPointerDown({
161
+ nativeEvent: {offsetX: 5, offsetY: 5},
162
+ pointerId: 1,
163
+ });
164
+ });
165
+
166
+ act(() => {
167
+ canvasProps.onPointerMove({
168
+ nativeEvent: {offsetX: 50, offsetY: 50},
169
+ pointerId: 1,
170
+ });
171
+ });
172
+
173
+ expect(ctx.lineTo).toHaveBeenCalledWith(50, 50);
174
+ expect(ctx.stroke).toHaveBeenCalled();
70
175
  });
71
176
 
72
- it("does not call onChange when toDataURL returns an empty value", () => {
73
- toDataURLReturn = "";
177
+ it("ignores pointerMove when not drawing", () => {
74
178
  const mockOnChange = mock(() => {});
75
- renderWithTheme(<Signature onChange={mockOnChange} />);
76
- expect(lastOnEnd).toBeDefined();
77
- lastOnEnd?.();
179
+ const ctx = createMockContext();
180
+ const {UNSAFE_root} = renderWithTheme(<Signature onChange={mockOnChange} />);
181
+
182
+ patchCanvasRefs(UNSAFE_root, ctx);
183
+
184
+ const canvasNodes = UNSAFE_root.findAll((node: {type: string}) => node.type === "canvas");
185
+ const canvasProps = canvasNodes[0].props as Record<string, (...args: unknown[]) => void>;
186
+
187
+ act(() => {
188
+ canvasProps.onPointerMove({
189
+ nativeEvent: {offsetX: 50, offsetY: 50},
190
+ pointerId: 1,
191
+ });
192
+ });
193
+
194
+ expect(ctx.lineTo).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it("exports the signature as a data URL on pointerUp after drawing", () => {
198
+ const mockOnChange = mock(() => {});
199
+ const mockOnEnd = mock(() => {});
200
+ const ctx = createMockContext();
201
+ const {UNSAFE_root} = renderWithTheme(<Signature onChange={mockOnChange} onEnd={mockOnEnd} />);
202
+
203
+ const canvas = patchCanvasRefs(UNSAFE_root, ctx);
204
+
205
+ const canvasNodes = UNSAFE_root.findAll((node: {type: string}) => node.type === "canvas");
206
+ const canvasProps = canvasNodes[0].props as Record<string, (...args: unknown[]) => void>;
207
+
208
+ // Start drawing
209
+ act(() => {
210
+ canvasProps.onPointerDown({
211
+ nativeEvent: {offsetX: 5, offsetY: 5},
212
+ pointerId: 1,
213
+ });
214
+ });
215
+
216
+ // Move to draw
217
+ act(() => {
218
+ canvasProps.onPointerMove({
219
+ nativeEvent: {offsetX: 50, offsetY: 50},
220
+ pointerId: 1,
221
+ });
222
+ });
223
+
224
+ // Lift the pointer
225
+ act(() => {
226
+ canvasProps.onPointerUp({
227
+ nativeEvent: {offsetX: 50, offsetY: 50},
228
+ pointerId: 1,
229
+ });
230
+ });
231
+
232
+ expect(mockOnChange).toHaveBeenCalledWith("data:image/png;base64,AAAA");
233
+ expect(mockOnEnd).toHaveBeenCalledTimes(1);
234
+ expect(
235
+ (canvas as unknown as {releasePointerCapture: ReturnType<typeof mock>}).releasePointerCapture
236
+ ).toHaveBeenCalledWith(1);
237
+ });
238
+
239
+ it("does not call onChange on pointerUp if nothing was drawn", () => {
240
+ const mockOnChange = mock(() => {});
241
+ const ctx = createMockContext();
242
+ const {UNSAFE_root} = renderWithTheme(<Signature onChange={mockOnChange} />);
243
+
244
+ patchCanvasRefs(UNSAFE_root, ctx);
245
+
246
+ const canvasNodes = UNSAFE_root.findAll((node: {type: string}) => node.type === "canvas");
247
+ const canvasProps = canvasNodes[0].props as Record<string, (...args: unknown[]) => void>;
248
+
249
+ // pointerDown without any move (no drawing)
250
+ act(() => {
251
+ canvasProps.onPointerDown({
252
+ nativeEvent: {offsetX: 5, offsetY: 5},
253
+ pointerId: 1,
254
+ });
255
+ });
256
+
257
+ act(() => {
258
+ canvasProps.onPointerUp({
259
+ nativeEvent: {offsetX: 5, offsetY: 5},
260
+ pointerId: 1,
261
+ });
262
+ });
263
+
264
+ // onChange should not be called with a data URL (no strokes were made)
78
265
  expect(mockOnChange).not.toHaveBeenCalled();
79
266
  });
267
+
268
+ it("ignores pointerUp when not drawing", () => {
269
+ const mockOnChange = mock(() => {});
270
+ const mockOnEnd = mock(() => {});
271
+ const ctx = createMockContext();
272
+ const {UNSAFE_root} = renderWithTheme(<Signature onChange={mockOnChange} onEnd={mockOnEnd} />);
273
+
274
+ patchCanvasRefs(UNSAFE_root, ctx);
275
+
276
+ const canvasNodes = UNSAFE_root.findAll((node: {type: string}) => node.type === "canvas");
277
+ const canvasProps = canvasNodes[0].props as Record<string, (...args: unknown[]) => void>;
278
+
279
+ act(() => {
280
+ canvasProps.onPointerUp({
281
+ nativeEvent: {offsetX: 50, offsetY: 50},
282
+ pointerId: 1,
283
+ });
284
+ });
285
+
286
+ expect(mockOnEnd).not.toHaveBeenCalled();
287
+ expect(mockOnChange).not.toHaveBeenCalled();
288
+ });
289
+
290
+ it("handles pointerLeave the same as pointerUp", () => {
291
+ const mockOnChange = mock(() => {});
292
+ const mockOnEnd = mock(() => {});
293
+ const ctx = createMockContext();
294
+ const {UNSAFE_root} = renderWithTheme(<Signature onChange={mockOnChange} onEnd={mockOnEnd} />);
295
+
296
+ patchCanvasRefs(UNSAFE_root, ctx);
297
+
298
+ const canvasNodes = UNSAFE_root.findAll((node: {type: string}) => node.type === "canvas");
299
+ const canvasProps = canvasNodes[0].props as Record<string, (...args: unknown[]) => void>;
300
+
301
+ // Draw something
302
+ act(() => {
303
+ canvasProps.onPointerDown({
304
+ nativeEvent: {offsetX: 5, offsetY: 5},
305
+ pointerId: 1,
306
+ });
307
+ });
308
+ act(() => {
309
+ canvasProps.onPointerMove({
310
+ nativeEvent: {offsetX: 50, offsetY: 50},
311
+ pointerId: 1,
312
+ });
313
+ });
314
+
315
+ // Leave triggers pointerUp handler
316
+ act(() => {
317
+ canvasProps.onPointerLeave({
318
+ nativeEvent: {offsetX: 50, offsetY: 50},
319
+ pointerId: 1,
320
+ });
321
+ });
322
+
323
+ expect(mockOnChange).toHaveBeenCalledWith("data:image/png;base64,AAAA");
324
+ expect(mockOnEnd).toHaveBeenCalledTimes(1);
325
+ });
326
+
327
+ it("resets canvas and notifies parent on clear after drawing", () => {
328
+ const mockOnChange = mock(() => {});
329
+ const ctx = createMockContext();
330
+ const {UNSAFE_root, getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
331
+
332
+ patchCanvasRefs(UNSAFE_root, ctx);
333
+
334
+ const canvasNodes = UNSAFE_root.findAll((node: {type: string}) => node.type === "canvas");
335
+ const canvasProps = canvasNodes[0].props as Record<string, (...args: unknown[]) => void>;
336
+
337
+ // Draw
338
+ act(() => {
339
+ canvasProps.onPointerDown({
340
+ nativeEvent: {offsetX: 5, offsetY: 5},
341
+ pointerId: 1,
342
+ });
343
+ });
344
+ act(() => {
345
+ canvasProps.onPointerMove({
346
+ nativeEvent: {offsetX: 50, offsetY: 50},
347
+ pointerId: 1,
348
+ });
349
+ });
350
+ act(() => {
351
+ canvasProps.onPointerUp({
352
+ nativeEvent: {offsetX: 50, offsetY: 50},
353
+ pointerId: 1,
354
+ });
355
+ });
356
+
357
+ mockOnChange.mockClear();
358
+
359
+ // Clear
360
+ fireEvent.press(getByText("Clear"));
361
+
362
+ expect(mockOnChange).toHaveBeenCalledWith("");
363
+ expect(ctx.fillRect).toHaveBeenCalled();
364
+ });
80
365
  });
package/src/Signature.tsx CHANGED
@@ -1,6 +1,11 @@
1
- import {type ReactElement, useRef} from "react";
1
+ import {
2
+ type ReactElement,
3
+ type PointerEvent as ReactPointerEvent,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ } from "react";
2
8
  import {Text, View} from "react-native";
3
- import SignatureCanvas from "react-signature-canvas";
4
9
 
5
10
  import {useTheme} from "./Theme";
6
11
 
@@ -8,38 +13,182 @@ export interface SignatureProps {
8
13
  onChange: (signature: string) => void;
9
14
  onStart?: () => void;
10
15
  onEnd?: () => void;
16
+ fullWidth?: boolean;
11
17
  value?: string; // note this
12
18
  }
13
19
 
14
- export const Signature = ({onChange}: SignatureProps): ReactElement | null => {
15
- const ref = useRef<SignatureCanvas>(null);
20
+ const SIGNATURE_WIDTH_PX = 300;
21
+ const SIGNATURE_HEIGHT_PX = 180;
22
+ const STROKE_WIDTH_PX = 2.5;
23
+
24
+ interface CanvasPoint {
25
+ x: number;
26
+ y: number;
27
+ }
28
+
29
+ type DrawingEvent = ReactPointerEvent<HTMLCanvasElement>;
30
+
31
+ /**
32
+ * Web signature pad backed by a raw HTML5 <canvas> — no third-party library.
33
+ *
34
+ * Pointer events capture strokes and the canvas is exported as a base64 PNG
35
+ * data URL via onChange. Clearing pushes "" so "signature required" gating in
36
+ * parents resets immediately, since clearing the canvas emits no draw event.
37
+ */
38
+ export const Signature = ({
39
+ fullWidth = false,
40
+ onChange,
41
+ onStart,
42
+ onEnd,
43
+ }: SignatureProps): ReactElement => {
16
44
  const {theme} = useTheme();
45
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
46
+ const isDrawingRef = useRef(false);
47
+ const hasDrawnRef = useRef(false);
17
48
 
18
- const onClear = () => {
19
- ref.current?.clear();
20
- // `clear()` on the underlying canvas does not fire `onEnd`, so the parent
21
- // never learns the signature is gone. Push an empty value so any
22
- // "signature required" gating reflects the cleared state immediately.
23
- onChange("");
24
- };
49
+ const getContext = useCallback((): CanvasRenderingContext2D | null => {
50
+ const canvas = canvasRef.current;
51
+ if (!canvas || typeof canvas.getContext !== "function") {
52
+ return null;
53
+ }
54
+ return canvas.getContext("2d");
55
+ }, []);
56
+
57
+ const getCanvasPoint = useCallback((event: DrawingEvent): CanvasPoint | null => {
58
+ const canvas = canvasRef.current;
59
+ if (!canvas) {
60
+ return null;
61
+ }
62
+
63
+ const rect = canvas.getBoundingClientRect?.();
64
+ const displayWidth = rect?.width ?? canvas.clientWidth ?? canvas.width;
65
+ const displayHeight = rect?.height ?? canvas.clientHeight ?? canvas.height;
66
+ if (displayWidth <= 0 || displayHeight <= 0) {
67
+ return {
68
+ x: event.nativeEvent.offsetX,
69
+ y: event.nativeEvent.offsetY,
70
+ };
71
+ }
72
+
73
+ return {
74
+ x: (event.nativeEvent.offsetX / displayWidth) * canvas.width,
75
+ y: (event.nativeEvent.offsetY / displayHeight) * canvas.height,
76
+ };
77
+ }, []);
25
78
 
26
- const onUpdatedSignature = () => {
27
- if (ref.current?.toDataURL()) {
28
- onChange(ref.current.toDataURL());
79
+ /**
80
+ * Paints the opaque background and configures stroke styling. Re-runs when
81
+ * the theme changes so the pad matches the active light/dark colors.
82
+ */
83
+ const resetCanvas = useCallback((): void => {
84
+ const canvas = canvasRef.current;
85
+ const ctx = getContext();
86
+ if (!canvas || !ctx) {
87
+ return;
29
88
  }
30
- };
89
+ ctx.fillStyle = theme.surface.base;
90
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
91
+ ctx.strokeStyle = theme.text.secondaryDark;
92
+ ctx.lineWidth = STROKE_WIDTH_PX;
93
+ ctx.lineCap = "round";
94
+ ctx.lineJoin = "round";
95
+ }, [getContext, theme.surface.base, theme.text.secondaryDark]);
96
+
97
+ // Initialize the canvas background and stroke styling once the element mounts.
98
+ useEffect((): void => {
99
+ resetCanvas();
100
+ }, [resetCanvas]);
101
+
102
+ const handlePointerDown = useCallback(
103
+ (event: DrawingEvent): void => {
104
+ const ctx = getContext();
105
+ const point = getCanvasPoint(event);
106
+ if (!ctx || !point) {
107
+ return;
108
+ }
109
+ canvasRef.current?.setPointerCapture?.(event.pointerId);
110
+ isDrawingRef.current = true;
111
+ ctx.beginPath();
112
+ ctx.moveTo(point.x, point.y);
113
+ onStart?.();
114
+ },
115
+ [getCanvasPoint, getContext, onStart]
116
+ );
117
+
118
+ const handlePointerMove = useCallback(
119
+ (event: DrawingEvent): void => {
120
+ if (!isDrawingRef.current) {
121
+ return;
122
+ }
123
+ const ctx = getContext();
124
+ const point = getCanvasPoint(event);
125
+ if (!ctx || !point) {
126
+ return;
127
+ }
128
+ ctx.lineTo(point.x, point.y);
129
+ ctx.stroke();
130
+ hasDrawnRef.current = true;
131
+ },
132
+ [getCanvasPoint, getContext]
133
+ );
134
+
135
+ const handlePointerUp = useCallback(
136
+ (event: DrawingEvent): void => {
137
+ if (!isDrawingRef.current) {
138
+ return;
139
+ }
140
+ isDrawingRef.current = false;
141
+ canvasRef.current?.releasePointerCapture?.(event.pointerId);
142
+ const canvas = canvasRef.current;
143
+ if (hasDrawnRef.current && canvas) {
144
+ onChange(canvas.toDataURL("image/png"));
145
+ }
146
+ onEnd?.();
147
+ },
148
+ [onChange, onEnd]
149
+ );
150
+
151
+ const handleClear = useCallback((): void => {
152
+ hasDrawnRef.current = false;
153
+ isDrawingRef.current = false;
154
+ resetCanvas();
155
+ // Clearing the canvas emits no draw event, so notify the parent directly
156
+ // to reset any "signature required" gating.
157
+ onChange("");
158
+ }, [resetCanvas, onChange]);
159
+
31
160
  return (
32
161
  <View>
33
- <View style={{borderColor: theme.border.dark, borderWidth: 1, maxWidth: 300, width: "100%"}}>
34
- <SignatureCanvas
35
- backgroundColor={theme.surface.base}
36
- onEnd={onUpdatedSignature}
37
- penColor={theme.text.secondaryDark}
38
- ref={ref}
162
+ <View
163
+ style={{
164
+ borderColor: theme.border.dark,
165
+ borderWidth: 1,
166
+ maxWidth: fullWidth ? undefined : SIGNATURE_WIDTH_PX,
167
+ width: "100%",
168
+ }}
169
+ >
170
+ <canvas
171
+ height={SIGNATURE_HEIGHT_PX}
172
+ onPointerDown={handlePointerDown}
173
+ onPointerLeave={handlePointerUp}
174
+ onPointerMove={handlePointerMove}
175
+ onPointerUp={handlePointerUp}
176
+ ref={canvasRef}
177
+ style={{
178
+ display: "block",
179
+ height: SIGNATURE_HEIGHT_PX,
180
+ maxWidth: "100%",
181
+ touchAction: "none",
182
+ width: "100%",
183
+ }}
184
+ width={SIGNATURE_WIDTH_PX}
39
185
  />
40
186
  </View>
41
187
  <View>
42
- <Text onPress={onClear} style={{color: theme.text.link, textDecorationLine: "underline"}}>
188
+ <Text
189
+ onPress={handleClear}
190
+ style={{color: theme.text.link, textDecorationLine: "underline"}}
191
+ >
43
192
  Clear
44
193
  </Text>
45
194
  </View>
@@ -1,18 +1,9 @@
1
1
  import {describe, expect, it, mock} from "bun:test";
2
- import {forwardRef} from "react";
3
- import {View} from "react-native";
4
2
  import type {ReactTestInstance} from "react-test-renderer";
5
3
 
6
4
  import {SignatureField} from "./SignatureField";
7
5
  import {renderWithTheme} from "./test-utils";
8
6
 
9
- // Mock react-signature-canvas (used by Signature component)
10
- mock.module("react-signature-canvas", () => ({
11
- default: forwardRef<View, {backgroundColor?: string}>(({backgroundColor}, ref) => (
12
- <View ref={ref} style={{backgroundColor}} testID="signature-canvas" />
13
- )),
14
- }));
15
-
16
7
  describe("SignatureField", () => {
17
8
  const defaultProps = {
18
9
  onChange: () => {},
@@ -20,6 +20,7 @@ export const SignatureField = ({
20
20
  onEnd,
21
21
  disabledText,
22
22
  errorText,
23
+ fullWidth = false,
23
24
  }: SignatureFieldProps): ReactElement => {
24
25
  const {theme} = useTheme();
25
26
  if (disabled) {
@@ -62,6 +63,7 @@ export const SignatureField = ({
62
63
  )}
63
64
  <View style={{marginVertical: 8}}>
64
65
  <Signature
66
+ fullWidth={fullWidth}
65
67
  onChange={onChange}
66
68
  onEnd={() => {
67
69
  onEnd?.();
@@ -0,0 +1,10 @@
1
+ export const SIGNATURE_PAD_HEIGHT_PX = 180;
2
+ export const IOS_SIGNATURE_PAD_HEIGHT_PX = 120;
3
+
4
+ export const getSignaturePadHeight = (platformOS: string): number => {
5
+ if (platformOS === "ios") {
6
+ return IOS_SIGNATURE_PAD_HEIGHT_PX;
7
+ }
8
+
9
+ return SIGNATURE_PAD_HEIGHT_PX;
10
+ };