@terreno/ui 0.1.0 → 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.
- package/dist/Common.d.ts +44 -0
- package/dist/SocialLoginButton.d.ts +19 -0
- package/dist/SocialLoginButton.js +119 -0
- package/dist/SocialLoginButton.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/login/LoginScreen.d.ts +25 -0
- package/dist/login/LoginScreen.js +55 -0
- package/dist/login/LoginScreen.js.map +1 -0
- package/dist/login/index.d.ts +2 -0
- package/dist/login/index.js +2 -0
- package/dist/login/index.js.map +1 -0
- package/dist/login/loginTypes.d.ts +48 -0
- package/dist/login/loginTypes.js +2 -0
- package/dist/login/loginTypes.js.map +1 -0
- package/dist/signUp/OAuthButtons.d.ts +18 -0
- package/dist/signUp/OAuthButtons.js +15 -0
- package/dist/signUp/OAuthButtons.js.map +1 -0
- package/dist/signUp/PasswordRequirements.d.ts +15 -0
- package/dist/signUp/PasswordRequirements.js +14 -0
- package/dist/signUp/PasswordRequirements.js.map +1 -0
- package/dist/signUp/SignUpScreen.d.ts +26 -0
- package/dist/signUp/SignUpScreen.js +64 -0
- package/dist/signUp/SignUpScreen.js.map +1 -0
- package/dist/signUp/Swiper.d.ts +13 -0
- package/dist/signUp/Swiper.js +16 -0
- package/dist/signUp/Swiper.js.map +1 -0
- package/dist/signUp/index.d.ts +6 -0
- package/dist/signUp/index.js +6 -0
- package/dist/signUp/index.js.map +1 -0
- package/dist/signUp/passwordPresets.d.ts +9 -0
- package/dist/signUp/passwordPresets.js +41 -0
- package/dist/signUp/passwordPresets.js.map +1 -0
- package/dist/signUp/signUpTypes.d.ts +90 -0
- package/dist/signUp/signUpTypes.js +2 -0
- package/dist/signUp/signUpTypes.js.map +1 -0
- package/package.json +3 -2
- package/src/Common.ts +52 -0
- package/src/SocialLoginButton.test.tsx +158 -0
- package/src/SocialLoginButton.tsx +182 -0
- package/src/__snapshots__/SocialLoginButton.test.tsx.snap +277 -0
- package/src/index.tsx +4 -0
- package/src/login/LoginScreen.test.tsx +148 -0
- package/src/login/LoginScreen.tsx +159 -0
- package/src/login/__snapshots__/LoginScreen.test.tsx.snap +630 -0
- package/src/login/index.ts +2 -0
- package/src/login/loginTypes.ts +51 -0
- package/src/signUp/OAuthButtons.test.tsx +45 -0
- package/src/signUp/OAuthButtons.tsx +52 -0
- package/src/signUp/PasswordRequirements.test.tsx +41 -0
- package/src/signUp/PasswordRequirements.tsx +49 -0
- package/src/signUp/SignUpScreen.test.tsx +134 -0
- package/src/signUp/SignUpScreen.tsx +172 -0
- package/src/signUp/Swiper.test.tsx +46 -0
- package/src/signUp/Swiper.tsx +59 -0
- package/src/signUp/__snapshots__/OAuthButtons.test.tsx.snap +272 -0
- package/src/signUp/__snapshots__/PasswordRequirements.test.tsx.snap +427 -0
- package/src/signUp/__snapshots__/SignUpScreen.test.tsx.snap +851 -0
- package/src/signUp/__snapshots__/Swiper.test.tsx.snap +249 -0
- package/src/signUp/index.ts +13 -0
- package/src/signUp/passwordPresets.test.ts +57 -0
- package/src/signUp/passwordPresets.ts +43 -0
- package/src/signUp/signUpTypes.ts +94 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type {ReactNode} from "react";
|
|
2
|
+
|
|
3
|
+
import type {OAuthProviderConfig} from "../signUp/signUpTypes";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configuration for a login form field.
|
|
7
|
+
*/
|
|
8
|
+
export interface LoginFieldConfig {
|
|
9
|
+
/** Unique field name used as the key in form state. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** Display label for the field. */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Placeholder text shown when the field is empty. */
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
/** Input type for the field. */
|
|
16
|
+
type?: "text" | "email" | "password";
|
|
17
|
+
/** Whether the field is required. */
|
|
18
|
+
required?: boolean;
|
|
19
|
+
/** Auto-complete hint for the field. */
|
|
20
|
+
autoComplete?: "current-password" | "on" | "off" | "username";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Props for the LoginScreen component.
|
|
25
|
+
*/
|
|
26
|
+
export interface LoginScreenProps {
|
|
27
|
+
/** Form field configurations. */
|
|
28
|
+
fields: LoginFieldConfig[];
|
|
29
|
+
/** Callback triggered on form submission. Receives field values as a record. */
|
|
30
|
+
onSubmit: (values: Record<string, string>) => Promise<void>;
|
|
31
|
+
/** Optional OAuth provider configurations for social login buttons. */
|
|
32
|
+
oauthProviders?: OAuthProviderConfig[];
|
|
33
|
+
/** Custom logo or banner to display above the form. */
|
|
34
|
+
logo?: ReactNode;
|
|
35
|
+
/** Title text for the login form. */
|
|
36
|
+
title?: string;
|
|
37
|
+
/** Whether the form is in a loading state. */
|
|
38
|
+
loading?: boolean;
|
|
39
|
+
/** Error message to display. */
|
|
40
|
+
error?: string;
|
|
41
|
+
/** Text for the link to navigate to sign up. */
|
|
42
|
+
signUpLinkText?: string;
|
|
43
|
+
/** Callback triggered when the sign-up link is pressed. */
|
|
44
|
+
onSignUpPress?: () => void;
|
|
45
|
+
/** Text for the forgot password link. */
|
|
46
|
+
forgotPasswordText?: string;
|
|
47
|
+
/** Callback triggered when the forgot password link is pressed. */
|
|
48
|
+
onForgotPasswordPress?: () => void;
|
|
49
|
+
/** Test ID for the root element. */
|
|
50
|
+
testID?: string;
|
|
51
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {describe, expect, it, mock} from "bun:test";
|
|
2
|
+
import {renderWithTheme} from "../test-utils";
|
|
3
|
+
import {OAuthButtons} from "./OAuthButtons";
|
|
4
|
+
|
|
5
|
+
describe("OAuthButtons", () => {
|
|
6
|
+
const mockProviders = [
|
|
7
|
+
{onPress: mock(() => Promise.resolve()), provider: "google" as const},
|
|
8
|
+
{onPress: mock(() => Promise.resolve()), provider: "github" as const},
|
|
9
|
+
{onPress: mock(() => Promise.resolve()), provider: "apple" as const},
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
it("renders all provider buttons", () => {
|
|
13
|
+
const {getByTestId} = renderWithTheme(
|
|
14
|
+
<OAuthButtons providers={mockProviders} testID="oauth" />
|
|
15
|
+
);
|
|
16
|
+
expect(getByTestId("oauth")).toBeTruthy();
|
|
17
|
+
expect(getByTestId("oauth-google")).toBeTruthy();
|
|
18
|
+
expect(getByTestId("oauth-github")).toBeTruthy();
|
|
19
|
+
expect(getByTestId("oauth-apple")).toBeTruthy();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("renders nothing when providers is empty", () => {
|
|
23
|
+
const {queryByTestId} = renderWithTheme(<OAuthButtons providers={[]} testID="oauth" />);
|
|
24
|
+
expect(queryByTestId("oauth")).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders with custom divider text", () => {
|
|
28
|
+
const {getByText} = renderWithTheme(
|
|
29
|
+
<OAuthButtons dividerText="Sign in with" providers={mockProviders} />
|
|
30
|
+
);
|
|
31
|
+
expect(getByText("Sign in with")).toBeTruthy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders default divider text", () => {
|
|
35
|
+
const {getByText} = renderWithTheme(<OAuthButtons providers={mockProviders} />);
|
|
36
|
+
expect(getByText("Or continue with")).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders correctly with all props", () => {
|
|
40
|
+
const {toJSON} = renderWithTheme(
|
|
41
|
+
<OAuthButtons disabled providers={mockProviders} testID="oauth" />
|
|
42
|
+
);
|
|
43
|
+
expect(toJSON()).toMatchSnapshot();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {FC} from "react";
|
|
2
|
+
|
|
3
|
+
import {Box} from "../Box";
|
|
4
|
+
import {SocialLoginButton} from "../SocialLoginButton";
|
|
5
|
+
import {Text} from "../Text";
|
|
6
|
+
import type {OAuthProviderConfig} from "./signUpTypes";
|
|
7
|
+
|
|
8
|
+
interface OAuthButtonsProps {
|
|
9
|
+
/** OAuth provider configurations. */
|
|
10
|
+
providers: OAuthProviderConfig[];
|
|
11
|
+
/** Whether all buttons should be disabled. */
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** Divider text displayed above the OAuth buttons. */
|
|
14
|
+
dividerText?: string;
|
|
15
|
+
/** Test ID prefix for the component. */
|
|
16
|
+
testID?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Renders OAuth provider buttons with an optional divider text.
|
|
21
|
+
* Uses SocialLoginButton for branded provider buttons.
|
|
22
|
+
*/
|
|
23
|
+
export const OAuthButtons: FC<OAuthButtonsProps> = ({
|
|
24
|
+
providers,
|
|
25
|
+
disabled = false,
|
|
26
|
+
dividerText = "Or continue with",
|
|
27
|
+
testID = "oauth-buttons",
|
|
28
|
+
}) => {
|
|
29
|
+
if (providers.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Box testID={testID} width="100%">
|
|
35
|
+
<Box alignItems="center" marginTop={6}>
|
|
36
|
+
<Text color="secondaryLight">{dividerText}</Text>
|
|
37
|
+
</Box>
|
|
38
|
+
<Box gap={3} marginTop={4}>
|
|
39
|
+
{providers.map((config) => (
|
|
40
|
+
<SocialLoginButton
|
|
41
|
+
disabled={disabled || config.disabled}
|
|
42
|
+
key={config.provider}
|
|
43
|
+
loading={config.loading}
|
|
44
|
+
onPress={config.onPress}
|
|
45
|
+
provider={config.provider}
|
|
46
|
+
testID={`${testID}-${config.provider}`}
|
|
47
|
+
/>
|
|
48
|
+
))}
|
|
49
|
+
</Box>
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
import {renderWithTheme} from "../test-utils";
|
|
3
|
+
import {PasswordRequirements} from "./PasswordRequirements";
|
|
4
|
+
import {defaultPasswordRequirements} from "./passwordPresets";
|
|
5
|
+
|
|
6
|
+
describe("PasswordRequirements", () => {
|
|
7
|
+
it("renders all requirements", () => {
|
|
8
|
+
const {getByTestId} = renderWithTheme(
|
|
9
|
+
<PasswordRequirements password="" requirements={defaultPasswordRequirements} />
|
|
10
|
+
);
|
|
11
|
+
expect(getByTestId("password-requirements")).toBeTruthy();
|
|
12
|
+
for (const req of defaultPasswordRequirements) {
|
|
13
|
+
expect(getByTestId(`password-requirements-${req.key}`)).toBeTruthy();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("renders with custom testID", () => {
|
|
18
|
+
const {getByTestId} = renderWithTheme(
|
|
19
|
+
<PasswordRequirements
|
|
20
|
+
password=""
|
|
21
|
+
requirements={defaultPasswordRequirements}
|
|
22
|
+
testID="custom-reqs"
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
expect(getByTestId("custom-reqs")).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders correctly with empty password", () => {
|
|
29
|
+
const {toJSON} = renderWithTheme(
|
|
30
|
+
<PasswordRequirements password="" requirements={defaultPasswordRequirements} />
|
|
31
|
+
);
|
|
32
|
+
expect(toJSON()).toMatchSnapshot();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders correctly with a strong password", () => {
|
|
36
|
+
const {toJSON} = renderWithTheme(
|
|
37
|
+
<PasswordRequirements password="MyP@ssw0rd!" requirements={defaultPasswordRequirements} />
|
|
38
|
+
);
|
|
39
|
+
expect(toJSON()).toMatchSnapshot();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {FC} from "react";
|
|
2
|
+
import {View} from "react-native";
|
|
3
|
+
|
|
4
|
+
import {Icon} from "../Icon";
|
|
5
|
+
import {Text} from "../Text";
|
|
6
|
+
import type {PasswordRequirement} from "./signUpTypes";
|
|
7
|
+
|
|
8
|
+
interface PasswordRequirementsProps {
|
|
9
|
+
/** The current password value to validate against. */
|
|
10
|
+
password: string;
|
|
11
|
+
/** List of password requirements to display. */
|
|
12
|
+
requirements: PasswordRequirement[];
|
|
13
|
+
/** Test ID prefix for the component. */
|
|
14
|
+
testID?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Displays a list of password requirements with check/cross indicators.
|
|
19
|
+
*/
|
|
20
|
+
export const PasswordRequirements: FC<PasswordRequirementsProps> = ({
|
|
21
|
+
password,
|
|
22
|
+
requirements,
|
|
23
|
+
testID = "password-requirements",
|
|
24
|
+
}) => {
|
|
25
|
+
return (
|
|
26
|
+
<View testID={testID}>
|
|
27
|
+
{requirements.map((req) => {
|
|
28
|
+
const isMet = password.length > 0 && req.validate(password);
|
|
29
|
+
return (
|
|
30
|
+
<View
|
|
31
|
+
key={req.key}
|
|
32
|
+
style={{alignItems: "center", flexDirection: "row", gap: 8, marginBottom: 4}}
|
|
33
|
+
testID={`${testID}-${req.key}`}
|
|
34
|
+
>
|
|
35
|
+
<Icon
|
|
36
|
+
color={isMet ? "success" : "secondaryLight"}
|
|
37
|
+
iconName={isMet ? "circle-check" : "circle"}
|
|
38
|
+
size="sm"
|
|
39
|
+
testID={`${testID}-${req.key}-icon`}
|
|
40
|
+
/>
|
|
41
|
+
<Text color={isMet ? "success" : "secondaryLight"} size="sm">
|
|
42
|
+
{req.label}
|
|
43
|
+
</Text>
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
47
|
+
</View>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
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 {defaultPasswordRequirements} from "./passwordPresets";
|
|
5
|
+
import {SignUpScreen} from "./SignUpScreen";
|
|
6
|
+
|
|
7
|
+
const defaultFields = [
|
|
8
|
+
{label: "Name", name: "name", required: true, type: "text" as const},
|
|
9
|
+
{label: "Email", name: "email", required: true, type: "email" as const},
|
|
10
|
+
{label: "Password", name: "password", required: true, type: "password" as const},
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
describe("SignUpScreen", () => {
|
|
14
|
+
it("renders with default props", () => {
|
|
15
|
+
const {getByTestId} = renderWithTheme(
|
|
16
|
+
<SignUpScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
|
|
17
|
+
);
|
|
18
|
+
expect(getByTestId("signup-screen")).toBeTruthy();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders title", () => {
|
|
22
|
+
const {getByTestId} = renderWithTheme(
|
|
23
|
+
<SignUpScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
|
|
24
|
+
);
|
|
25
|
+
expect(getByTestId("signup-screen-title")).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders custom title", () => {
|
|
29
|
+
const {getByText} = renderWithTheme(
|
|
30
|
+
<SignUpScreen
|
|
31
|
+
fields={defaultFields}
|
|
32
|
+
onSubmit={mock(() => Promise.resolve())}
|
|
33
|
+
title="Join Us"
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
expect(getByText("Join Us")).toBeTruthy();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("renders all form fields", () => {
|
|
40
|
+
const {getByTestId} = renderWithTheme(
|
|
41
|
+
<SignUpScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
|
|
42
|
+
);
|
|
43
|
+
expect(getByTestId("signup-screen-name-input")).toBeTruthy();
|
|
44
|
+
expect(getByTestId("signup-screen-email-input")).toBeTruthy();
|
|
45
|
+
expect(getByTestId("signup-screen-password-input")).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders submit button", () => {
|
|
49
|
+
const {getByTestId} = renderWithTheme(
|
|
50
|
+
<SignUpScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
|
|
51
|
+
);
|
|
52
|
+
expect(getByTestId("signup-screen-submit-button")).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("renders login link when onLoginPress is provided", () => {
|
|
56
|
+
const {getByTestId} = renderWithTheme(
|
|
57
|
+
<SignUpScreen
|
|
58
|
+
fields={defaultFields}
|
|
59
|
+
onLoginPress={() => {}}
|
|
60
|
+
onSubmit={mock(() => Promise.resolve())}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
expect(getByTestId("signup-screen-login-link")).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("renders error message", () => {
|
|
67
|
+
const {getByTestId} = renderWithTheme(
|
|
68
|
+
<SignUpScreen
|
|
69
|
+
error="Something went wrong"
|
|
70
|
+
fields={defaultFields}
|
|
71
|
+
onSubmit={mock(() => Promise.resolve())}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
expect(getByTestId("signup-screen-error")).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("renders password requirements when provided", () => {
|
|
78
|
+
const {getByTestId} = renderWithTheme(
|
|
79
|
+
<SignUpScreen
|
|
80
|
+
fields={defaultFields}
|
|
81
|
+
onSubmit={mock(() => Promise.resolve())}
|
|
82
|
+
passwordRequirements={defaultPasswordRequirements}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
expect(getByTestId("signup-screen-password-requirements")).toBeTruthy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders OAuth buttons when providers are given", () => {
|
|
89
|
+
const providers = [{onPress: mock(() => Promise.resolve()), provider: "google" as const}];
|
|
90
|
+
const {getByTestId} = renderWithTheme(
|
|
91
|
+
<SignUpScreen
|
|
92
|
+
fields={defaultFields}
|
|
93
|
+
oauthProviders={providers}
|
|
94
|
+
onSubmit={mock(() => Promise.resolve())}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
expect(getByTestId("signup-screen-oauth")).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("updates form field values on change", () => {
|
|
101
|
+
const {getByTestId} = renderWithTheme(
|
|
102
|
+
<SignUpScreen fields={defaultFields} onSubmit={mock(() => Promise.resolve())} />
|
|
103
|
+
);
|
|
104
|
+
const nameInput = getByTestId("signup-screen-name-input");
|
|
105
|
+
fireEvent.changeText(nameInput, "John Doe");
|
|
106
|
+
expect(nameInput.props.value).toBe("John Doe");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders with custom testID", () => {
|
|
110
|
+
const {getByTestId} = renderWithTheme(
|
|
111
|
+
<SignUpScreen
|
|
112
|
+
fields={defaultFields}
|
|
113
|
+
onSubmit={mock(() => Promise.resolve())}
|
|
114
|
+
testID="custom-signup"
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
expect(getByTestId("custom-signup")).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("renders correctly with all props", () => {
|
|
121
|
+
const {toJSON} = renderWithTheme(
|
|
122
|
+
<SignUpScreen
|
|
123
|
+
error="Error!"
|
|
124
|
+
fields={defaultFields}
|
|
125
|
+
loading
|
|
126
|
+
onLoginPress={() => {}}
|
|
127
|
+
onSubmit={mock(() => Promise.resolve())}
|
|
128
|
+
passwordRequirements={defaultPasswordRequirements}
|
|
129
|
+
title="Sign Up"
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
expect(toJSON()).toMatchSnapshot();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
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 {Text} from "../Text";
|
|
9
|
+
import {TextField} from "../TextField";
|
|
10
|
+
import {OAuthButtons} from "./OAuthButtons";
|
|
11
|
+
import {PasswordRequirements} from "./PasswordRequirements";
|
|
12
|
+
import {Swiper} from "./Swiper";
|
|
13
|
+
import type {SignUpScreenProps} from "./signUpTypes";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A configurable sign-up screen component with support for custom fields,
|
|
17
|
+
* password requirements, OAuth providers, and onboarding pages.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```tsx
|
|
21
|
+
* <SignUpScreen
|
|
22
|
+
* fields={[
|
|
23
|
+
* {name: "name", label: "Name", type: "text", required: true},
|
|
24
|
+
* {name: "email", label: "Email", type: "email", required: true},
|
|
25
|
+
* {name: "password", label: "Password", type: "password", required: true},
|
|
26
|
+
* ]}
|
|
27
|
+
* onSubmit={async (values) => {
|
|
28
|
+
* await signUp(values.email, values.password, values.name);
|
|
29
|
+
* }}
|
|
30
|
+
* passwordRequirements={defaultPasswordRequirements}
|
|
31
|
+
* oauthProviders={[
|
|
32
|
+
* {provider: "google", onPress: () => signInWithSocial("google")},
|
|
33
|
+
* ]}
|
|
34
|
+
* onLoginPress={() => router.push("/login")}
|
|
35
|
+
* />
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export const SignUpScreen: FC<SignUpScreenProps> = ({
|
|
39
|
+
fields,
|
|
40
|
+
onSubmit,
|
|
41
|
+
oauthProviders,
|
|
42
|
+
passwordRequirements,
|
|
43
|
+
onboardingPages,
|
|
44
|
+
logo,
|
|
45
|
+
title = "Create Account",
|
|
46
|
+
loading = false,
|
|
47
|
+
error,
|
|
48
|
+
loginLinkText = "Already have an account? Log in",
|
|
49
|
+
onLoginPress,
|
|
50
|
+
testID = "signup-screen",
|
|
51
|
+
}) => {
|
|
52
|
+
const [formValues, setFormValues] = useState<Record<string, string>>(() => {
|
|
53
|
+
const initial: Record<string, string> = {};
|
|
54
|
+
for (const field of fields) {
|
|
55
|
+
initial[field.name] = "";
|
|
56
|
+
}
|
|
57
|
+
return initial;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const handleFieldChange = useCallback((fieldName: string, value: string) => {
|
|
61
|
+
setFormValues((prev) => ({...prev, [fieldName]: value}));
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const handleSubmit = useCallback(async () => {
|
|
65
|
+
await onSubmit(formValues);
|
|
66
|
+
}, [formValues, onSubmit]);
|
|
67
|
+
|
|
68
|
+
const passwordField = fields.find((f) => f.type === "password");
|
|
69
|
+
const passwordValue = passwordField ? (formValues[passwordField.name] ?? "") : "";
|
|
70
|
+
|
|
71
|
+
const allRequirementsMet =
|
|
72
|
+
!passwordRequirements ||
|
|
73
|
+
passwordRequirements.length === 0 ||
|
|
74
|
+
passwordRequirements.every((req) => req.validate(passwordValue));
|
|
75
|
+
|
|
76
|
+
const requiredFieldsFilled = fields
|
|
77
|
+
.filter((f) => f.required)
|
|
78
|
+
.every((f) => (formValues[f.name] ?? "").trim().length > 0);
|
|
79
|
+
|
|
80
|
+
const isSubmitDisabled = loading || !requiredFieldsFilled || !allRequirementsMet;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Page navigation={undefined}>
|
|
84
|
+
<Box
|
|
85
|
+
alignItems="center"
|
|
86
|
+
alignSelf="center"
|
|
87
|
+
flex="grow"
|
|
88
|
+
justifyContent="center"
|
|
89
|
+
maxWidth={400}
|
|
90
|
+
padding={4}
|
|
91
|
+
testID={testID}
|
|
92
|
+
width="100%"
|
|
93
|
+
>
|
|
94
|
+
{Boolean(onboardingPages && onboardingPages.length > 0) && (
|
|
95
|
+
<Box marginBottom={6}>
|
|
96
|
+
<Swiper pages={onboardingPages!} testID={`${testID}-swiper`} />
|
|
97
|
+
</Box>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{Boolean(logo) && <Box marginBottom={6}>{logo}</Box>}
|
|
101
|
+
|
|
102
|
+
<Box marginBottom={8}>
|
|
103
|
+
<Heading testID={`${testID}-title`}>{title}</Heading>
|
|
104
|
+
</Box>
|
|
105
|
+
|
|
106
|
+
<Box gap={4} width="100%">
|
|
107
|
+
{fields.map((field) => (
|
|
108
|
+
<TextField
|
|
109
|
+
autoComplete={field.autoComplete}
|
|
110
|
+
disabled={loading}
|
|
111
|
+
key={field.name}
|
|
112
|
+
onChange={(value: string) => handleFieldChange(field.name, value)}
|
|
113
|
+
placeholder={field.placeholder ?? field.label}
|
|
114
|
+
testID={`${testID}-${field.name}-input`}
|
|
115
|
+
title={field.label}
|
|
116
|
+
type={
|
|
117
|
+
field.type === "email" ? "email" : field.type === "password" ? "password" : "text"
|
|
118
|
+
}
|
|
119
|
+
value={formValues[field.name]}
|
|
120
|
+
/>
|
|
121
|
+
))}
|
|
122
|
+
|
|
123
|
+
{Boolean(passwordRequirements && passwordRequirements.length > 0 && passwordField) && (
|
|
124
|
+
<PasswordRequirements
|
|
125
|
+
password={passwordValue}
|
|
126
|
+
requirements={passwordRequirements!}
|
|
127
|
+
testID={`${testID}-password-requirements`}
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
|
|
131
|
+
{Boolean(error) && (
|
|
132
|
+
<Text color="error" testID={`${testID}-error`}>
|
|
133
|
+
{error}
|
|
134
|
+
</Text>
|
|
135
|
+
)}
|
|
136
|
+
|
|
137
|
+
<Box marginTop={4}>
|
|
138
|
+
<Button
|
|
139
|
+
disabled={isSubmitDisabled}
|
|
140
|
+
fullWidth
|
|
141
|
+
loading={loading}
|
|
142
|
+
onClick={handleSubmit}
|
|
143
|
+
testID={`${testID}-submit-button`}
|
|
144
|
+
text="Sign Up"
|
|
145
|
+
/>
|
|
146
|
+
</Box>
|
|
147
|
+
|
|
148
|
+
{Boolean(onLoginPress) && (
|
|
149
|
+
<Box marginTop={2}>
|
|
150
|
+
<Button
|
|
151
|
+
disabled={loading}
|
|
152
|
+
fullWidth
|
|
153
|
+
onClick={onLoginPress!}
|
|
154
|
+
testID={`${testID}-login-link`}
|
|
155
|
+
text={loginLinkText!}
|
|
156
|
+
variant="outline"
|
|
157
|
+
/>
|
|
158
|
+
</Box>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{Boolean(oauthProviders && oauthProviders.length > 0) && (
|
|
162
|
+
<OAuthButtons
|
|
163
|
+
disabled={loading}
|
|
164
|
+
providers={oauthProviders!}
|
|
165
|
+
testID={`${testID}-oauth`}
|
|
166
|
+
/>
|
|
167
|
+
)}
|
|
168
|
+
</Box>
|
|
169
|
+
</Box>
|
|
170
|
+
</Page>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {describe, expect, it} from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {Text} from "react-native";
|
|
4
|
+
import {renderWithTheme} from "../test-utils";
|
|
5
|
+
import {Swiper} from "./Swiper";
|
|
6
|
+
|
|
7
|
+
describe("Swiper", () => {
|
|
8
|
+
const mockPages = [
|
|
9
|
+
{subtitle: "Get started with our app", title: "Welcome"},
|
|
10
|
+
{subtitle: "Discover what we offer", title: "Features"},
|
|
11
|
+
{subtitle: "Create your account", title: "Ready?"},
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
it("renders with pages", () => {
|
|
15
|
+
const {getByTestId} = renderWithTheme(<Swiper pages={mockPages} testID="swiper" />);
|
|
16
|
+
expect(getByTestId("swiper")).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders nothing when pages is empty", () => {
|
|
20
|
+
const {queryByTestId} = renderWithTheme(<Swiper pages={[]} testID="swiper" />);
|
|
21
|
+
expect(queryByTestId("swiper")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders page titles", () => {
|
|
25
|
+
const {getByText} = renderWithTheme(<Swiper pages={mockPages} />);
|
|
26
|
+
expect(getByText("Welcome")).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders page subtitles", () => {
|
|
30
|
+
const {getByText} = renderWithTheme(<Swiper pages={mockPages} />);
|
|
31
|
+
expect(getByText("Get started with our app")).toBeTruthy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders custom content", () => {
|
|
35
|
+
const pagesWithContent = [
|
|
36
|
+
{content: <Text testID="custom-content">Custom Content</Text>, title: "Custom"},
|
|
37
|
+
];
|
|
38
|
+
const {getByTestId} = renderWithTheme(<Swiper pages={pagesWithContent} />);
|
|
39
|
+
expect(getByTestId("custom-content")).toBeTruthy();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders correctly", () => {
|
|
43
|
+
const {toJSON} = renderWithTheme(<Swiper pages={mockPages} testID="swiper" />);
|
|
44
|
+
expect(toJSON()).toMatchSnapshot();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type {FC} from "react";
|
|
2
|
+
import {Image} from "react-native";
|
|
3
|
+
import {SwiperFlatList} from "react-native-swiper-flatlist";
|
|
4
|
+
|
|
5
|
+
import {Box} from "../Box";
|
|
6
|
+
import {Heading} from "../Heading";
|
|
7
|
+
import {Text} from "../Text";
|
|
8
|
+
import type {OnboardingPage} from "./signUpTypes";
|
|
9
|
+
|
|
10
|
+
interface SwiperProps {
|
|
11
|
+
/** Onboarding pages to display. */
|
|
12
|
+
pages: OnboardingPage[];
|
|
13
|
+
/** Test ID prefix for the component. */
|
|
14
|
+
testID?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* An onboarding swiper that displays pages with optional images, titles, and subtitles.
|
|
19
|
+
*/
|
|
20
|
+
export const Swiper: FC<SwiperProps> = ({pages, testID = "onboarding-swiper"}) => {
|
|
21
|
+
if (pages.length === 0) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Box height={300} testID={testID} width="100%">
|
|
27
|
+
<SwiperFlatList autoplay autoplayDelay={4} autoplayLoop showPagination>
|
|
28
|
+
{pages.map((page, index) => (
|
|
29
|
+
<Box
|
|
30
|
+
alignItems="center"
|
|
31
|
+
justifyContent="center"
|
|
32
|
+
key={`${page.title}-${index}`}
|
|
33
|
+
padding={4}
|
|
34
|
+
width="100%"
|
|
35
|
+
>
|
|
36
|
+
{Boolean(page.image) && (
|
|
37
|
+
<Image
|
|
38
|
+
resizeMode="contain"
|
|
39
|
+
source={page.image!}
|
|
40
|
+
style={{height: 120, marginBottom: 16, width: 120}}
|
|
41
|
+
/>
|
|
42
|
+
)}
|
|
43
|
+
{Boolean(page.content) && page.content}
|
|
44
|
+
<Heading align="center" size="md">
|
|
45
|
+
{page.title}
|
|
46
|
+
</Heading>
|
|
47
|
+
{Boolean(page.subtitle) && (
|
|
48
|
+
<Box marginTop={2}>
|
|
49
|
+
<Text align="center" color="secondaryLight">
|
|
50
|
+
{page.subtitle}
|
|
51
|
+
</Text>
|
|
52
|
+
</Box>
|
|
53
|
+
)}
|
|
54
|
+
</Box>
|
|
55
|
+
))}
|
|
56
|
+
</SwiperFlatList>
|
|
57
|
+
</Box>
|
|
58
|
+
);
|
|
59
|
+
};
|