@terreno/ui 0.15.0 → 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/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/Signature.d.ts +2 -1
- package/dist/Signature.js +37 -9
- package/dist/Signature.js.map +1 -1
- package/dist/Signature.native.d.ts +1 -0
- package/dist/Signature.native.js +6 -5
- 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/Toast.d.ts +4 -4
- package/dist/Toast.js.map +1 -1
- package/package.json +1 -1
- package/src/Common.ts +1 -0
- package/src/ConsentFormScreen.test.tsx +15 -0
- package/src/ConsentFormScreen.tsx +21 -4
- package/src/DismissButton.tsx +4 -3
- package/src/IconButton.tsx +2 -2
- package/src/Signature.native.test.tsx +9 -0
- package/src/Signature.native.tsx +7 -5
- package/src/Signature.test.tsx +336 -4
- package/src/Signature.tsx +55 -12
- package/src/SignatureField.tsx +2 -0
- package/src/SignatureSizing.ts +10 -0
- package/src/Toast.tsx +5 -3
- package/src/__snapshots__/DismissButton.test.tsx.snap +9 -3
- package/src/__snapshots__/Field.test.tsx.snap +3 -1
- package/src/__snapshots__/Signature.test.tsx.snap +3 -1
- package/src/__snapshots__/SignatureField.test.tsx.snap +3 -1
package/src/Signature.native.tsx
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import {Canvas, ImageFormat, Path, Skia, useCanvasRef} from "@shopify/react-native-skia";
|
|
2
2
|
import {type FC, useCallback, useMemo, useRef, useState} from "react";
|
|
3
|
-
import {Text, View} from "react-native";
|
|
3
|
+
import {Platform, Text, View} from "react-native";
|
|
4
4
|
import {Gesture, GestureDetector} from "react-native-gesture-handler";
|
|
5
5
|
|
|
6
|
+
import {getSignaturePadHeight} from "./SignatureSizing";
|
|
6
7
|
import {useTheme} from "./Theme";
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
9
10
|
onChange: (signature: string) => void;
|
|
10
11
|
onStart?: () => void;
|
|
11
12
|
onEnd?: () => void;
|
|
13
|
+
fullWidth?: boolean;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
const SIGNATURE_PAD_HEIGHT_PX = 180;
|
|
15
16
|
const STROKE_WIDTH_PX = 2.5;
|
|
16
17
|
// Snapshot after the released stroke has painted to the Skia canvas.
|
|
17
18
|
const SNAPSHOT_DELAY_MS = 60;
|
|
@@ -31,9 +32,10 @@ const SNAPSHOT_DELAY_MS = 60;
|
|
|
31
32
|
* Reports the signature to the parent as a base64 PNG data URL via onChange,
|
|
32
33
|
* and pushes "" on clear so "signature required" gating resets immediately.
|
|
33
34
|
*/
|
|
34
|
-
export const Signature: FC<Props> = ({onChange, onStart, onEnd}: Props) => {
|
|
35
|
+
export const Signature: FC<Props> = ({fullWidth = false, onChange, onStart, onEnd}: Props) => {
|
|
35
36
|
const {theme} = useTheme();
|
|
36
37
|
const canvasRef = useCanvasRef();
|
|
38
|
+
const signaturePadHeight = getSignaturePadHeight(Platform.OS);
|
|
37
39
|
const snapshotTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
38
40
|
// Completed strokes as SVG path strings; the active stroke is tracked separately.
|
|
39
41
|
const [completedStrokes, setCompletedStrokes] = useState<string[]>([]);
|
|
@@ -138,14 +140,14 @@ export const Signature: FC<Props> = ({onChange, onStart, onEnd}: Props) => {
|
|
|
138
140
|
}, [clearSnapshotTimer, onChange]);
|
|
139
141
|
|
|
140
142
|
return (
|
|
141
|
-
<View style={{minWidth: 220}}>
|
|
143
|
+
<View style={{minWidth: 220, width: fullWidth ? "100%" : undefined}}>
|
|
142
144
|
<GestureDetector gesture={panGesture}>
|
|
143
145
|
<View
|
|
144
146
|
style={{
|
|
145
147
|
backgroundColor: theme.surface.base,
|
|
146
148
|
borderColor: theme.border.dark,
|
|
147
149
|
borderWidth: 1,
|
|
148
|
-
height:
|
|
150
|
+
height: signaturePadHeight,
|
|
149
151
|
overflow: "hidden",
|
|
150
152
|
}}
|
|
151
153
|
>
|
package/src/Signature.test.tsx
CHANGED
|
@@ -1,10 +1,51 @@
|
|
|
1
|
-
import {describe, expect, it, mock} from "bun:test";
|
|
2
|
-
import {fireEvent} from "@testing-library/react-native";
|
|
1
|
+
import {afterEach, describe, expect, it, mock} from "bun:test";
|
|
2
|
+
import {act, fireEvent} from "@testing-library/react-native";
|
|
3
|
+
import {View} from "react-native";
|
|
3
4
|
|
|
4
5
|
import {Signature} from "./Signature";
|
|
5
6
|
import {renderWithTheme} from "./test-utils";
|
|
6
7
|
|
|
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
|
+
};
|
|
43
|
+
|
|
7
44
|
describe("Signature", () => {
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
mock.restore();
|
|
47
|
+
});
|
|
48
|
+
|
|
8
49
|
it("renders correctly", () => {
|
|
9
50
|
const mockOnChange = mock(() => {});
|
|
10
51
|
const {toJSON} = renderWithTheme(<Signature onChange={mockOnChange} />);
|
|
@@ -22,12 +63,303 @@ describe("Signature", () => {
|
|
|
22
63
|
expect(getByText("Clear")).toBeTruthy();
|
|
23
64
|
});
|
|
24
65
|
|
|
66
|
+
it("scales the web canvas to the available container width", () => {
|
|
67
|
+
const mockOnChange = mock(() => {});
|
|
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);
|
|
115
|
+
});
|
|
116
|
+
|
|
25
117
|
it("notifies the parent with an empty value when Clear is pressed", () => {
|
|
26
118
|
const mockOnChange = mock(() => {});
|
|
27
119
|
const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
|
|
28
120
|
fireEvent.press(getByText("Clear"));
|
|
29
|
-
// Clearing the canvas emits no draw event, so the component must push ""
|
|
30
|
-
// directly or "signature required" gating in parents would never reset.
|
|
31
121
|
expect(mockOnChange).toHaveBeenCalledWith("");
|
|
32
122
|
});
|
|
123
|
+
|
|
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", () => {
|
|
150
|
+
const mockOnChange = mock(() => {});
|
|
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();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("ignores pointerMove when not drawing", () => {
|
|
178
|
+
const mockOnChange = mock(() => {});
|
|
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)
|
|
265
|
+
expect(mockOnChange).not.toHaveBeenCalled();
|
|
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
|
+
});
|
|
33
365
|
});
|
package/src/Signature.tsx
CHANGED
|
@@ -13,6 +13,7 @@ export interface SignatureProps {
|
|
|
13
13
|
onChange: (signature: string) => void;
|
|
14
14
|
onStart?: () => void;
|
|
15
15
|
onEnd?: () => void;
|
|
16
|
+
fullWidth?: boolean;
|
|
16
17
|
value?: string; // note this
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -20,6 +21,13 @@ const SIGNATURE_WIDTH_PX = 300;
|
|
|
20
21
|
const SIGNATURE_HEIGHT_PX = 180;
|
|
21
22
|
const STROKE_WIDTH_PX = 2.5;
|
|
22
23
|
|
|
24
|
+
interface CanvasPoint {
|
|
25
|
+
x: number;
|
|
26
|
+
y: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type DrawingEvent = ReactPointerEvent<HTMLCanvasElement>;
|
|
30
|
+
|
|
23
31
|
/**
|
|
24
32
|
* Web signature pad backed by a raw HTML5 <canvas> — no third-party library.
|
|
25
33
|
*
|
|
@@ -27,7 +35,12 @@ const STROKE_WIDTH_PX = 2.5;
|
|
|
27
35
|
* data URL via onChange. Clearing pushes "" so "signature required" gating in
|
|
28
36
|
* parents resets immediately, since clearing the canvas emits no draw event.
|
|
29
37
|
*/
|
|
30
|
-
export const Signature = ({
|
|
38
|
+
export const Signature = ({
|
|
39
|
+
fullWidth = false,
|
|
40
|
+
onChange,
|
|
41
|
+
onStart,
|
|
42
|
+
onEnd,
|
|
43
|
+
}: SignatureProps): ReactElement => {
|
|
31
44
|
const {theme} = useTheme();
|
|
32
45
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
33
46
|
const isDrawingRef = useRef(false);
|
|
@@ -41,6 +54,28 @@ export const Signature = ({onChange, onStart, onEnd}: SignatureProps): ReactElem
|
|
|
41
54
|
return canvas.getContext("2d");
|
|
42
55
|
}, []);
|
|
43
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
|
+
}, []);
|
|
78
|
+
|
|
44
79
|
/**
|
|
45
80
|
* Paints the opaque background and configures stroke styling. Re-runs when
|
|
46
81
|
* the theme changes so the pad matches the active light/dark colors.
|
|
@@ -65,38 +100,40 @@ export const Signature = ({onChange, onStart, onEnd}: SignatureProps): ReactElem
|
|
|
65
100
|
}, [resetCanvas]);
|
|
66
101
|
|
|
67
102
|
const handlePointerDown = useCallback(
|
|
68
|
-
(event:
|
|
103
|
+
(event: DrawingEvent): void => {
|
|
69
104
|
const ctx = getContext();
|
|
70
|
-
|
|
105
|
+
const point = getCanvasPoint(event);
|
|
106
|
+
if (!ctx || !point) {
|
|
71
107
|
return;
|
|
72
108
|
}
|
|
73
109
|
canvasRef.current?.setPointerCapture?.(event.pointerId);
|
|
74
110
|
isDrawingRef.current = true;
|
|
75
111
|
ctx.beginPath();
|
|
76
|
-
ctx.moveTo(
|
|
112
|
+
ctx.moveTo(point.x, point.y);
|
|
77
113
|
onStart?.();
|
|
78
114
|
},
|
|
79
|
-
[getContext, onStart]
|
|
115
|
+
[getCanvasPoint, getContext, onStart]
|
|
80
116
|
);
|
|
81
117
|
|
|
82
118
|
const handlePointerMove = useCallback(
|
|
83
|
-
(event:
|
|
119
|
+
(event: DrawingEvent): void => {
|
|
84
120
|
if (!isDrawingRef.current) {
|
|
85
121
|
return;
|
|
86
122
|
}
|
|
87
123
|
const ctx = getContext();
|
|
88
|
-
|
|
124
|
+
const point = getCanvasPoint(event);
|
|
125
|
+
if (!ctx || !point) {
|
|
89
126
|
return;
|
|
90
127
|
}
|
|
91
|
-
ctx.lineTo(
|
|
128
|
+
ctx.lineTo(point.x, point.y);
|
|
92
129
|
ctx.stroke();
|
|
93
130
|
hasDrawnRef.current = true;
|
|
94
131
|
},
|
|
95
|
-
[getContext]
|
|
132
|
+
[getCanvasPoint, getContext]
|
|
96
133
|
);
|
|
97
134
|
|
|
98
135
|
const handlePointerUp = useCallback(
|
|
99
|
-
(event:
|
|
136
|
+
(event: DrawingEvent): void => {
|
|
100
137
|
if (!isDrawingRef.current) {
|
|
101
138
|
return;
|
|
102
139
|
}
|
|
@@ -126,7 +163,7 @@ export const Signature = ({onChange, onStart, onEnd}: SignatureProps): ReactElem
|
|
|
126
163
|
style={{
|
|
127
164
|
borderColor: theme.border.dark,
|
|
128
165
|
borderWidth: 1,
|
|
129
|
-
maxWidth: SIGNATURE_WIDTH_PX,
|
|
166
|
+
maxWidth: fullWidth ? undefined : SIGNATURE_WIDTH_PX,
|
|
130
167
|
width: "100%",
|
|
131
168
|
}}
|
|
132
169
|
>
|
|
@@ -137,7 +174,13 @@ export const Signature = ({onChange, onStart, onEnd}: SignatureProps): ReactElem
|
|
|
137
174
|
onPointerMove={handlePointerMove}
|
|
138
175
|
onPointerUp={handlePointerUp}
|
|
139
176
|
ref={canvasRef}
|
|
140
|
-
style={{
|
|
177
|
+
style={{
|
|
178
|
+
display: "block",
|
|
179
|
+
height: SIGNATURE_HEIGHT_PX,
|
|
180
|
+
maxWidth: "100%",
|
|
181
|
+
touchAction: "none",
|
|
182
|
+
width: "100%",
|
|
183
|
+
}}
|
|
141
184
|
width={SIGNATURE_WIDTH_PX}
|
|
142
185
|
/>
|
|
143
186
|
</View>
|
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
|
+
};
|
package/src/Toast.tsx
CHANGED
|
@@ -11,15 +11,17 @@ import {isAPIError, printAPIError} from "./Utilities";
|
|
|
11
11
|
|
|
12
12
|
const TOAST_DURATION_MS = 3 * 1000;
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface UseToastVariantOptions {
|
|
15
15
|
persistent?: ToastProps["persistent"];
|
|
16
16
|
secondary?: ToastProps["secondary"];
|
|
17
17
|
size?: ToastProps["size"];
|
|
18
18
|
onDismiss?: ToastProps["onDismiss"];
|
|
19
19
|
subtitle?: ToastProps["subtitle"];
|
|
20
|
-
}
|
|
20
|
+
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
interface UseToastOptions extends UseToastVariantOptions {
|
|
23
|
+
variant?: ToastProps["variant"];
|
|
24
|
+
}
|
|
23
25
|
|
|
24
26
|
export const useToast = (): {
|
|
25
27
|
hide: (id: string) => void;
|
|
@@ -8,7 +8,9 @@ exports[`DismissButton renders correctly with default props 1`] = `
|
|
|
8
8
|
"$$typeof": Symbol(react.test.json),
|
|
9
9
|
"children": null,
|
|
10
10
|
"props": {
|
|
11
|
-
"
|
|
11
|
+
"onPointerEnter": [Function: AsyncFunction],
|
|
12
|
+
"onPointerLeave": [Function: AsyncFunction],
|
|
13
|
+
"style": {},
|
|
12
14
|
"testID": undefined,
|
|
13
15
|
},
|
|
14
16
|
"type": "View",
|
|
@@ -38,7 +40,9 @@ exports[`DismissButton renders with primary color (default) 1`] = `
|
|
|
38
40
|
"$$typeof": Symbol(react.test.json),
|
|
39
41
|
"children": null,
|
|
40
42
|
"props": {
|
|
41
|
-
"
|
|
43
|
+
"onPointerEnter": [Function: AsyncFunction],
|
|
44
|
+
"onPointerLeave": [Function: AsyncFunction],
|
|
45
|
+
"style": {},
|
|
42
46
|
"testID": undefined,
|
|
43
47
|
},
|
|
44
48
|
"type": "View",
|
|
@@ -68,7 +72,9 @@ exports[`DismissButton renders with inverted color 1`] = `
|
|
|
68
72
|
"$$typeof": Symbol(react.test.json),
|
|
69
73
|
"children": null,
|
|
70
74
|
"props": {
|
|
71
|
-
"
|
|
75
|
+
"onPointerEnter": [Function: AsyncFunction],
|
|
76
|
+
"onPointerLeave": [Function: AsyncFunction],
|
|
77
|
+
"style": {},
|
|
72
78
|
"testID": undefined,
|
|
73
79
|
},
|
|
74
80
|
"type": "View",
|
|
@@ -4991,9 +4991,11 @@ exports[`Field renders signature field 1`] = `
|
|
|
4991
4991
|
"current": null,
|
|
4992
4992
|
},
|
|
4993
4993
|
"style": {
|
|
4994
|
+
"display": "block",
|
|
4994
4995
|
"height": 180,
|
|
4996
|
+
"maxWidth": "100%",
|
|
4995
4997
|
"touchAction": "none",
|
|
4996
|
-
"width":
|
|
4998
|
+
"width": "100%",
|
|
4997
4999
|
},
|
|
4998
5000
|
"width": 300,
|
|
4999
5001
|
},
|
|
@@ -42,9 +42,11 @@ exports[`SignatureField renders correctly with default props 1`] = `
|
|
|
42
42
|
"current": null,
|
|
43
43
|
},
|
|
44
44
|
"style": {
|
|
45
|
+
"display": "block",
|
|
45
46
|
"height": 180,
|
|
47
|
+
"maxWidth": "100%",
|
|
46
48
|
"touchAction": "none",
|
|
47
|
-
"width":
|
|
49
|
+
"width": "100%",
|
|
48
50
|
},
|
|
49
51
|
"width": 300,
|
|
50
52
|
},
|