@terreno/ui 0.14.1 → 0.15.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/dist/ActionSheet.js +15 -27
- package/dist/ActionSheet.js.map +1 -1
- package/dist/Badge.js +1 -0
- package/dist/Badge.js.map +1 -1
- package/dist/Banner.d.ts +8 -0
- package/dist/Banner.js +2 -2
- package/dist/Banner.js.map +1 -1
- package/dist/MarkdownView.js +20 -7
- package/dist/MarkdownView.js.map +1 -1
- package/dist/PickerSelect.js +6 -2
- package/dist/PickerSelect.js.map +1 -1
- package/dist/Signature.d.ts +8 -1
- package/dist/Signature.js +93 -18
- package/dist/Signature.js.map +1 -1
- package/dist/Signature.native.d.ts +15 -0
- package/dist/Signature.native.js +116 -21
- package/dist/Signature.native.js.map +1 -1
- package/dist/TapToEdit.js +1 -1
- package/dist/TapToEdit.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/useConsentHistory.d.ts +6 -1
- package/dist/useConsentHistory.js +2 -1
- package/dist/useConsentHistory.js.map +1 -1
- package/package.json +2 -4
- package/src/ActionSheet.test.tsx +554 -0
- package/src/ActionSheet.tsx +24 -37
- package/src/Badge.test.tsx +7 -0
- package/src/Badge.tsx +1 -0
- package/src/Banner.test.tsx +58 -3
- package/src/Banner.tsx +3 -3
- package/src/DataTable.test.tsx +176 -1
- package/src/DateTimeField.test.tsx +942 -2
- package/src/Field.test.tsx +23 -0
- package/src/HeightActionSheet.test.tsx +1 -1
- package/src/HeightField.test.tsx +35 -0
- package/src/HeightFieldDesktop.test.tsx +19 -0
- package/src/MarkdownView.test.tsx +28 -0
- package/src/MarkdownView.tsx +69 -7
- package/src/MobileAddressAutoComplete.test.tsx +6 -2
- package/src/PickerSelect.test.tsx +265 -0
- package/src/PickerSelect.tsx +24 -8
- package/src/Signature.native.tsx +147 -30
- package/src/Signature.test.tsx +2 -49
- package/src/Signature.tsx +128 -22
- package/src/SignatureField.test.tsx +0 -9
- package/src/SplitPage.test.tsx +299 -43
- package/src/TapToEdit.test.tsx +46 -0
- package/src/TapToEdit.tsx +1 -1
- package/src/ToastNotifications.test.tsx +748 -1
- package/src/Tooltip.test.tsx +707 -1
- package/src/WebAddressAutocomplete.test.tsx +99 -0
- package/src/WebDropdownMenu.test.tsx +28 -2
- package/src/__snapshots__/Banner.test.tsx.snap +125 -0
- package/src/__snapshots__/CustomSelectField.test.tsx.snap +5 -4
- package/src/__snapshots__/DataTable.test.tsx.snap +366 -0
- package/src/__snapshots__/Field.test.tsx.snap +377 -0
- package/src/__snapshots__/MarkdownView.test.tsx.snap +284 -74
- package/src/__snapshots__/PickerSelect.test.tsx.snap +5 -4
- package/src/__snapshots__/SegmentedControl.test.tsx.snap +9 -0
- package/src/__snapshots__/SelectField.test.tsx.snap +5 -4
- package/src/__snapshots__/Signature.test.tsx.snap +13 -3
- package/src/__snapshots__/SignatureField.test.tsx.snap +10 -3
- package/src/__snapshots__/SplitPage.test.tsx.snap +698 -46
- package/src/bunSetup.ts +0 -19
- package/src/index.tsx +1 -1
- package/src/login/LoginScreen.test.tsx +12 -0
- package/src/useConsentHistory.test.ts +20 -13
- package/src/useConsentHistory.ts +7 -2
package/src/Signature.native.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {Canvas, ImageFormat, Path, Skia, useCanvasRef} from "@shopify/react-native-skia";
|
|
2
|
+
import {type FC, useCallback, useMemo, useRef, useState} from "react";
|
|
2
3
|
import {Text, View} from "react-native";
|
|
3
|
-
import
|
|
4
|
+
import {Gesture, GestureDetector} from "react-native-gesture-handler";
|
|
4
5
|
|
|
5
6
|
import {useTheme} from "./Theme";
|
|
6
7
|
|
|
@@ -10,44 +11,160 @@ interface Props {
|
|
|
10
11
|
onEnd?: () => void;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
+
const SIGNATURE_PAD_HEIGHT_PX = 180;
|
|
15
|
+
const STROKE_WIDTH_PX = 2.5;
|
|
16
|
+
// Snapshot after the released stroke has painted to the Skia canvas.
|
|
17
|
+
const SNAPSHOT_DELAY_MS = 60;
|
|
14
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Native (iOS + Android) signature pad backed by Skia — no WebView.
|
|
21
|
+
*
|
|
22
|
+
* Replaces the previous react-native-signature-canvas WebView, which on iOS
|
|
23
|
+
* stayed on `about:blank` (its signature_pad script never loaded, so onOK
|
|
24
|
+
* never fired). Skia draws strokes natively and exports a PNG via
|
|
25
|
+
* makeImageSnapshot, which behaves consistently across both platforms.
|
|
26
|
+
*
|
|
27
|
+
* Touches are captured with react-native-gesture-handler rather than
|
|
28
|
+
* PanResponder because the Skia <Canvas> renders a native view that swallows
|
|
29
|
+
* React Native's JS touch responder.
|
|
30
|
+
*
|
|
31
|
+
* Reports the signature to the parent as a base64 PNG data URL via onChange,
|
|
32
|
+
* and pushes "" on clear so "signature required" gating resets immediately.
|
|
33
|
+
*/
|
|
15
34
|
export const Signature: FC<Props> = ({onChange, onStart, onEnd}: Props) => {
|
|
16
|
-
const ref = useRef<SignatureViewRef>(null);
|
|
17
35
|
const {theme} = useTheme();
|
|
36
|
+
const canvasRef = useCanvasRef();
|
|
37
|
+
const snapshotTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
38
|
+
// Completed strokes as SVG path strings; the active stroke is tracked separately.
|
|
39
|
+
const [completedStrokes, setCompletedStrokes] = useState<string[]>([]);
|
|
40
|
+
const [activeStroke, setActiveStroke] = useState<string | null>(null);
|
|
41
|
+
const activeStrokeRef = useRef<string | null>(null);
|
|
18
42
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
43
|
+
const clearSnapshotTimer = useCallback((): void => {
|
|
44
|
+
if (snapshotTimerRef.current !== null) {
|
|
45
|
+
clearTimeout(snapshotTimerRef.current);
|
|
46
|
+
snapshotTimerRef.current = null;
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Snapshots the Skia canvas and reports a PNG data URL. Runs after a short
|
|
52
|
+
* delay so the just-completed stroke is painted before the snapshot.
|
|
53
|
+
*/
|
|
54
|
+
const captureSignature = useCallback((): void => {
|
|
55
|
+
clearSnapshotTimer();
|
|
56
|
+
snapshotTimerRef.current = setTimeout(() => {
|
|
57
|
+
const image = canvasRef.current?.makeImageSnapshot();
|
|
58
|
+
if (!image) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const base64 = image.encodeToBase64(ImageFormat.PNG, 100);
|
|
62
|
+
if (base64 && base64.length > 0) {
|
|
63
|
+
onChange(`data:image/png;base64,${base64}`);
|
|
64
|
+
}
|
|
65
|
+
}, SNAPSHOT_DELAY_MS);
|
|
66
|
+
}, [canvasRef, clearSnapshotTimer, onChange]);
|
|
67
|
+
|
|
68
|
+
const beginStroke = useCallback(
|
|
69
|
+
(x: number, y: number): void => {
|
|
70
|
+
const next = `M${x.toFixed(2)} ${y.toFixed(2)}`;
|
|
71
|
+
activeStrokeRef.current = next;
|
|
72
|
+
setActiveStroke(next);
|
|
73
|
+
onStart?.();
|
|
74
|
+
},
|
|
75
|
+
[onStart]
|
|
76
|
+
);
|
|
26
77
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
78
|
+
const extendStroke = useCallback((x: number, y: number): void => {
|
|
79
|
+
const prev = activeStrokeRef.current;
|
|
80
|
+
if (prev === null) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const next = `${prev} L${x.toFixed(2)} ${y.toFixed(2)}`;
|
|
84
|
+
activeStrokeRef.current = next;
|
|
85
|
+
setActiveStroke(next);
|
|
86
|
+
}, []);
|
|
30
87
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
88
|
+
const endStroke = useCallback((): void => {
|
|
89
|
+
const finished = activeStrokeRef.current;
|
|
90
|
+
activeStrokeRef.current = null;
|
|
91
|
+
setActiveStroke(null);
|
|
92
|
+
// A tap without movement has no line segment, so there is nothing to capture.
|
|
93
|
+
if (finished === null || !finished.includes("L")) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
setCompletedStrokes((prev) => [...prev, finished]);
|
|
97
|
+
captureSignature();
|
|
35
98
|
onEnd?.();
|
|
36
|
-
};
|
|
99
|
+
}, [captureSignature, onEnd]);
|
|
100
|
+
|
|
101
|
+
const panGesture = useMemo(
|
|
102
|
+
() =>
|
|
103
|
+
Gesture.Pan()
|
|
104
|
+
.runOnJS(true)
|
|
105
|
+
.minDistance(0)
|
|
106
|
+
.onBegin((event) => {
|
|
107
|
+
beginStroke(event.x, event.y);
|
|
108
|
+
})
|
|
109
|
+
.onUpdate((event) => {
|
|
110
|
+
extendStroke(event.x, event.y);
|
|
111
|
+
})
|
|
112
|
+
.onEnd(() => {
|
|
113
|
+
endStroke();
|
|
114
|
+
})
|
|
115
|
+
.onFinalize(() => {
|
|
116
|
+
// Covers cancellation paths where onEnd does not fire.
|
|
117
|
+
if (activeStrokeRef.current !== null) {
|
|
118
|
+
endStroke();
|
|
119
|
+
}
|
|
120
|
+
}),
|
|
121
|
+
[beginStroke, extendStroke, endStroke]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const skiaPaths = useMemo(() => {
|
|
125
|
+
const allStrokes = activeStroke ? [...completedStrokes, activeStroke] : completedStrokes;
|
|
126
|
+
return allStrokes
|
|
127
|
+
.map((svg) => Skia.Path.MakeFromSVGString(svg))
|
|
128
|
+
.filter((path): path is NonNullable<typeof path> => path !== null);
|
|
129
|
+
}, [completedStrokes, activeStroke]);
|
|
130
|
+
|
|
131
|
+
const handleClear = useCallback((): void => {
|
|
132
|
+
clearSnapshotTimer();
|
|
133
|
+
activeStrokeRef.current = null;
|
|
134
|
+
setActiveStroke(null);
|
|
135
|
+
setCompletedStrokes([]);
|
|
136
|
+
// clearing must reset parent gating, mirroring the web Signature variant.
|
|
137
|
+
onChange("");
|
|
138
|
+
}, [clearSnapshotTimer, onChange]);
|
|
37
139
|
|
|
38
140
|
return (
|
|
39
141
|
<View style={{minWidth: 220}}>
|
|
40
|
-
<
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
142
|
+
<GestureDetector gesture={panGesture}>
|
|
143
|
+
<View
|
|
144
|
+
style={{
|
|
145
|
+
backgroundColor: theme.surface.base,
|
|
146
|
+
borderColor: theme.border.dark,
|
|
147
|
+
borderWidth: 1,
|
|
148
|
+
height: SIGNATURE_PAD_HEIGHT_PX,
|
|
149
|
+
overflow: "hidden",
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<Canvas ref={canvasRef} style={{flex: 1}}>
|
|
153
|
+
{skiaPaths.map((path, index) => (
|
|
154
|
+
<Path
|
|
155
|
+
color={theme.text.secondaryDark}
|
|
156
|
+
// Strokes are append-only, so the index is a stable key here.
|
|
157
|
+
key={index}
|
|
158
|
+
path={path}
|
|
159
|
+
strokeCap="round"
|
|
160
|
+
strokeJoin="round"
|
|
161
|
+
strokeWidth={STROKE_WIDTH_PX}
|
|
162
|
+
style="stroke"
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
165
|
+
</Canvas>
|
|
166
|
+
</View>
|
|
167
|
+
</GestureDetector>
|
|
51
168
|
<View style={{flexDirection: "row"}}>
|
|
52
169
|
<Text
|
|
53
170
|
onPress={handleClear}
|
package/src/Signature.test.tsx
CHANGED
|
@@ -1,29 +1,9 @@
|
|
|
1
|
-
// biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
|
|
2
1
|
import {describe, expect, it, mock} from "bun:test";
|
|
3
2
|
import {fireEvent} from "@testing-library/react-native";
|
|
4
|
-
import {forwardRef, useImperativeHandle} from "react";
|
|
5
|
-
import {View} from "react-native";
|
|
6
3
|
|
|
7
4
|
import {Signature} from "./Signature";
|
|
8
5
|
import {renderWithTheme} from "./test-utils";
|
|
9
6
|
|
|
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
|
-
}));
|
|
26
|
-
|
|
27
7
|
describe("Signature", () => {
|
|
28
8
|
it("renders correctly", () => {
|
|
29
9
|
const mockOnChange = mock(() => {});
|
|
@@ -42,39 +22,12 @@ describe("Signature", () => {
|
|
|
42
22
|
expect(getByText("Clear")).toBeTruthy();
|
|
43
23
|
});
|
|
44
24
|
|
|
45
|
-
it("calls clear on the signature canvas when Clear is pressed", () => {
|
|
46
|
-
clearMock.mockClear();
|
|
47
|
-
const mockOnChange = mock(() => {});
|
|
48
|
-
const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
|
|
49
|
-
fireEvent.press(getByText("Clear"));
|
|
50
|
-
expect(clearMock).toHaveBeenCalledTimes(1);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
25
|
it("notifies the parent with an empty value when Clear is pressed", () => {
|
|
54
|
-
clearMock.mockClear();
|
|
55
26
|
const mockOnChange = mock(() => {});
|
|
56
27
|
const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
|
|
57
28
|
fireEvent.press(getByText("Clear"));
|
|
58
|
-
//
|
|
59
|
-
//
|
|
29
|
+
// Clearing the canvas emits no draw event, so the component must push ""
|
|
30
|
+
// directly or "signature required" gating in parents would never reset.
|
|
60
31
|
expect(mockOnChange).toHaveBeenCalledWith("");
|
|
61
32
|
});
|
|
62
|
-
|
|
63
|
-
it("calls onChange with the data URL when a stroke ends", () => {
|
|
64
|
-
toDataURLReturn = "data:image/png;base64,abc";
|
|
65
|
-
const mockOnChange = mock(() => {});
|
|
66
|
-
renderWithTheme(<Signature onChange={mockOnChange} />);
|
|
67
|
-
expect(lastOnEnd).toBeDefined();
|
|
68
|
-
lastOnEnd?.();
|
|
69
|
-
expect(mockOnChange).toHaveBeenCalledWith("data:image/png;base64,abc");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("does not call onChange when toDataURL returns an empty value", () => {
|
|
73
|
-
toDataURLReturn = "";
|
|
74
|
-
const mockOnChange = mock(() => {});
|
|
75
|
-
renderWithTheme(<Signature onChange={mockOnChange} />);
|
|
76
|
-
expect(lastOnEnd).toBeDefined();
|
|
77
|
-
lastOnEnd?.();
|
|
78
|
-
expect(mockOnChange).not.toHaveBeenCalled();
|
|
79
|
-
});
|
|
80
33
|
});
|
package/src/Signature.tsx
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
|
|
@@ -11,35 +16,136 @@ export interface SignatureProps {
|
|
|
11
16
|
value?: string; // note this
|
|
12
17
|
}
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
const SIGNATURE_WIDTH_PX = 300;
|
|
20
|
+
const SIGNATURE_HEIGHT_PX = 180;
|
|
21
|
+
const STROKE_WIDTH_PX = 2.5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Web signature pad backed by a raw HTML5 <canvas> — no third-party library.
|
|
25
|
+
*
|
|
26
|
+
* Pointer events capture strokes and the canvas is exported as a base64 PNG
|
|
27
|
+
* data URL via onChange. Clearing pushes "" so "signature required" gating in
|
|
28
|
+
* parents resets immediately, since clearing the canvas emits no draw event.
|
|
29
|
+
*/
|
|
30
|
+
export const Signature = ({onChange, onStart, onEnd}: SignatureProps): ReactElement => {
|
|
16
31
|
const {theme} = useTheme();
|
|
32
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
33
|
+
const isDrawingRef = useRef(false);
|
|
34
|
+
const hasDrawnRef = useRef(false);
|
|
17
35
|
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
36
|
+
const getContext = useCallback((): CanvasRenderingContext2D | null => {
|
|
37
|
+
const canvas = canvasRef.current;
|
|
38
|
+
if (!canvas || typeof canvas.getContext !== "function") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return canvas.getContext("2d");
|
|
42
|
+
}, []);
|
|
25
43
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Paints the opaque background and configures stroke styling. Re-runs when
|
|
46
|
+
* the theme changes so the pad matches the active light/dark colors.
|
|
47
|
+
*/
|
|
48
|
+
const resetCanvas = useCallback((): void => {
|
|
49
|
+
const canvas = canvasRef.current;
|
|
50
|
+
const ctx = getContext();
|
|
51
|
+
if (!canvas || !ctx) {
|
|
52
|
+
return;
|
|
29
53
|
}
|
|
30
|
-
|
|
54
|
+
ctx.fillStyle = theme.surface.base;
|
|
55
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
56
|
+
ctx.strokeStyle = theme.text.secondaryDark;
|
|
57
|
+
ctx.lineWidth = STROKE_WIDTH_PX;
|
|
58
|
+
ctx.lineCap = "round";
|
|
59
|
+
ctx.lineJoin = "round";
|
|
60
|
+
}, [getContext, theme.surface.base, theme.text.secondaryDark]);
|
|
61
|
+
|
|
62
|
+
// Initialize the canvas background and stroke styling once the element mounts.
|
|
63
|
+
useEffect((): void => {
|
|
64
|
+
resetCanvas();
|
|
65
|
+
}, [resetCanvas]);
|
|
66
|
+
|
|
67
|
+
const handlePointerDown = useCallback(
|
|
68
|
+
(event: ReactPointerEvent<HTMLCanvasElement>): void => {
|
|
69
|
+
const ctx = getContext();
|
|
70
|
+
if (!ctx) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
canvasRef.current?.setPointerCapture?.(event.pointerId);
|
|
74
|
+
isDrawingRef.current = true;
|
|
75
|
+
ctx.beginPath();
|
|
76
|
+
ctx.moveTo(event.nativeEvent.offsetX, event.nativeEvent.offsetY);
|
|
77
|
+
onStart?.();
|
|
78
|
+
},
|
|
79
|
+
[getContext, onStart]
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const handlePointerMove = useCallback(
|
|
83
|
+
(event: ReactPointerEvent<HTMLCanvasElement>): void => {
|
|
84
|
+
if (!isDrawingRef.current) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const ctx = getContext();
|
|
88
|
+
if (!ctx) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
ctx.lineTo(event.nativeEvent.offsetX, event.nativeEvent.offsetY);
|
|
92
|
+
ctx.stroke();
|
|
93
|
+
hasDrawnRef.current = true;
|
|
94
|
+
},
|
|
95
|
+
[getContext]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const handlePointerUp = useCallback(
|
|
99
|
+
(event: ReactPointerEvent<HTMLCanvasElement>): void => {
|
|
100
|
+
if (!isDrawingRef.current) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
isDrawingRef.current = false;
|
|
104
|
+
canvasRef.current?.releasePointerCapture?.(event.pointerId);
|
|
105
|
+
const canvas = canvasRef.current;
|
|
106
|
+
if (hasDrawnRef.current && canvas) {
|
|
107
|
+
onChange(canvas.toDataURL("image/png"));
|
|
108
|
+
}
|
|
109
|
+
onEnd?.();
|
|
110
|
+
},
|
|
111
|
+
[onChange, onEnd]
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const handleClear = useCallback((): void => {
|
|
115
|
+
hasDrawnRef.current = false;
|
|
116
|
+
isDrawingRef.current = false;
|
|
117
|
+
resetCanvas();
|
|
118
|
+
// Clearing the canvas emits no draw event, so notify the parent directly
|
|
119
|
+
// to reset any "signature required" gating.
|
|
120
|
+
onChange("");
|
|
121
|
+
}, [resetCanvas, onChange]);
|
|
122
|
+
|
|
31
123
|
return (
|
|
32
124
|
<View>
|
|
33
|
-
<View
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
125
|
+
<View
|
|
126
|
+
style={{
|
|
127
|
+
borderColor: theme.border.dark,
|
|
128
|
+
borderWidth: 1,
|
|
129
|
+
maxWidth: SIGNATURE_WIDTH_PX,
|
|
130
|
+
width: "100%",
|
|
131
|
+
}}
|
|
132
|
+
>
|
|
133
|
+
<canvas
|
|
134
|
+
height={SIGNATURE_HEIGHT_PX}
|
|
135
|
+
onPointerDown={handlePointerDown}
|
|
136
|
+
onPointerLeave={handlePointerUp}
|
|
137
|
+
onPointerMove={handlePointerMove}
|
|
138
|
+
onPointerUp={handlePointerUp}
|
|
139
|
+
ref={canvasRef}
|
|
140
|
+
style={{height: SIGNATURE_HEIGHT_PX, touchAction: "none", width: SIGNATURE_WIDTH_PX}}
|
|
141
|
+
width={SIGNATURE_WIDTH_PX}
|
|
39
142
|
/>
|
|
40
143
|
</View>
|
|
41
144
|
<View>
|
|
42
|
-
<Text
|
|
145
|
+
<Text
|
|
146
|
+
onPress={handleClear}
|
|
147
|
+
style={{color: theme.text.link, textDecorationLine: "underline"}}
|
|
148
|
+
>
|
|
43
149
|
Clear
|
|
44
150
|
</Text>
|
|
45
151
|
</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: () => {},
|