@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
@@ -1105,4 +1105,230 @@ describe("DateTimeField", () => {
1105
1105
  expect(mockOnChange).toHaveBeenCalled();
1106
1106
  });
1107
1107
  });
1108
+
1109
+ describe("12 AM handling in time type (getISOFromFields)", () => {
1110
+ it("should convert hour 12 AM to 0 in time type", async () => {
1111
+ setDesktop();
1112
+ const user = userEvent.setup();
1113
+ // 04:00 UTC = 00:00 (12:00 AM) in America/New_York
1114
+ const {getByPlaceholderText} = renderWithTheme(
1115
+ <DateTimeField
1116
+ onChange={mockOnChange}
1117
+ timezone="America/New_York"
1118
+ type="time"
1119
+ value="2023-05-15T04:00:00.000Z"
1120
+ />
1121
+ );
1122
+ expect(getByPlaceholderText("hh").props.value).toBe("12");
1123
+
1124
+ const minuteInput = getByPlaceholderText("mm");
1125
+ await user.clear(minuteInput);
1126
+ await user.type(minuteInput, "15");
1127
+ await act(async () => {
1128
+ await new Promise((resolve) => setTimeout(resolve, 100));
1129
+ });
1130
+ expect(mockOnChange).toHaveBeenCalled();
1131
+ const lastCall = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0];
1132
+ const parsed = DateTime.fromISO(lastCall).setZone("America/New_York");
1133
+ expect(parsed.hour).toBe(0);
1134
+ expect(parsed.minute).toBe(15);
1135
+ });
1136
+
1137
+ it("should convert hour 12 AM to 0 in datetime type", async () => {
1138
+ setDesktop();
1139
+ const user = userEvent.setup();
1140
+ // 04:30 UTC = 00:30 (12:30 AM) in America/New_York
1141
+ const {getByPlaceholderText} = renderWithTheme(
1142
+ <DateTimeField
1143
+ onChange={mockOnChange}
1144
+ timezone="America/New_York"
1145
+ type="datetime"
1146
+ value="2023-05-15T04:30:00.000Z"
1147
+ />
1148
+ );
1149
+ expect(getByPlaceholderText("hh").props.value).toBe("12");
1150
+
1151
+ const minuteInput = getByPlaceholderText("mm");
1152
+ await user.clear(minuteInput);
1153
+ await user.type(minuteInput, "45");
1154
+ await act(async () => {
1155
+ await new Promise((resolve) => setTimeout(resolve, 100));
1156
+ });
1157
+ expect(mockOnChange).toHaveBeenCalled();
1158
+ const lastCall = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0];
1159
+ const parsed = DateTime.fromISO(lastCall).setZone("America/New_York");
1160
+ expect(parsed.hour).toBe(0);
1161
+ });
1162
+ });
1163
+
1164
+ describe("onActionSheetChange invalid date handling", () => {
1165
+ it("should warn and return early for invalid ISO string", async () => {
1166
+ setDesktop();
1167
+ const warnSpy = mock(() => {});
1168
+ const originalWarn = console.warn;
1169
+ console.warn = warnSpy;
1170
+
1171
+ const {UNSAFE_root} = renderWithTheme(
1172
+ <DateTimeField
1173
+ onChange={mockOnChange}
1174
+ timezone="America/New_York"
1175
+ type="date"
1176
+ value="2023-05-15T00:00:00.000Z"
1177
+ />
1178
+ );
1179
+
1180
+ mockOnChange.mockClear();
1181
+ const actionSheet = UNSAFE_root.findAll(
1182
+ (n: any) => n.props?.onChange && n.props?.visible !== undefined
1183
+ );
1184
+ expect(actionSheet.length).toBeGreaterThan(0);
1185
+ await act(async () => {
1186
+ actionSheet[0].props.onChange("not-a-valid-date");
1187
+ });
1188
+ expect(warnSpy).toHaveBeenCalledWith(
1189
+ "Invalid date passed to DateTimeField",
1190
+ "not-a-valid-date"
1191
+ );
1192
+ expect(mockOnChange).not.toHaveBeenCalled();
1193
+
1194
+ console.warn = originalWarn;
1195
+ });
1196
+ });
1197
+
1198
+ describe("useEffect invalid value handling", () => {
1199
+ it("should warn and return early for invalid non-empty value prop", () => {
1200
+ const warnSpy = mock(() => {});
1201
+ const originalWarn = console.warn;
1202
+ console.warn = warnSpy;
1203
+
1204
+ const {getByPlaceholderText} = renderWithTheme(
1205
+ <DateTimeField onChange={mockOnChange} type="date" value="invalid-date-string" />
1206
+ );
1207
+
1208
+ expect(warnSpy).toHaveBeenCalledWith(
1209
+ "Invalid date passed to DateTimeField",
1210
+ "invalid-date-string"
1211
+ );
1212
+ expect(getByPlaceholderText("MM").props.value).toBe("");
1213
+
1214
+ console.warn = originalWarn;
1215
+ });
1216
+
1217
+ it("should warn for invalid value in time type", () => {
1218
+ const warnSpy = mock(() => {});
1219
+ const originalWarn = console.warn;
1220
+ console.warn = warnSpy;
1221
+
1222
+ renderWithTheme(
1223
+ <DateTimeField
1224
+ onChange={mockOnChange}
1225
+ timezone="America/New_York"
1226
+ type="time"
1227
+ value="not-valid"
1228
+ />
1229
+ );
1230
+
1231
+ expect(warnSpy).toHaveBeenCalledWith("Invalid date passed to DateTimeField", "not-valid");
1232
+
1233
+ console.warn = originalWarn;
1234
+ });
1235
+ });
1236
+
1237
+ describe("getFieldValue datetime hour/minute indices", () => {
1238
+ it("should return hour and minute for datetime indices 3 and 4", () => {
1239
+ setDesktop();
1240
+ // 20:30 UTC = 4:30 PM in America/New_York
1241
+ const {getByPlaceholderText} = renderWithTheme(
1242
+ <DateTimeField
1243
+ onChange={mockOnChange}
1244
+ timezone="America/New_York"
1245
+ type="datetime"
1246
+ value="2023-05-15T20:30:00.000Z"
1247
+ />
1248
+ );
1249
+ // Indices 0-2 are date fields, indices 3-4 are hour/minute
1250
+ expect(getByPlaceholderText("hh").props.value).toBe("04");
1251
+ expect(getByPlaceholderText("mm").props.value).toBe("30");
1252
+ });
1253
+
1254
+ it("should return hour and minute for datetime at midnight", () => {
1255
+ setDesktop();
1256
+ // 04:00 UTC = 00:00 (12:00 AM) in America/New_York
1257
+ const {getByPlaceholderText} = renderWithTheme(
1258
+ <DateTimeField
1259
+ onChange={mockOnChange}
1260
+ timezone="America/New_York"
1261
+ type="datetime"
1262
+ value="2023-05-15T04:00:00.000Z"
1263
+ />
1264
+ );
1265
+ expect(getByPlaceholderText("hh").props.value).toBe("12");
1266
+ expect(getByPlaceholderText("mm").props.value).toBe("00");
1267
+ });
1268
+ });
1269
+
1270
+ describe("handleTimezoneChange branches", () => {
1271
+ it("should call onTimezoneChange when provided for datetime type", async () => {
1272
+ setDesktop();
1273
+ const mockTzChange = mock(() => {});
1274
+ const {UNSAFE_root} = renderWithTheme(
1275
+ <DateTimeField
1276
+ onChange={mockOnChange}
1277
+ onTimezoneChange={mockTzChange}
1278
+ timezone="America/New_York"
1279
+ type="datetime"
1280
+ value="2023-05-15T15:30:00.000Z"
1281
+ />
1282
+ );
1283
+
1284
+ const tzPickers = UNSAFE_root.findAll((n: any) => n.props?.onTimezoneChange);
1285
+ expect(tzPickers.length).toBeGreaterThan(0);
1286
+ await act(async () => {
1287
+ tzPickers[0].props.onTimezoneChange("America/Chicago");
1288
+ });
1289
+ expect(mockTzChange).toHaveBeenCalledWith("America/Chicago");
1290
+ });
1291
+
1292
+ it("should set local timezone when onTimezoneChange not provided for datetime type", async () => {
1293
+ setDesktop();
1294
+ const {UNSAFE_root} = renderWithTheme(
1295
+ <DateTimeField
1296
+ onChange={mockOnChange}
1297
+ timezone="America/New_York"
1298
+ type="datetime"
1299
+ value="2023-05-15T15:30:00.000Z"
1300
+ />
1301
+ );
1302
+
1303
+ const tzPickers = UNSAFE_root.findAll((n: any) => n.props?.onTimezoneChange);
1304
+ expect(tzPickers.length).toBeGreaterThan(0);
1305
+ await act(async () => {
1306
+ tzPickers[0].props.onTimezoneChange("America/Chicago");
1307
+ });
1308
+ expect(mockOnChange).toHaveBeenCalled();
1309
+ });
1310
+ });
1311
+
1312
+ describe("minute validation in validateField", () => {
1313
+ it("should validate minute field for datetime type via hour change triggering revalidation", async () => {
1314
+ setDesktop();
1315
+ const user = userEvent.setup();
1316
+ const {getByPlaceholderText} = renderWithTheme(
1317
+ <DateTimeField
1318
+ onChange={mockOnChange}
1319
+ timezone="America/New_York"
1320
+ type="datetime"
1321
+ value="2023-05-15T15:30:00.000Z"
1322
+ />
1323
+ );
1324
+ // Type an invalid hour (triggers validateField for datetime index 3)
1325
+ const hourInput = getByPlaceholderText("hh");
1326
+ await user.clear(hourInput);
1327
+ await user.type(hourInput, "0");
1328
+ await act(async () => {
1329
+ await new Promise((resolve) => setTimeout(resolve, 100));
1330
+ });
1331
+ expect(hourInput).toBeTruthy();
1332
+ });
1333
+ });
1108
1334
  });
@@ -1,6 +1,7 @@
1
1
  import type React from "react";
2
- import {Pressable, View} from "react-native";
2
+ import {Pressable} from "react-native";
3
3
 
4
+ import {Box} from "./Box";
4
5
  import type {DismissButtonProps} from "./Common";
5
6
  import {Icon} from "./Icon";
6
7
 
@@ -23,9 +24,9 @@ export const DismissButton = ({
23
24
  width: 24.5,
24
25
  }}
25
26
  >
26
- <View>
27
+ <Box>
27
28
  <Icon color={color} iconName="x" type="solid" />
28
- </View>
29
+ </Box>
29
30
  </Pressable>
30
31
  );
31
32
  };
@@ -125,4 +125,27 @@ describe("Field", () => {
125
125
  );
126
126
  expect(toJSON()).toMatchSnapshot();
127
127
  });
128
+
129
+ it("renders customSelect field", () => {
130
+ const {toJSON} = renderWithTheme(
131
+ <Field
132
+ label="Custom"
133
+ onChange={() => {}}
134
+ options={[
135
+ {label: "Option A", value: "a"},
136
+ {label: "Option B", value: "b"},
137
+ ]}
138
+ type="customSelect"
139
+ value="a"
140
+ />
141
+ );
142
+ expect(toJSON()).toMatchSnapshot();
143
+ });
144
+
145
+ it("renders signature field", () => {
146
+ const {toJSON} = renderWithTheme(
147
+ <Field label="Sign here" onChange={() => {}} type="signature" value="" />
148
+ );
149
+ expect(toJSON()).toMatchSnapshot();
150
+ });
128
151
  });
@@ -12,14 +12,14 @@ import {Tooltip} from "./Tooltip";
12
12
  import {Unifier} from "./Unifier";
13
13
  import {isNative} from "./Utilities";
14
14
 
15
- type ConfirmationModalProps = {
15
+ interface ConfirmationModalProps {
16
16
  visible: boolean;
17
17
  title: string;
18
18
  subtitle?: string;
19
19
  text: string;
20
20
  onConfirm: () => void;
21
21
  onCancel: () => void;
22
- };
22
+ }
23
23
 
24
24
  const ConfirmationModal: FC<ConfirmationModalProps> = ({
25
25
  visible,
@@ -308,6 +308,28 @@ describe("PickerSelect", () => {
308
308
  restoreDocument();
309
309
  }
310
310
  });
311
+
312
+ it("calls onValueChange when a web dropdown option is selected", async () => {
313
+ ensureDocument();
314
+ savedOS = PlatformModule.OS;
315
+ try {
316
+ PlatformModule.OS = "web";
317
+ const mockOnValueChange = mock(() => {});
318
+ const {getByTestId} = renderWithTheme(
319
+ <RNPickerSelect {...defaultProps} onValueChange={mockOnValueChange} value="1" />
320
+ );
321
+ await act(async () => {
322
+ fireEvent.press(getByTestId("web_picker"));
323
+ });
324
+ await act(async () => {
325
+ fireEvent.press(getByTestId("web_dropdown_option_2"));
326
+ });
327
+ expect(mockOnValueChange).toHaveBeenCalledWith("2", 2);
328
+ } finally {
329
+ PlatformModule.OS = savedOS;
330
+ restoreDocument();
331
+ }
332
+ });
311
333
  });
312
334
 
313
335
  describe("android rendering", () => {
@@ -38,6 +38,7 @@ import {
38
38
  Text,
39
39
  TextInput,
40
40
  type TextInputProps,
41
+ type TextProps,
41
42
  TouchableOpacity,
42
43
  View,
43
44
  } from "react-native";
@@ -388,6 +389,7 @@ export const RNPickerSelect = ({
388
389
  return <View style={{pointerEvents: "box-only"}}>{children}</View>;
389
390
  }
390
391
 
392
+ const textProps = textInputProps as Partial<TextProps> | undefined;
391
393
  return (
392
394
  <View
393
395
  style={{
@@ -397,13 +399,27 @@ export const RNPickerSelect = ({
397
399
  width: "100%",
398
400
  }}
399
401
  >
400
- <TextInput
401
- readOnly
402
- style={{color: disabled ? theme.text.secondaryLight : theme.text.primary}}
403
- testID="text_input"
404
- value={selectedItem?.inputLabel ? selectedItem?.inputLabel : selectedItem?.label}
405
- {...textInputProps}
406
- />
402
+ {disabled ? (
403
+ <Text
404
+ {...textProps}
405
+ style={
406
+ textProps?.style
407
+ ? [{color: theme.text.secondaryLight, flex: 1}, textProps.style]
408
+ : {color: theme.text.secondaryLight, flex: 1}
409
+ }
410
+ testID={textInputProps?.testID ?? "text_input"}
411
+ >
412
+ {selectedItem?.inputLabel ? selectedItem?.inputLabel : selectedItem?.label}
413
+ </Text>
414
+ ) : (
415
+ <TextInput
416
+ readOnly
417
+ style={{color: theme.text.primary}}
418
+ testID="text_input"
419
+ value={selectedItem?.inputLabel ? selectedItem?.inputLabel : selectedItem?.label}
420
+ {...textInputProps}
421
+ />
422
+ )}
407
423
  {renderIcon()}
408
424
  </View>
409
425
  );
@@ -629,7 +645,7 @@ export const RNPickerSelect = ({
629
645
  {...touchableWrapperProps}
630
646
  >
631
647
  <Text
632
- numberOfLines={1}
648
+ numberOfLines={disabled ? undefined : 1}
633
649
  style={{
634
650
  color: disabled ? theme.text.secondaryLight : theme.text.primary,
635
651
  flex: 1,
@@ -0,0 +1,9 @@
1
+ import {describe, expect, it} from "bun:test";
2
+
3
+ import {getSignaturePadHeight} from "./SignatureSizing";
4
+
5
+ describe("Signature native sizing", () => {
6
+ it("uses a smaller signature pad on iOS", () => {
7
+ expect(getSignaturePadHeight("ios")).toBeLessThan(getSignaturePadHeight("android"));
8
+ });
9
+ });
@@ -1,53 +1,172 @@
1
- import {type FC, useRef} from "react";
2
- import {Text, View} from "react-native";
3
- import SignatureScreen, {type SignatureViewRef} from "react-native-signature-canvas";
1
+ import {Canvas, ImageFormat, Path, Skia, useCanvasRef} from "@shopify/react-native-skia";
2
+ import {type FC, useCallback, useMemo, useRef, useState} from "react";
3
+ import {Platform, Text, View} from "react-native";
4
+ import {Gesture, GestureDetector} from "react-native-gesture-handler";
4
5
 
6
+ import {getSignaturePadHeight} from "./SignatureSizing";
5
7
  import {useTheme} from "./Theme";
6
8
 
7
9
  interface Props {
8
10
  onChange: (signature: string) => void;
9
11
  onStart?: () => void;
10
12
  onEnd?: () => void;
13
+ fullWidth?: boolean;
11
14
  }
12
15
 
13
- const style = `.m-signature-pad--footer {display: none; margin: 0px;}`;
16
+ const STROKE_WIDTH_PX = 2.5;
17
+ // Snapshot after the released stroke has painted to the Skia canvas.
18
+ const SNAPSHOT_DELAY_MS = 60;
14
19
 
15
- export const Signature: FC<Props> = ({onChange, onStart, onEnd}: Props) => {
16
- const ref = useRef<SignatureViewRef>(null);
20
+ /**
21
+ * Native (iOS + Android) signature pad backed by Skia — no WebView.
22
+ *
23
+ * Replaces the previous react-native-signature-canvas WebView, which on iOS
24
+ * stayed on `about:blank` (its signature_pad script never loaded, so onOK
25
+ * never fired). Skia draws strokes natively and exports a PNG via
26
+ * makeImageSnapshot, which behaves consistently across both platforms.
27
+ *
28
+ * Touches are captured with react-native-gesture-handler rather than
29
+ * PanResponder because the Skia <Canvas> renders a native view that swallows
30
+ * React Native's JS touch responder.
31
+ *
32
+ * Reports the signature to the parent as a base64 PNG data URL via onChange,
33
+ * and pushes "" on clear so "signature required" gating resets immediately.
34
+ */
35
+ export const Signature: FC<Props> = ({fullWidth = false, onChange, onStart, onEnd}: Props) => {
17
36
  const {theme} = useTheme();
37
+ const canvasRef = useCanvasRef();
38
+ const signaturePadHeight = getSignaturePadHeight(Platform.OS);
39
+ const snapshotTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
40
+ // Completed strokes as SVG path strings; the active stroke is tracked separately.
41
+ const [completedStrokes, setCompletedStrokes] = useState<string[]>([]);
42
+ const [activeStroke, setActiveStroke] = useState<string | null>(null);
43
+ const activeStrokeRef = useRef<string | null>(null);
18
44
 
19
- const handleClear = () => {
20
- ref.current?.clearSignature();
21
- // `clearSignature` on the underlying canvas does not fire `onOK`, so the
22
- // parent never learns the signature is gone. Push an empty value so any
23
- // "signature required" gating reflects the cleared state immediately.
24
- onChange("");
25
- };
45
+ const clearSnapshotTimer = useCallback((): void => {
46
+ if (snapshotTimerRef.current !== null) {
47
+ clearTimeout(snapshotTimerRef.current);
48
+ snapshotTimerRef.current = null;
49
+ }
50
+ }, []);
51
+
52
+ /**
53
+ * Snapshots the Skia canvas and reports a PNG data URL. Runs after a short
54
+ * delay so the just-completed stroke is painted before the snapshot.
55
+ */
56
+ const captureSignature = useCallback((): void => {
57
+ clearSnapshotTimer();
58
+ snapshotTimerRef.current = setTimeout(() => {
59
+ const image = canvasRef.current?.makeImageSnapshot();
60
+ if (!image) {
61
+ return;
62
+ }
63
+ const base64 = image.encodeToBase64(ImageFormat.PNG, 100);
64
+ if (base64 && base64.length > 0) {
65
+ onChange(`data:image/png;base64,${base64}`);
66
+ }
67
+ }, SNAPSHOT_DELAY_MS);
68
+ }, [canvasRef, clearSnapshotTimer, onChange]);
69
+
70
+ const beginStroke = useCallback(
71
+ (x: number, y: number): void => {
72
+ const next = `M${x.toFixed(2)} ${y.toFixed(2)}`;
73
+ activeStrokeRef.current = next;
74
+ setActiveStroke(next);
75
+ onStart?.();
76
+ },
77
+ [onStart]
78
+ );
26
79
 
27
- const onBegin = () => {
28
- onStart?.();
29
- };
80
+ const extendStroke = useCallback((x: number, y: number): void => {
81
+ const prev = activeStrokeRef.current;
82
+ if (prev === null) {
83
+ return;
84
+ }
85
+ const next = `${prev} L${x.toFixed(2)} ${y.toFixed(2)}`;
86
+ activeStrokeRef.current = next;
87
+ setActiveStroke(next);
88
+ }, []);
30
89
 
31
- // Called after end of stroke. Kind of goofy if you ask me,
32
- // but you need this in order to trigger the 'onOK' callback that gives us the actual image.
33
- const handleEnd = () => {
34
- ref.current?.readSignature();
90
+ const endStroke = useCallback((): void => {
91
+ const finished = activeStrokeRef.current;
92
+ activeStrokeRef.current = null;
93
+ setActiveStroke(null);
94
+ // A tap without movement has no line segment, so there is nothing to capture.
95
+ if (finished === null || !finished.includes("L")) {
96
+ return;
97
+ }
98
+ setCompletedStrokes((prev) => [...prev, finished]);
99
+ captureSignature();
35
100
  onEnd?.();
36
- };
101
+ }, [captureSignature, onEnd]);
102
+
103
+ const panGesture = useMemo(
104
+ () =>
105
+ Gesture.Pan()
106
+ .runOnJS(true)
107
+ .minDistance(0)
108
+ .onBegin((event) => {
109
+ beginStroke(event.x, event.y);
110
+ })
111
+ .onUpdate((event) => {
112
+ extendStroke(event.x, event.y);
113
+ })
114
+ .onEnd(() => {
115
+ endStroke();
116
+ })
117
+ .onFinalize(() => {
118
+ // Covers cancellation paths where onEnd does not fire.
119
+ if (activeStrokeRef.current !== null) {
120
+ endStroke();
121
+ }
122
+ }),
123
+ [beginStroke, extendStroke, endStroke]
124
+ );
125
+
126
+ const skiaPaths = useMemo(() => {
127
+ const allStrokes = activeStroke ? [...completedStrokes, activeStroke] : completedStrokes;
128
+ return allStrokes
129
+ .map((svg) => Skia.Path.MakeFromSVGString(svg))
130
+ .filter((path): path is NonNullable<typeof path> => path !== null);
131
+ }, [completedStrokes, activeStroke]);
132
+
133
+ const handleClear = useCallback((): void => {
134
+ clearSnapshotTimer();
135
+ activeStrokeRef.current = null;
136
+ setActiveStroke(null);
137
+ setCompletedStrokes([]);
138
+ // clearing must reset parent gating, mirroring the web Signature variant.
139
+ onChange("");
140
+ }, [clearSnapshotTimer, onChange]);
37
141
 
38
142
  return (
39
- <View style={{minWidth: 220}}>
40
- <View style={{borderColor: theme.border.dark, borderWidth: 1, minHeight: 90}}>
41
- <SignatureScreen
42
- backgroundColor={theme.surface.base}
43
- onBegin={onBegin}
44
- onEnd={handleEnd}
45
- onOK={(img) => onChange(img)}
46
- ref={ref}
47
- trimWhitespace
48
- webStyle={style}
49
- />
50
- </View>
143
+ <View style={{minWidth: 220, width: fullWidth ? "100%" : undefined}}>
144
+ <GestureDetector gesture={panGesture}>
145
+ <View
146
+ style={{
147
+ backgroundColor: theme.surface.base,
148
+ borderColor: theme.border.dark,
149
+ borderWidth: 1,
150
+ height: signaturePadHeight,
151
+ overflow: "hidden",
152
+ }}
153
+ >
154
+ <Canvas ref={canvasRef} style={{flex: 1}}>
155
+ {skiaPaths.map((path, index) => (
156
+ <Path
157
+ color={theme.text.secondaryDark}
158
+ // Strokes are append-only, so the index is a stable key here.
159
+ key={index}
160
+ path={path}
161
+ strokeCap="round"
162
+ strokeJoin="round"
163
+ strokeWidth={STROKE_WIDTH_PX}
164
+ style="stroke"
165
+ />
166
+ ))}
167
+ </Canvas>
168
+ </View>
169
+ </GestureDetector>
51
170
  <View style={{flexDirection: "row"}}>
52
171
  <Text
53
172
  onPress={handleClear}