@terreno/ui 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/ActionSheet.js +15 -27
  2. package/dist/ActionSheet.js.map +1 -1
  3. package/dist/Badge.js +1 -0
  4. package/dist/Badge.js.map +1 -1
  5. package/dist/Banner.d.ts +8 -0
  6. package/dist/Banner.js +2 -2
  7. package/dist/Banner.js.map +1 -1
  8. package/dist/MarkdownView.js +20 -7
  9. package/dist/MarkdownView.js.map +1 -1
  10. package/dist/PickerSelect.js +6 -2
  11. package/dist/PickerSelect.js.map +1 -1
  12. package/dist/Signature.d.ts +8 -1
  13. package/dist/Signature.js +93 -18
  14. package/dist/Signature.js.map +1 -1
  15. package/dist/Signature.native.d.ts +15 -0
  16. package/dist/Signature.native.js +116 -21
  17. package/dist/Signature.native.js.map +1 -1
  18. package/dist/TapToEdit.js +1 -1
  19. package/dist/TapToEdit.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/useConsentHistory.d.ts +6 -1
  24. package/dist/useConsentHistory.js +2 -1
  25. package/dist/useConsentHistory.js.map +1 -1
  26. package/package.json +2 -4
  27. package/src/ActionSheet.test.tsx +554 -0
  28. package/src/ActionSheet.tsx +24 -37
  29. package/src/Badge.test.tsx +7 -0
  30. package/src/Badge.tsx +1 -0
  31. package/src/Banner.test.tsx +58 -3
  32. package/src/Banner.tsx +3 -3
  33. package/src/DataTable.test.tsx +176 -1
  34. package/src/DateTimeField.test.tsx +942 -2
  35. package/src/Field.test.tsx +23 -0
  36. package/src/HeightActionSheet.test.tsx +1 -1
  37. package/src/HeightField.test.tsx +35 -0
  38. package/src/HeightFieldDesktop.test.tsx +19 -0
  39. package/src/MarkdownView.test.tsx +28 -0
  40. package/src/MarkdownView.tsx +69 -7
  41. package/src/MobileAddressAutoComplete.test.tsx +6 -2
  42. package/src/PickerSelect.test.tsx +265 -0
  43. package/src/PickerSelect.tsx +24 -8
  44. package/src/Signature.native.tsx +147 -30
  45. package/src/Signature.test.tsx +2 -49
  46. package/src/Signature.tsx +128 -22
  47. package/src/SignatureField.test.tsx +0 -9
  48. package/src/SplitPage.test.tsx +299 -43
  49. package/src/TapToEdit.test.tsx +46 -0
  50. package/src/TapToEdit.tsx +1 -1
  51. package/src/ToastNotifications.test.tsx +748 -1
  52. package/src/Tooltip.test.tsx +707 -1
  53. package/src/WebAddressAutocomplete.test.tsx +99 -0
  54. package/src/WebDropdownMenu.test.tsx +28 -2
  55. package/src/__snapshots__/Banner.test.tsx.snap +125 -0
  56. package/src/__snapshots__/CustomSelectField.test.tsx.snap +5 -4
  57. package/src/__snapshots__/DataTable.test.tsx.snap +366 -0
  58. package/src/__snapshots__/Field.test.tsx.snap +377 -0
  59. package/src/__snapshots__/MarkdownView.test.tsx.snap +284 -74
  60. package/src/__snapshots__/PickerSelect.test.tsx.snap +5 -4
  61. package/src/__snapshots__/SegmentedControl.test.tsx.snap +9 -0
  62. package/src/__snapshots__/SelectField.test.tsx.snap +5 -4
  63. package/src/__snapshots__/Signature.test.tsx.snap +13 -3
  64. package/src/__snapshots__/SignatureField.test.tsx.snap +10 -3
  65. package/src/__snapshots__/SplitPage.test.tsx.snap +698 -46
  66. package/src/bunSetup.ts +0 -19
  67. package/src/index.tsx +1 -1
  68. package/src/login/LoginScreen.test.tsx +12 -0
  69. package/src/useConsentHistory.test.ts +20 -13
  70. package/src/useConsentHistory.ts +7 -2
@@ -1,6 +1,7 @@
1
- import {type FC, useRef} from "react";
1
+ import {Canvas, ImageFormat, Path, Skia, useCanvasRef} from "@shopify/react-native-skia";
2
+ import {type FC, useCallback, useMemo, useRef, useState} from "react";
2
3
  import {Text, View} from "react-native";
3
- import SignatureScreen, {type SignatureViewRef} from "react-native-signature-canvas";
4
+ import {Gesture, GestureDetector} from "react-native-gesture-handler";
4
5
 
5
6
  import {useTheme} from "./Theme";
6
7
 
@@ -10,44 +11,160 @@ interface Props {
10
11
  onEnd?: () => void;
11
12
  }
12
13
 
13
- const style = `.m-signature-pad--footer {display: none; margin: 0px;}`;
14
+ const SIGNATURE_PAD_HEIGHT_PX = 180;
15
+ const STROKE_WIDTH_PX = 2.5;
16
+ // Snapshot after the released stroke has painted to the Skia canvas.
17
+ const SNAPSHOT_DELAY_MS = 60;
14
18
 
19
+ /**
20
+ * Native (iOS + Android) signature pad backed by Skia — no WebView.
21
+ *
22
+ * Replaces the previous react-native-signature-canvas WebView, which on iOS
23
+ * stayed on `about:blank` (its signature_pad script never loaded, so onOK
24
+ * never fired). Skia draws strokes natively and exports a PNG via
25
+ * makeImageSnapshot, which behaves consistently across both platforms.
26
+ *
27
+ * Touches are captured with react-native-gesture-handler rather than
28
+ * PanResponder because the Skia <Canvas> renders a native view that swallows
29
+ * React Native's JS touch responder.
30
+ *
31
+ * Reports the signature to the parent as a base64 PNG data URL via onChange,
32
+ * and pushes "" on clear so "signature required" gating resets immediately.
33
+ */
15
34
  export const Signature: FC<Props> = ({onChange, onStart, onEnd}: Props) => {
16
- const ref = useRef<SignatureViewRef>(null);
17
35
  const {theme} = useTheme();
36
+ const canvasRef = useCanvasRef();
37
+ const snapshotTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
38
+ // Completed strokes as SVG path strings; the active stroke is tracked separately.
39
+ const [completedStrokes, setCompletedStrokes] = useState<string[]>([]);
40
+ const [activeStroke, setActiveStroke] = useState<string | null>(null);
41
+ const activeStrokeRef = useRef<string | null>(null);
18
42
 
19
- const 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
- };
43
+ const clearSnapshotTimer = useCallback((): void => {
44
+ if (snapshotTimerRef.current !== null) {
45
+ clearTimeout(snapshotTimerRef.current);
46
+ snapshotTimerRef.current = null;
47
+ }
48
+ }, []);
49
+
50
+ /**
51
+ * Snapshots the Skia canvas and reports a PNG data URL. Runs after a short
52
+ * delay so the just-completed stroke is painted before the snapshot.
53
+ */
54
+ const captureSignature = useCallback((): void => {
55
+ clearSnapshotTimer();
56
+ snapshotTimerRef.current = setTimeout(() => {
57
+ const image = canvasRef.current?.makeImageSnapshot();
58
+ if (!image) {
59
+ return;
60
+ }
61
+ const base64 = image.encodeToBase64(ImageFormat.PNG, 100);
62
+ if (base64 && base64.length > 0) {
63
+ onChange(`data:image/png;base64,${base64}`);
64
+ }
65
+ }, SNAPSHOT_DELAY_MS);
66
+ }, [canvasRef, clearSnapshotTimer, onChange]);
67
+
68
+ const beginStroke = useCallback(
69
+ (x: number, y: number): void => {
70
+ const next = `M${x.toFixed(2)} ${y.toFixed(2)}`;
71
+ activeStrokeRef.current = next;
72
+ setActiveStroke(next);
73
+ onStart?.();
74
+ },
75
+ [onStart]
76
+ );
26
77
 
27
- const onBegin = () => {
28
- onStart?.();
29
- };
78
+ const extendStroke = useCallback((x: number, y: number): void => {
79
+ const prev = activeStrokeRef.current;
80
+ if (prev === null) {
81
+ return;
82
+ }
83
+ const next = `${prev} L${x.toFixed(2)} ${y.toFixed(2)}`;
84
+ activeStrokeRef.current = next;
85
+ setActiveStroke(next);
86
+ }, []);
30
87
 
31
- // 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();
88
+ const endStroke = useCallback((): void => {
89
+ const finished = activeStrokeRef.current;
90
+ activeStrokeRef.current = null;
91
+ setActiveStroke(null);
92
+ // A tap without movement has no line segment, so there is nothing to capture.
93
+ if (finished === null || !finished.includes("L")) {
94
+ return;
95
+ }
96
+ setCompletedStrokes((prev) => [...prev, finished]);
97
+ captureSignature();
35
98
  onEnd?.();
36
- };
99
+ }, [captureSignature, onEnd]);
100
+
101
+ const panGesture = useMemo(
102
+ () =>
103
+ Gesture.Pan()
104
+ .runOnJS(true)
105
+ .minDistance(0)
106
+ .onBegin((event) => {
107
+ beginStroke(event.x, event.y);
108
+ })
109
+ .onUpdate((event) => {
110
+ extendStroke(event.x, event.y);
111
+ })
112
+ .onEnd(() => {
113
+ endStroke();
114
+ })
115
+ .onFinalize(() => {
116
+ // Covers cancellation paths where onEnd does not fire.
117
+ if (activeStrokeRef.current !== null) {
118
+ endStroke();
119
+ }
120
+ }),
121
+ [beginStroke, extendStroke, endStroke]
122
+ );
123
+
124
+ const skiaPaths = useMemo(() => {
125
+ const allStrokes = activeStroke ? [...completedStrokes, activeStroke] : completedStrokes;
126
+ return allStrokes
127
+ .map((svg) => Skia.Path.MakeFromSVGString(svg))
128
+ .filter((path): path is NonNullable<typeof path> => path !== null);
129
+ }, [completedStrokes, activeStroke]);
130
+
131
+ const handleClear = useCallback((): void => {
132
+ clearSnapshotTimer();
133
+ activeStrokeRef.current = null;
134
+ setActiveStroke(null);
135
+ setCompletedStrokes([]);
136
+ // clearing must reset parent gating, mirroring the web Signature variant.
137
+ onChange("");
138
+ }, [clearSnapshotTimer, onChange]);
37
139
 
38
140
  return (
39
141
  <View style={{minWidth: 220}}>
40
- <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>
142
+ <GestureDetector gesture={panGesture}>
143
+ <View
144
+ style={{
145
+ backgroundColor: theme.surface.base,
146
+ borderColor: theme.border.dark,
147
+ borderWidth: 1,
148
+ height: SIGNATURE_PAD_HEIGHT_PX,
149
+ overflow: "hidden",
150
+ }}
151
+ >
152
+ <Canvas ref={canvasRef} style={{flex: 1}}>
153
+ {skiaPaths.map((path, index) => (
154
+ <Path
155
+ color={theme.text.secondaryDark}
156
+ // Strokes are append-only, so the index is a stable key here.
157
+ key={index}
158
+ path={path}
159
+ strokeCap="round"
160
+ strokeJoin="round"
161
+ strokeWidth={STROKE_WIDTH_PX}
162
+ style="stroke"
163
+ />
164
+ ))}
165
+ </Canvas>
166
+ </View>
167
+ </GestureDetector>
51
168
  <View style={{flexDirection: "row"}}>
52
169
  <Text
53
170
  onPress={handleClear}
@@ -1,29 +1,9 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
2
1
  import {describe, expect, it, mock} from "bun:test";
3
2
  import {fireEvent} from "@testing-library/react-native";
4
- import {forwardRef, useImperativeHandle} from "react";
5
- import {View} from "react-native";
6
3
 
7
4
  import {Signature} from "./Signature";
8
5
  import {renderWithTheme} from "./test-utils";
9
6
 
10
- const clearMock = mock(() => {});
11
- let toDataURLReturn: string = "";
12
- const toDataURLMock = mock(() => toDataURLReturn);
13
- let lastOnEnd: (() => void) | undefined;
14
-
15
- // Mock react-signature-canvas so we can exercise the ref methods and onEnd callback.
16
- mock.module("react-signature-canvas", () => ({
17
- default: forwardRef(({backgroundColor, onEnd}: any, ref) => {
18
- lastOnEnd = onEnd;
19
- useImperativeHandle(ref, () => ({
20
- clear: clearMock,
21
- toDataURL: toDataURLMock,
22
- }));
23
- return <View style={{backgroundColor}} testID="signature-canvas" />;
24
- }),
25
- }));
26
-
27
7
  describe("Signature", () => {
28
8
  it("renders correctly", () => {
29
9
  const mockOnChange = mock(() => {});
@@ -42,39 +22,12 @@ describe("Signature", () => {
42
22
  expect(getByText("Clear")).toBeTruthy();
43
23
  });
44
24
 
45
- it("calls clear on the signature canvas when Clear is pressed", () => {
46
- clearMock.mockClear();
47
- const mockOnChange = mock(() => {});
48
- const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
49
- fireEvent.press(getByText("Clear"));
50
- expect(clearMock).toHaveBeenCalledTimes(1);
51
- });
52
-
53
25
  it("notifies the parent with an empty value when Clear is pressed", () => {
54
- clearMock.mockClear();
55
26
  const mockOnChange = mock(() => {});
56
27
  const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
57
28
  fireEvent.press(getByText("Clear"));
58
- // Without this, "signature required" gating in parents would never reset
59
- // because the underlying canvas clear() does not fire onEnd/onOK.
29
+ // Clearing the canvas emits no draw event, so the component must push ""
30
+ // directly or "signature required" gating in parents would never reset.
60
31
  expect(mockOnChange).toHaveBeenCalledWith("");
61
32
  });
62
-
63
- it("calls onChange with the data URL when a stroke ends", () => {
64
- toDataURLReturn = "data:image/png;base64,abc";
65
- const mockOnChange = mock(() => {});
66
- renderWithTheme(<Signature onChange={mockOnChange} />);
67
- expect(lastOnEnd).toBeDefined();
68
- lastOnEnd?.();
69
- expect(mockOnChange).toHaveBeenCalledWith("data:image/png;base64,abc");
70
- });
71
-
72
- it("does not call onChange when toDataURL returns an empty value", () => {
73
- toDataURLReturn = "";
74
- const mockOnChange = mock(() => {});
75
- renderWithTheme(<Signature onChange={mockOnChange} />);
76
- expect(lastOnEnd).toBeDefined();
77
- lastOnEnd?.();
78
- expect(mockOnChange).not.toHaveBeenCalled();
79
- });
80
33
  });
package/src/Signature.tsx CHANGED
@@ -1,6 +1,11 @@
1
- import {type ReactElement, useRef} from "react";
1
+ import {
2
+ type ReactElement,
3
+ type PointerEvent as ReactPointerEvent,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ } from "react";
2
8
  import {Text, View} from "react-native";
3
- import SignatureCanvas from "react-signature-canvas";
4
9
 
5
10
  import {useTheme} from "./Theme";
6
11
 
@@ -11,35 +16,136 @@ export interface SignatureProps {
11
16
  value?: string; // note this
12
17
  }
13
18
 
14
- export const Signature = ({onChange}: SignatureProps): ReactElement | null => {
15
- const ref = useRef<SignatureCanvas>(null);
19
+ const SIGNATURE_WIDTH_PX = 300;
20
+ const SIGNATURE_HEIGHT_PX = 180;
21
+ const STROKE_WIDTH_PX = 2.5;
22
+
23
+ /**
24
+ * Web signature pad backed by a raw HTML5 <canvas> — no third-party library.
25
+ *
26
+ * Pointer events capture strokes and the canvas is exported as a base64 PNG
27
+ * data URL via onChange. Clearing pushes "" so "signature required" gating in
28
+ * parents resets immediately, since clearing the canvas emits no draw event.
29
+ */
30
+ export const Signature = ({onChange, onStart, onEnd}: SignatureProps): ReactElement => {
16
31
  const {theme} = useTheme();
32
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
33
+ const isDrawingRef = useRef(false);
34
+ const hasDrawnRef = useRef(false);
17
35
 
18
- const onClear = () => {
19
- ref.current?.clear();
20
- // `clear()` on the underlying canvas does not fire `onEnd`, so the parent
21
- // never learns the signature is gone. Push an empty value so any
22
- // "signature required" gating reflects the cleared state immediately.
23
- onChange("");
24
- };
36
+ const getContext = useCallback((): CanvasRenderingContext2D | null => {
37
+ const canvas = canvasRef.current;
38
+ if (!canvas || typeof canvas.getContext !== "function") {
39
+ return null;
40
+ }
41
+ return canvas.getContext("2d");
42
+ }, []);
25
43
 
26
- const onUpdatedSignature = () => {
27
- if (ref.current?.toDataURL()) {
28
- onChange(ref.current.toDataURL());
44
+ /**
45
+ * Paints the opaque background and configures stroke styling. Re-runs when
46
+ * the theme changes so the pad matches the active light/dark colors.
47
+ */
48
+ const resetCanvas = useCallback((): void => {
49
+ const canvas = canvasRef.current;
50
+ const ctx = getContext();
51
+ if (!canvas || !ctx) {
52
+ return;
29
53
  }
30
- };
54
+ ctx.fillStyle = theme.surface.base;
55
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
56
+ ctx.strokeStyle = theme.text.secondaryDark;
57
+ ctx.lineWidth = STROKE_WIDTH_PX;
58
+ ctx.lineCap = "round";
59
+ ctx.lineJoin = "round";
60
+ }, [getContext, theme.surface.base, theme.text.secondaryDark]);
61
+
62
+ // Initialize the canvas background and stroke styling once the element mounts.
63
+ useEffect((): void => {
64
+ resetCanvas();
65
+ }, [resetCanvas]);
66
+
67
+ const handlePointerDown = useCallback(
68
+ (event: ReactPointerEvent<HTMLCanvasElement>): void => {
69
+ const ctx = getContext();
70
+ if (!ctx) {
71
+ return;
72
+ }
73
+ canvasRef.current?.setPointerCapture?.(event.pointerId);
74
+ isDrawingRef.current = true;
75
+ ctx.beginPath();
76
+ ctx.moveTo(event.nativeEvent.offsetX, event.nativeEvent.offsetY);
77
+ onStart?.();
78
+ },
79
+ [getContext, onStart]
80
+ );
81
+
82
+ const handlePointerMove = useCallback(
83
+ (event: ReactPointerEvent<HTMLCanvasElement>): void => {
84
+ if (!isDrawingRef.current) {
85
+ return;
86
+ }
87
+ const ctx = getContext();
88
+ if (!ctx) {
89
+ return;
90
+ }
91
+ ctx.lineTo(event.nativeEvent.offsetX, event.nativeEvent.offsetY);
92
+ ctx.stroke();
93
+ hasDrawnRef.current = true;
94
+ },
95
+ [getContext]
96
+ );
97
+
98
+ const handlePointerUp = useCallback(
99
+ (event: ReactPointerEvent<HTMLCanvasElement>): void => {
100
+ if (!isDrawingRef.current) {
101
+ return;
102
+ }
103
+ isDrawingRef.current = false;
104
+ canvasRef.current?.releasePointerCapture?.(event.pointerId);
105
+ const canvas = canvasRef.current;
106
+ if (hasDrawnRef.current && canvas) {
107
+ onChange(canvas.toDataURL("image/png"));
108
+ }
109
+ onEnd?.();
110
+ },
111
+ [onChange, onEnd]
112
+ );
113
+
114
+ const handleClear = useCallback((): void => {
115
+ hasDrawnRef.current = false;
116
+ isDrawingRef.current = false;
117
+ resetCanvas();
118
+ // Clearing the canvas emits no draw event, so notify the parent directly
119
+ // to reset any "signature required" gating.
120
+ onChange("");
121
+ }, [resetCanvas, onChange]);
122
+
31
123
  return (
32
124
  <View>
33
- <View style={{borderColor: theme.border.dark, borderWidth: 1, maxWidth: 300, width: "100%"}}>
34
- <SignatureCanvas
35
- backgroundColor={theme.surface.base}
36
- onEnd={onUpdatedSignature}
37
- penColor={theme.text.secondaryDark}
38
- ref={ref}
125
+ <View
126
+ style={{
127
+ borderColor: theme.border.dark,
128
+ borderWidth: 1,
129
+ maxWidth: SIGNATURE_WIDTH_PX,
130
+ width: "100%",
131
+ }}
132
+ >
133
+ <canvas
134
+ height={SIGNATURE_HEIGHT_PX}
135
+ onPointerDown={handlePointerDown}
136
+ onPointerLeave={handlePointerUp}
137
+ onPointerMove={handlePointerMove}
138
+ onPointerUp={handlePointerUp}
139
+ ref={canvasRef}
140
+ style={{height: SIGNATURE_HEIGHT_PX, touchAction: "none", width: SIGNATURE_WIDTH_PX}}
141
+ width={SIGNATURE_WIDTH_PX}
39
142
  />
40
143
  </View>
41
144
  <View>
42
- <Text onPress={onClear} style={{color: theme.text.link, textDecorationLine: "underline"}}>
145
+ <Text
146
+ onPress={handleClear}
147
+ style={{color: theme.text.link, textDecorationLine: "underline"}}
148
+ >
43
149
  Clear
44
150
  </Text>
45
151
  </View>
@@ -1,18 +1,9 @@
1
1
  import {describe, expect, it, mock} from "bun:test";
2
- import {forwardRef} from "react";
3
- import {View} from "react-native";
4
2
  import type {ReactTestInstance} from "react-test-renderer";
5
3
 
6
4
  import {SignatureField} from "./SignatureField";
7
5
  import {renderWithTheme} from "./test-utils";
8
6
 
9
- // Mock react-signature-canvas (used by Signature component)
10
- mock.module("react-signature-canvas", () => ({
11
- default: forwardRef<View, {backgroundColor?: string}>(({backgroundColor}, ref) => (
12
- <View ref={ref} style={{backgroundColor}} testID="signature-canvas" />
13
- )),
14
- }));
15
-
16
7
  describe("SignatureField", () => {
17
8
  const defaultProps = {
18
9
  onChange: () => {},