@terreno/ui 0.14.2 → 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/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/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/package.json +2 -4
- package/src/Badge.test.tsx +7 -0
- package/src/Badge.tsx +1 -0
- package/src/Banner.test.tsx +23 -3
- package/src/Banner.tsx +3 -3
- package/src/DateTimeField.test.tsx +226 -0
- package/src/Field.test.tsx +23 -0
- package/src/PickerSelect.test.tsx +22 -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/TapToEdit.test.tsx +33 -0
- package/src/TapToEdit.tsx +1 -1
- package/src/ToastNotifications.test.tsx +74 -1
- package/src/__snapshots__/CustomSelectField.test.tsx.snap +5 -4
- package/src/__snapshots__/Field.test.tsx.snap +377 -0
- 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/bunSetup.ts +0 -15
- package/src/index.tsx +1 -1
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: () => {},
|
package/src/TapToEdit.test.tsx
CHANGED
|
@@ -375,4 +375,37 @@ describe("TapToEdit - additional function coverage", () => {
|
|
|
375
375
|
});
|
|
376
376
|
expect(queryByText("Save")).toBeTruthy();
|
|
377
377
|
});
|
|
378
|
+
|
|
379
|
+
it("renders textarea in editing mode with grow and row defaults", async () => {
|
|
380
|
+
const setValue = mock(() => {});
|
|
381
|
+
const {getByLabelText, queryByText} = renderWithTheme(
|
|
382
|
+
<TapToEdit setValue={setValue} title="Notes" type="textarea" value="Some notes" />
|
|
383
|
+
);
|
|
384
|
+
await act(async () => {
|
|
385
|
+
fireEvent.press(getByLabelText("Edit"));
|
|
386
|
+
});
|
|
387
|
+
expect(queryByText("Save")).toBeTruthy();
|
|
388
|
+
expect(queryByText("Cancel")).toBeTruthy();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("hides helperText in title when onlyShowHelperTextWhileEditing is true (default)", () => {
|
|
392
|
+
const {queryByText} = renderWithTheme(
|
|
393
|
+
<TapToEdit
|
|
394
|
+
editable={false}
|
|
395
|
+
helperText="Should be hidden in title"
|
|
396
|
+
title="Field"
|
|
397
|
+
value="val"
|
|
398
|
+
/>
|
|
399
|
+
);
|
|
400
|
+
expect(queryByText("Field")).toBeTruthy();
|
|
401
|
+
expect(queryByText("Should be hidden in title")).toBeNull();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("renders non-editable textarea with display value below title", () => {
|
|
405
|
+
const {getByText} = renderWithTheme(
|
|
406
|
+
<TapToEdit editable={false} title="Bio" type="textarea" value="A long bio text" />
|
|
407
|
+
);
|
|
408
|
+
expect(getByText("Bio")).toBeTruthy();
|
|
409
|
+
expect(getByText("A long bio text")).toBeTruthy();
|
|
410
|
+
});
|
|
378
411
|
});
|
package/src/TapToEdit.tsx
CHANGED
|
@@ -116,7 +116,7 @@ export const TapToEdit: FC<TapToEditProps> = ({
|
|
|
116
116
|
}
|
|
117
117
|
: undefined
|
|
118
118
|
}
|
|
119
|
-
onChange={setValue
|
|
119
|
+
onChange={setValue as NonNullable<typeof setValue>}
|
|
120
120
|
row={fieldProps?.type === "textarea" ? 5 : undefined}
|
|
121
121
|
type={(fieldProps?.type ?? "text") as NonNullable<FieldProps["type"]>}
|
|
122
122
|
value={value}
|
|
@@ -3,7 +3,7 @@ import {act, render, waitFor} from "@testing-library/react-native";
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import {Text} from "react-native";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import ToastContainer, {
|
|
7
7
|
GlobalToast,
|
|
8
8
|
type ToastContainerRef,
|
|
9
9
|
type ToastOptions,
|
|
@@ -2260,4 +2260,77 @@ describe("ToastNotifications", () => {
|
|
|
2260
2260
|
}
|
|
2261
2261
|
});
|
|
2262
2262
|
});
|
|
2263
|
+
|
|
2264
|
+
describe("update map callback with toast in state", () => {
|
|
2265
|
+
it("should invoke the map callback inside update when toasts exist", async () => {
|
|
2266
|
+
let toastRef: ToastType | null = null;
|
|
2267
|
+
|
|
2268
|
+
const TestComponent = () => {
|
|
2269
|
+
const toast = useToastNotifications();
|
|
2270
|
+
toastRef = toast;
|
|
2271
|
+
return <Text>Test</Text>;
|
|
2272
|
+
};
|
|
2273
|
+
|
|
2274
|
+
render(
|
|
2275
|
+
<ToastProvider swipeEnabled={false}>
|
|
2276
|
+
<TestComponent />
|
|
2277
|
+
</ToastProvider>
|
|
2278
|
+
);
|
|
2279
|
+
|
|
2280
|
+
await waitFor(() => {
|
|
2281
|
+
expect(toastRef?.show).toBeDefined();
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
await act(async () => {
|
|
2285
|
+
toastRef?.show("Original", {duration: 0, id: "update-map-test"});
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
// Flush RAF so the toast is actually in state before calling update
|
|
2289
|
+
for (let i = 0; i < 5; i++) {
|
|
2290
|
+
await act(async () => {
|
|
2291
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// Now update — the map callback will iterate over the non-empty toasts array
|
|
2296
|
+
await act(async () => {
|
|
2297
|
+
toastRef?.update("update-map-test", "Updated");
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
await act(async () => {
|
|
2301
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2302
|
+
});
|
|
2303
|
+
});
|
|
2304
|
+
});
|
|
2305
|
+
|
|
2306
|
+
describe("isOpen some callback with toast in state", () => {
|
|
2307
|
+
it("should invoke the some callback inside isOpen when toasts exist", async () => {
|
|
2308
|
+
// Use ToastContainer directly via ref to get the latest isOpen closure
|
|
2309
|
+
// (the context-based ref from ToastProvider captures stale toasts)
|
|
2310
|
+
const containerRef = React.createRef<ToastContainerRef>();
|
|
2311
|
+
|
|
2312
|
+
render(<ToastContainer ref={containerRef} />);
|
|
2313
|
+
|
|
2314
|
+
await waitFor(() => {
|
|
2315
|
+
expect(containerRef.current?.show).toBeDefined();
|
|
2316
|
+
});
|
|
2317
|
+
|
|
2318
|
+
await act(async () => {
|
|
2319
|
+
containerRef.current?.show("IsOpen test", {duration: 0, id: "isopen-some-test"});
|
|
2320
|
+
});
|
|
2321
|
+
|
|
2322
|
+
// Flush RAF so the toast is actually in state
|
|
2323
|
+
for (let i = 0; i < 5; i++) {
|
|
2324
|
+
await act(async () => {
|
|
2325
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// Now isOpen — the some callback will iterate over the non-empty toasts array.
|
|
2330
|
+
// Using the ref directly ensures we get the latest isOpen with updated toasts.
|
|
2331
|
+
await waitFor(() => {
|
|
2332
|
+
expect(containerRef.current?.isOpen("isopen-some-test")).toBe(true);
|
|
2333
|
+
});
|
|
2334
|
+
});
|
|
2335
|
+
});
|
|
2263
2336
|
});
|
|
@@ -1710,16 +1710,17 @@ exports[`CustomSelectField renders disabled state 1`] = `
|
|
|
1710
1710
|
"children": [
|
|
1711
1711
|
{
|
|
1712
1712
|
"$$typeof": Symbol(react.test.json),
|
|
1713
|
-
"children":
|
|
1713
|
+
"children": [
|
|
1714
|
+
"Option A",
|
|
1715
|
+
],
|
|
1714
1716
|
"props": {
|
|
1715
|
-
"readOnly": true,
|
|
1716
1717
|
"style": {
|
|
1717
1718
|
"color": "#686868",
|
|
1719
|
+
"flex": 1,
|
|
1718
1720
|
},
|
|
1719
1721
|
"testID": "text_input",
|
|
1720
|
-
"value": "Option A",
|
|
1721
1722
|
},
|
|
1722
|
-
"type": "
|
|
1723
|
+
"type": "Text",
|
|
1723
1724
|
},
|
|
1724
1725
|
{
|
|
1725
1726
|
"$$typeof": Symbol(react.test.json),
|