@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.
- 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/Common.d.ts +1 -0
- package/dist/Common.js.map +1 -1
- package/dist/ConsentFormScreen.js +4 -2
- package/dist/ConsentFormScreen.js.map +1 -1
- package/dist/DismissButton.js +3 -2
- package/dist/DismissButton.js.map +1 -1
- package/dist/PickerSelect.js +6 -2
- package/dist/PickerSelect.js.map +1 -1
- package/dist/Signature.d.ts +9 -1
- package/dist/Signature.js +121 -18
- package/dist/Signature.js.map +1 -1
- package/dist/Signature.native.d.ts +16 -0
- package/dist/Signature.native.js +119 -23
- package/dist/Signature.native.js.map +1 -1
- package/dist/SignatureField.d.ts +1 -1
- package/dist/SignatureField.js +2 -2
- package/dist/SignatureField.js.map +1 -1
- package/dist/SignatureSizing.d.ts +3 -0
- package/dist/SignatureSizing.js +9 -0
- package/dist/SignatureSizing.js.map +1 -0
- package/dist/TapToEdit.js +1 -1
- package/dist/TapToEdit.js.map +1 -1
- package/dist/Toast.d.ts +4 -4
- package/dist/Toast.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/Common.ts +1 -0
- package/src/ConsentFormScreen.test.tsx +15 -0
- package/src/ConsentFormScreen.tsx +21 -4
- package/src/DateTimeField.test.tsx +226 -0
- package/src/DismissButton.tsx +4 -3
- package/src/Field.test.tsx +23 -0
- package/src/IconButton.tsx +2 -2
- package/src/PickerSelect.test.tsx +22 -0
- package/src/PickerSelect.tsx +24 -8
- package/src/Signature.native.test.tsx +9 -0
- package/src/Signature.native.tsx +152 -33
- package/src/Signature.test.tsx +324 -39
- package/src/Signature.tsx +171 -22
- package/src/SignatureField.test.tsx +0 -9
- package/src/SignatureField.tsx +2 -0
- package/src/SignatureSizing.ts +10 -0
- package/src/TapToEdit.test.tsx +33 -0
- package/src/TapToEdit.tsx +1 -1
- package/src/Toast.tsx +5 -3
- package/src/ToastNotifications.test.tsx +74 -1
- package/src/__snapshots__/CustomSelectField.test.tsx.snap +5 -4
- package/src/__snapshots__/DismissButton.test.tsx.snap +9 -3
- package/src/__snapshots__/Field.test.tsx.snap +379 -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 +15 -3
- package/src/__snapshots__/SignatureField.test.tsx.snap +12 -3
- package/src/bunSetup.ts +0 -15
- 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
|
});
|
package/src/DismissButton.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
-
import {Pressable
|
|
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
|
-
<
|
|
27
|
+
<Box>
|
|
27
28
|
<Icon color={color} iconName="x" type="solid" />
|
|
28
|
-
</
|
|
29
|
+
</Box>
|
|
29
30
|
</Pressable>
|
|
30
31
|
);
|
|
31
32
|
};
|
package/src/Field.test.tsx
CHANGED
|
@@ -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
|
});
|
package/src/IconButton.tsx
CHANGED
|
@@ -12,14 +12,14 @@ import {Tooltip} from "./Tooltip";
|
|
|
12
12
|
import {Unifier} from "./Unifier";
|
|
13
13
|
import {isNative} from "./Utilities";
|
|
14
14
|
|
|
15
|
-
|
|
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", () => {
|
package/src/PickerSelect.tsx
CHANGED
|
@@ -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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
});
|
package/src/Signature.native.tsx
CHANGED
|
@@ -1,53 +1,172 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
<
|
|
41
|
-
<
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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}
|