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