@terreno/ui 0.0.18 → 0.2.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 (95) hide show
  1. package/dist/Button.js +1 -1
  2. package/dist/Button.js.map +1 -1
  3. package/dist/Common.d.ts +45 -1
  4. package/dist/Hyperlink.js +2 -2
  5. package/dist/Hyperlink.js.map +1 -1
  6. package/dist/Page.js +2 -1
  7. package/dist/Page.js.map +1 -1
  8. package/dist/SocialLoginButton.d.ts +19 -0
  9. package/dist/SocialLoginButton.js +119 -0
  10. package/dist/SocialLoginButton.js.map +1 -0
  11. package/dist/Text.js +3 -0
  12. package/dist/Text.js.map +1 -1
  13. package/dist/Theme.js +27 -27
  14. package/dist/Theme.js.map +1 -1
  15. package/dist/Toast.js +5 -2
  16. package/dist/Toast.js.map +1 -1
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.js +3 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/login/LoginScreen.d.ts +25 -0
  21. package/dist/login/LoginScreen.js +55 -0
  22. package/dist/login/LoginScreen.js.map +1 -0
  23. package/dist/login/index.d.ts +2 -0
  24. package/dist/login/index.js +2 -0
  25. package/dist/login/index.js.map +1 -0
  26. package/dist/login/loginTypes.d.ts +48 -0
  27. package/dist/login/loginTypes.js +2 -0
  28. package/dist/login/loginTypes.js.map +1 -0
  29. package/dist/signUp/OAuthButtons.d.ts +18 -0
  30. package/dist/signUp/OAuthButtons.js +15 -0
  31. package/dist/signUp/OAuthButtons.js.map +1 -0
  32. package/dist/signUp/PasswordRequirements.d.ts +15 -0
  33. package/dist/signUp/PasswordRequirements.js +14 -0
  34. package/dist/signUp/PasswordRequirements.js.map +1 -0
  35. package/dist/signUp/SignUpScreen.d.ts +26 -0
  36. package/dist/signUp/SignUpScreen.js +64 -0
  37. package/dist/signUp/SignUpScreen.js.map +1 -0
  38. package/dist/signUp/Swiper.d.ts +13 -0
  39. package/dist/signUp/Swiper.js +16 -0
  40. package/dist/signUp/Swiper.js.map +1 -0
  41. package/dist/signUp/index.d.ts +6 -0
  42. package/dist/signUp/index.js +6 -0
  43. package/dist/signUp/index.js.map +1 -0
  44. package/dist/signUp/passwordPresets.d.ts +9 -0
  45. package/dist/signUp/passwordPresets.js +41 -0
  46. package/dist/signUp/passwordPresets.js.map +1 -0
  47. package/dist/signUp/signUpTypes.d.ts +90 -0
  48. package/dist/signUp/signUpTypes.js +2 -0
  49. package/dist/signUp/signUpTypes.js.map +1 -0
  50. package/package.json +10 -7
  51. package/src/Button.tsx +1 -1
  52. package/src/Common.ts +53 -2
  53. package/src/Hyperlink.tsx +2 -10
  54. package/src/Page.tsx +3 -2
  55. package/src/SocialLoginButton.test.tsx +158 -0
  56. package/src/SocialLoginButton.tsx +182 -0
  57. package/src/Text.tsx +3 -0
  58. package/src/Theme.tsx +33 -27
  59. package/src/Toast.tsx +5 -2
  60. package/src/__snapshots__/Button.test.tsx.snap +12 -12
  61. package/src/__snapshots__/DecimalRangeActionSheet.test.tsx.snap +2 -2
  62. package/src/__snapshots__/ErrorPage.test.tsx.snap +1 -1
  63. package/src/__snapshots__/Field.test.tsx.snap +138 -138
  64. package/src/__snapshots__/HeightActionSheet.test.tsx.snap +2 -2
  65. package/src/__snapshots__/InfoModalIcon.test.tsx.snap +4 -4
  66. package/src/__snapshots__/Modal.test.tsx.snap +3 -3
  67. package/src/__snapshots__/NumberPickerActionSheet.test.tsx.snap +2 -2
  68. package/src/__snapshots__/Page.test.tsx.snap +1 -1
  69. package/src/__snapshots__/PhoneNumberField.test.tsx.snap +17 -17
  70. package/src/__snapshots__/SocialLoginButton.test.tsx.snap +277 -0
  71. package/src/bunSetup.ts +23 -0
  72. package/src/index.tsx +6 -0
  73. package/src/login/LoginScreen.test.tsx +148 -0
  74. package/src/login/LoginScreen.tsx +159 -0
  75. package/src/login/__snapshots__/LoginScreen.test.tsx.snap +630 -0
  76. package/src/login/index.ts +2 -0
  77. package/src/login/loginTypes.ts +51 -0
  78. package/src/signUp/OAuthButtons.test.tsx +45 -0
  79. package/src/signUp/OAuthButtons.tsx +52 -0
  80. package/src/signUp/PasswordRequirements.test.tsx +41 -0
  81. package/src/signUp/PasswordRequirements.tsx +49 -0
  82. package/src/signUp/SignUpScreen.test.tsx +134 -0
  83. package/src/signUp/SignUpScreen.tsx +172 -0
  84. package/src/signUp/Swiper.test.tsx +46 -0
  85. package/src/signUp/Swiper.tsx +59 -0
  86. package/src/signUp/__snapshots__/OAuthButtons.test.tsx.snap +272 -0
  87. package/src/signUp/__snapshots__/PasswordRequirements.test.tsx.snap +427 -0
  88. package/src/signUp/__snapshots__/SignUpScreen.test.tsx.snap +851 -0
  89. package/src/signUp/__snapshots__/Swiper.test.tsx.snap +249 -0
  90. package/src/signUp/index.ts +13 -0
  91. package/src/signUp/passwordPresets.test.ts +57 -0
  92. package/src/signUp/passwordPresets.ts +43 -0
  93. package/src/signUp/signUpTypes.ts +94 -0
  94. package/src/table/__snapshots__/TableDate.test.tsx.snap +4 -4
  95. package/src/table/__snapshots__/TableRow.test.tsx.snap +64 -64
@@ -0,0 +1,277 @@
1
+ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots
2
+
3
+ exports[`SocialLoginButton renders correctly with Google provider 1`] = `
4
+ {
5
+ "$$typeof": Symbol(react.test.json),
6
+ "children": [
7
+ {
8
+ "$$typeof": Symbol(react.test.json),
9
+ "children": [
10
+ {
11
+ "$$typeof": Symbol(react.test.json),
12
+ "children": null,
13
+ "props": {
14
+ "style": {
15
+ "marginRight": 12,
16
+ },
17
+ "testID": undefined,
18
+ },
19
+ "type": "View",
20
+ },
21
+ {
22
+ "$$typeof": Symbol(react.test.json),
23
+ "children": [
24
+ "Continue with Google",
25
+ ],
26
+ "props": {
27
+ "style": {
28
+ "color": "#1f1f1f",
29
+ "fontSize": 16,
30
+ "fontWeight": "600",
31
+ },
32
+ },
33
+ "type": "Text",
34
+ },
35
+ ],
36
+ "props": {
37
+ "style": {
38
+ "alignItems": "center",
39
+ "flexDirection": "row",
40
+ },
41
+ "testID": undefined,
42
+ },
43
+ "type": "View",
44
+ },
45
+ ],
46
+ "props": {
47
+ "accessibilityHint": "Sign in with Google",
48
+ "aria-label": "Continue with Google",
49
+ "aria-role": "button",
50
+ "disabled": undefined,
51
+ "onPress": [Function: debounced],
52
+ "style": {
53
+ "alignItems": "center",
54
+ "alignSelf": "stretch",
55
+ "backgroundColor": "#ffffff",
56
+ "borderColor": "#dadce0",
57
+ "borderRadius": 360,
58
+ "borderWidth": 1,
59
+ "flexDirection": "row",
60
+ "justifyContent": "center",
61
+ "opacity": 1,
62
+ "paddingHorizontal": 20,
63
+ "paddingVertical": 12,
64
+ "width": "100%",
65
+ },
66
+ "testID": "social-login-google",
67
+ },
68
+ "type": "Pressable",
69
+ }
70
+ `;
71
+
72
+ exports[`SocialLoginButton renders correctly with GitHub provider 1`] = `
73
+ {
74
+ "$$typeof": Symbol(react.test.json),
75
+ "children": [
76
+ {
77
+ "$$typeof": Symbol(react.test.json),
78
+ "children": [
79
+ {
80
+ "$$typeof": Symbol(react.test.json),
81
+ "children": null,
82
+ "props": {
83
+ "style": {
84
+ "marginRight": 12,
85
+ },
86
+ "testID": undefined,
87
+ },
88
+ "type": "View",
89
+ },
90
+ {
91
+ "$$typeof": Symbol(react.test.json),
92
+ "children": [
93
+ "Continue with GitHub",
94
+ ],
95
+ "props": {
96
+ "style": {
97
+ "color": "#ffffff",
98
+ "fontSize": 16,
99
+ "fontWeight": "600",
100
+ },
101
+ },
102
+ "type": "Text",
103
+ },
104
+ ],
105
+ "props": {
106
+ "style": {
107
+ "alignItems": "center",
108
+ "flexDirection": "row",
109
+ },
110
+ "testID": undefined,
111
+ },
112
+ "type": "View",
113
+ },
114
+ ],
115
+ "props": {
116
+ "accessibilityHint": "Sign in with GitHub",
117
+ "aria-label": "Continue with GitHub",
118
+ "aria-role": "button",
119
+ "disabled": undefined,
120
+ "onPress": [Function: debounced],
121
+ "style": {
122
+ "alignItems": "center",
123
+ "alignSelf": "stretch",
124
+ "backgroundColor": "#24292e",
125
+ "borderColor": "#24292e",
126
+ "borderRadius": 360,
127
+ "borderWidth": 1,
128
+ "flexDirection": "row",
129
+ "justifyContent": "center",
130
+ "opacity": 1,
131
+ "paddingHorizontal": 20,
132
+ "paddingVertical": 12,
133
+ "width": "100%",
134
+ },
135
+ "testID": "social-login-github",
136
+ },
137
+ "type": "Pressable",
138
+ }
139
+ `;
140
+
141
+ exports[`SocialLoginButton renders correctly with Apple provider 1`] = `
142
+ {
143
+ "$$typeof": Symbol(react.test.json),
144
+ "children": [
145
+ {
146
+ "$$typeof": Symbol(react.test.json),
147
+ "children": [
148
+ {
149
+ "$$typeof": Symbol(react.test.json),
150
+ "children": null,
151
+ "props": {
152
+ "style": {
153
+ "marginRight": 12,
154
+ },
155
+ "testID": undefined,
156
+ },
157
+ "type": "View",
158
+ },
159
+ {
160
+ "$$typeof": Symbol(react.test.json),
161
+ "children": [
162
+ "Continue with Apple",
163
+ ],
164
+ "props": {
165
+ "style": {
166
+ "color": "#ffffff",
167
+ "fontSize": 16,
168
+ "fontWeight": "600",
169
+ },
170
+ },
171
+ "type": "Text",
172
+ },
173
+ ],
174
+ "props": {
175
+ "style": {
176
+ "alignItems": "center",
177
+ "flexDirection": "row",
178
+ },
179
+ "testID": undefined,
180
+ },
181
+ "type": "View",
182
+ },
183
+ ],
184
+ "props": {
185
+ "accessibilityHint": "Sign in with Apple",
186
+ "aria-label": "Continue with Apple",
187
+ "aria-role": "button",
188
+ "disabled": undefined,
189
+ "onPress": [Function: debounced],
190
+ "style": {
191
+ "alignItems": "center",
192
+ "alignSelf": "stretch",
193
+ "backgroundColor": "#000000",
194
+ "borderColor": "#000000",
195
+ "borderRadius": 360,
196
+ "borderWidth": 1,
197
+ "flexDirection": "row",
198
+ "justifyContent": "center",
199
+ "opacity": 1,
200
+ "paddingHorizontal": 20,
201
+ "paddingVertical": 12,
202
+ "width": "100%",
203
+ },
204
+ "testID": "social-login-apple",
205
+ },
206
+ "type": "Pressable",
207
+ }
208
+ `;
209
+
210
+ exports[`SocialLoginButton renders with outline variant 1`] = `
211
+ {
212
+ "$$typeof": Symbol(react.test.json),
213
+ "children": [
214
+ {
215
+ "$$typeof": Symbol(react.test.json),
216
+ "children": [
217
+ {
218
+ "$$typeof": Symbol(react.test.json),
219
+ "children": null,
220
+ "props": {
221
+ "style": {
222
+ "marginRight": 12,
223
+ },
224
+ "testID": undefined,
225
+ },
226
+ "type": "View",
227
+ },
228
+ {
229
+ "$$typeof": Symbol(react.test.json),
230
+ "children": [
231
+ "Continue with Google",
232
+ ],
233
+ "props": {
234
+ "style": {
235
+ "color": "#1C1C1C",
236
+ "fontSize": 16,
237
+ "fontWeight": "600",
238
+ },
239
+ },
240
+ "type": "Text",
241
+ },
242
+ ],
243
+ "props": {
244
+ "style": {
245
+ "alignItems": "center",
246
+ "flexDirection": "row",
247
+ },
248
+ "testID": undefined,
249
+ },
250
+ "type": "View",
251
+ },
252
+ ],
253
+ "props": {
254
+ "accessibilityHint": "Sign in with Google",
255
+ "aria-label": "Continue with Google",
256
+ "aria-role": "button",
257
+ "disabled": undefined,
258
+ "onPress": [Function: debounced],
259
+ "style": {
260
+ "alignItems": "center",
261
+ "alignSelf": "stretch",
262
+ "backgroundColor": "#FFFFFF",
263
+ "borderColor": "#dadce0",
264
+ "borderRadius": 360,
265
+ "borderWidth": 1,
266
+ "flexDirection": "row",
267
+ "justifyContent": "center",
268
+ "opacity": 1,
269
+ "paddingHorizontal": 20,
270
+ "paddingVertical": 12,
271
+ "width": "100%",
272
+ },
273
+ "testID": "social-login-google",
274
+ },
275
+ "type": "Pressable",
276
+ }
277
+ `;
package/src/bunSetup.ts CHANGED
@@ -432,6 +432,29 @@ if (typeof globalThis.expo === "undefined") {
432
432
  } as any;
433
433
  }
434
434
 
435
+ // Mock expo-router
436
+ mock.module("expo-router", () => ({
437
+ Link: ({children, ...props}: any) => React.createElement("Link", props, children),
438
+ router: {
439
+ back: mock(() => {}),
440
+ canGoBack: mock(() => true),
441
+ navigate: mock(() => {}),
442
+ push: mock(() => {}),
443
+ replace: mock(() => {}),
444
+ },
445
+ Stack: ({children, ...props}: any) => React.createElement("Stack", props, children),
446
+ Tabs: ({children, ...props}: any) => React.createElement("Tabs", props, children),
447
+ useLocalSearchParams: mock(() => ({})),
448
+ useRouter: mock(() => ({
449
+ back: mock(() => {}),
450
+ canGoBack: mock(() => true),
451
+ navigate: mock(() => {}),
452
+ push: mock(() => {}),
453
+ replace: mock(() => {}),
454
+ })),
455
+ useSegments: mock(() => []),
456
+ }));
457
+
435
458
  // Mock @react-native-async-storage/async-storage
436
459
  mock.module("@react-native-async-storage/async-storage", () => ({
437
460
  clear: mock(() => Promise.resolve()),
package/src/index.tsx CHANGED
@@ -1,3 +1,5 @@
1
+ // Re-export React Native style types for use in consumer projects
2
+ export type {StyleProp, ViewStyle} from "react-native";
1
3
  export * from "./Accordion";
2
4
  export * from "./ActionSheet";
3
5
  export * from "./AddressField";
@@ -34,6 +36,8 @@ export * from "./ImageBackground";
34
36
  export * from "./InfoModalIcon";
35
37
  export * from "./InfoTooltipButton";
36
38
  export * from "./Link";
39
+ export * from "./login";
40
+
37
41
  export * from "./MarkdownView";
38
42
  export * from "./MediaQuery";
39
43
  export * from "./MobileAddressAutoComplete";
@@ -58,8 +62,10 @@ export * from "./SideDrawer";
58
62
  export * from "./Signature";
59
63
  export * from "./SignatureField";
60
64
  export * from "./Slider";
65
+ export * from "./SocialLoginButton";
61
66
  export * from "./Spinner";
62
67
  export * from "./SplitPage";
68
+ export * from "./signUp";
63
69
  export * from "./TapToEdit";
64
70
  export * from "./TerrenoProvider";
65
71
  export * from "./Text";
@@ -0,0 +1,148 @@
1
+ import {describe, expect, it, mock} from "bun:test";
2
+ import {fireEvent} from "@testing-library/react-native";
3
+ import {renderWithTheme} from "../test-utils";
4
+ import {LoginScreen} from "./LoginScreen";
5
+
6
+ const defaultFields = [
7
+ {label: "Email", name: "email", required: true, type: "email" as const},
8
+ {label: "Password", name: "password", required: true, type: "password" as const},
9
+ ];
10
+
11
+ describe("LoginScreen", () => {
12
+ it("renders with default props", () => {
13
+ const {getByTestId} = renderWithTheme(
14
+ <LoginScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
15
+ );
16
+ expect(getByTestId("login-screen")).toBeTruthy();
17
+ });
18
+
19
+ it("renders title", () => {
20
+ const {getByTestId} = renderWithTheme(
21
+ <LoginScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
22
+ );
23
+ expect(getByTestId("login-screen-title")).toBeTruthy();
24
+ });
25
+
26
+ it("renders custom title", () => {
27
+ const {getByText} = renderWithTheme(
28
+ <LoginScreen
29
+ fields={defaultFields}
30
+ onSubmit={mock(() => Promise.resolve())}
31
+ title="Sign In"
32
+ />
33
+ );
34
+ expect(getByText("Sign In")).toBeTruthy();
35
+ });
36
+
37
+ it("renders all form fields", () => {
38
+ const {getByTestId} = renderWithTheme(
39
+ <LoginScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
40
+ );
41
+ expect(getByTestId("login-screen-email-input")).toBeTruthy();
42
+ expect(getByTestId("login-screen-password-input")).toBeTruthy();
43
+ });
44
+
45
+ it("renders submit button", () => {
46
+ const {getByTestId} = renderWithTheme(
47
+ <LoginScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
48
+ );
49
+ expect(getByTestId("login-screen-submit-button")).toBeTruthy();
50
+ });
51
+
52
+ it("renders sign-up link when onSignUpPress is provided", () => {
53
+ const {getByTestId} = renderWithTheme(
54
+ <LoginScreen
55
+ fields={defaultFields}
56
+ onSignUpPress={() => {}}
57
+ onSubmit={mock(() => Promise.resolve())}
58
+ />
59
+ );
60
+ expect(getByTestId("login-screen-signup-link")).toBeTruthy();
61
+ });
62
+
63
+ it("renders forgot password button when onForgotPasswordPress is provided", () => {
64
+ const {getByTestId} = renderWithTheme(
65
+ <LoginScreen
66
+ fields={defaultFields}
67
+ onForgotPasswordPress={() => {}}
68
+ onSubmit={mock(() => Promise.resolve())}
69
+ />
70
+ );
71
+ expect(getByTestId("login-screen-forgot-password")).toBeTruthy();
72
+ });
73
+
74
+ it("renders error message", () => {
75
+ const {getByTestId} = renderWithTheme(
76
+ <LoginScreen
77
+ error="Invalid credentials"
78
+ fields={defaultFields}
79
+ onSubmit={mock(() => Promise.resolve())}
80
+ />
81
+ );
82
+ expect(getByTestId("login-screen-error")).toBeTruthy();
83
+ });
84
+
85
+ it("renders OAuth buttons when providers are given", () => {
86
+ const providers = [
87
+ {onPress: mock(() => Promise.resolve()), provider: "google" as const},
88
+ {onPress: mock(() => Promise.resolve()), provider: "github" as const},
89
+ ];
90
+ const {getByTestId} = renderWithTheme(
91
+ <LoginScreen
92
+ fields={defaultFields}
93
+ oauthProviders={providers}
94
+ onSubmit={mock(() => Promise.resolve())}
95
+ />
96
+ );
97
+ expect(getByTestId("login-screen-oauth")).toBeTruthy();
98
+ });
99
+
100
+ it("updates form field values on change", () => {
101
+ const {getByTestId} = renderWithTheme(
102
+ <LoginScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
103
+ );
104
+ const emailInput = getByTestId("login-screen-email-input");
105
+ fireEvent.changeText(emailInput, "test@example.com");
106
+ expect(emailInput.props.value).toBe("test@example.com");
107
+ });
108
+
109
+ it("renders with custom testID", () => {
110
+ const {getByTestId} = renderWithTheme(
111
+ <LoginScreen
112
+ fields={defaultFields}
113
+ onSubmit={mock(() => Promise.resolve())}
114
+ testID="custom-login"
115
+ />
116
+ );
117
+ expect(getByTestId("custom-login")).toBeTruthy();
118
+ });
119
+
120
+ it("does not render forgot password when handler not provided", () => {
121
+ const {queryByTestId} = renderWithTheme(
122
+ <LoginScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
123
+ );
124
+ expect(queryByTestId("login-screen-forgot-password")).toBeNull();
125
+ });
126
+
127
+ it("does not render sign-up link when handler not provided", () => {
128
+ const {queryByTestId} = renderWithTheme(
129
+ <LoginScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
130
+ );
131
+ expect(queryByTestId("login-screen-signup-link")).toBeNull();
132
+ });
133
+
134
+ it("renders correctly with all props", () => {
135
+ const {toJSON} = renderWithTheme(
136
+ <LoginScreen
137
+ error="Error!"
138
+ fields={defaultFields}
139
+ loading
140
+ onForgotPasswordPress={() => {}}
141
+ onSignUpPress={() => {}}
142
+ onSubmit={mock(() => Promise.resolve())}
143
+ title="Log In"
144
+ />
145
+ );
146
+ expect(toJSON()).toMatchSnapshot();
147
+ });
148
+ });
@@ -0,0 +1,159 @@
1
+ import type {FC} from "react";
2
+ import {useCallback, useState} from "react";
3
+
4
+ import {Box} from "../Box";
5
+ import {Button} from "../Button";
6
+ import {Heading} from "../Heading";
7
+ import {Page} from "../Page";
8
+ import {OAuthButtons} from "../signUp/OAuthButtons";
9
+ import {Text} from "../Text";
10
+ import {TextField} from "../TextField";
11
+ import type {LoginScreenProps} from "./loginTypes";
12
+
13
+ /**
14
+ * A configurable login screen component with support for custom fields,
15
+ * OAuth providers, sign-up link, and forgot password link.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <LoginScreen
20
+ * fields={[
21
+ * {name: "email", label: "Email", type: "email", required: true},
22
+ * {name: "password", label: "Password", type: "password", required: true},
23
+ * ]}
24
+ * onSubmit={async (values) => {
25
+ * await signIn(values.email, values.password);
26
+ * }}
27
+ * oauthProviders={[
28
+ * {provider: "google", onPress: () => signInWithSocial("google")},
29
+ * ]}
30
+ * onSignUpPress={() => router.push("/signup")}
31
+ * onForgotPasswordPress={() => router.push("/forgot-password")}
32
+ * />
33
+ * ```
34
+ */
35
+ export const LoginScreen: FC<LoginScreenProps> = ({
36
+ fields,
37
+ onSubmit,
38
+ oauthProviders,
39
+ logo,
40
+ title = "Welcome Back",
41
+ loading = false,
42
+ error,
43
+ signUpLinkText = "Need an account? Sign Up",
44
+ onSignUpPress,
45
+ forgotPasswordText = "Forgot password?",
46
+ onForgotPasswordPress,
47
+ testID = "login-screen",
48
+ }) => {
49
+ const [formValues, setFormValues] = useState<Record<string, string>>(() => {
50
+ const initial: Record<string, string> = {};
51
+ for (const field of fields) {
52
+ initial[field.name] = "";
53
+ }
54
+ return initial;
55
+ });
56
+
57
+ const handleFieldChange = useCallback((fieldName: string, value: string) => {
58
+ setFormValues((prev) => ({...prev, [fieldName]: value}));
59
+ }, []);
60
+
61
+ const handleSubmit = useCallback(async () => {
62
+ await onSubmit(formValues);
63
+ }, [formValues, onSubmit]);
64
+
65
+ const requiredFieldsFilled = fields
66
+ .filter((f) => f.required)
67
+ .every((f) => (formValues[f.name] ?? "").trim().length > 0);
68
+
69
+ const isSubmitDisabled = loading || !requiredFieldsFilled;
70
+
71
+ return (
72
+ <Page navigation={undefined}>
73
+ <Box
74
+ alignItems="center"
75
+ alignSelf="center"
76
+ flex="grow"
77
+ justifyContent="center"
78
+ maxWidth={400}
79
+ padding={4}
80
+ testID={testID}
81
+ width="100%"
82
+ >
83
+ {Boolean(logo) && <Box marginBottom={6}>{logo}</Box>}
84
+
85
+ <Box marginBottom={8}>
86
+ <Heading testID={`${testID}-title`}>{title}</Heading>
87
+ </Box>
88
+
89
+ <Box gap={4} width="100%">
90
+ {fields.map((field) => (
91
+ <TextField
92
+ autoComplete={field.autoComplete}
93
+ disabled={loading}
94
+ key={field.name}
95
+ onChange={(value: string) => handleFieldChange(field.name, value)}
96
+ placeholder={field.placeholder ?? field.label}
97
+ testID={`${testID}-${field.name}-input`}
98
+ title={field.label}
99
+ type={
100
+ field.type === "email" ? "email" : field.type === "password" ? "password" : "text"
101
+ }
102
+ value={formValues[field.name]}
103
+ />
104
+ ))}
105
+
106
+ {Boolean(error) && (
107
+ <Text color="error" testID={`${testID}-error`}>
108
+ {error}
109
+ </Text>
110
+ )}
111
+
112
+ <Box marginTop={4}>
113
+ <Button
114
+ disabled={isSubmitDisabled}
115
+ fullWidth
116
+ loading={loading}
117
+ onClick={handleSubmit}
118
+ testID={`${testID}-submit-button`}
119
+ text="Log In"
120
+ />
121
+ </Box>
122
+
123
+ {Boolean(onForgotPasswordPress) && (
124
+ <Box alignItems="center" marginTop={2}>
125
+ <Button
126
+ disabled={loading}
127
+ onClick={onForgotPasswordPress!}
128
+ testID={`${testID}-forgot-password`}
129
+ text={forgotPasswordText!}
130
+ variant="muted"
131
+ />
132
+ </Box>
133
+ )}
134
+
135
+ {Boolean(onSignUpPress) && (
136
+ <Box marginTop={2}>
137
+ <Button
138
+ disabled={loading}
139
+ fullWidth
140
+ onClick={onSignUpPress!}
141
+ testID={`${testID}-signup-link`}
142
+ text={signUpLinkText!}
143
+ variant="outline"
144
+ />
145
+ </Box>
146
+ )}
147
+
148
+ {Boolean(oauthProviders && oauthProviders.length > 0) && (
149
+ <OAuthButtons
150
+ disabled={loading}
151
+ providers={oauthProviders!}
152
+ testID={`${testID}-oauth`}
153
+ />
154
+ )}
155
+ </Box>
156
+ </Box>
157
+ </Page>
158
+ );
159
+ };