@vvedantb/native-ui 0.6.0 → 0.7.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.
@@ -0,0 +1,302 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { useSignIn, useSignUp } from "@clerk/clerk-expo";
4
+ import { useRouter } from "expo-router";
5
+ import { Button } from "../Button";
6
+ import { textStyle } from "../controlStyles";
7
+ import { Input } from "../Input";
8
+ import { toast } from "../toastStore";
9
+ import { useScheme } from "../useScheme";
10
+ import { AuthScreen, FieldError, OrDivider } from "./AuthScreen";
11
+ import { authErrorMessage } from "./authErrors";
12
+ import { GoogleSignInButton, type AuthNavigationOptions } from "./GoogleSignInButton";
13
+
14
+ export type SignInMode = "sign-in" | "sign-up";
15
+
16
+ /** The messages the screen can show. Generic: none names a provider. */
17
+ export const AUTH_MESSAGES = {
18
+ missingFields: "Enter your email and password",
19
+ signInFailed: "Sign in failed",
20
+ invalidCredentials: "Invalid email or password",
21
+ signUpFailed: "Could not create account",
22
+ verifyFailed: "Invalid code",
23
+ missingCode: "Enter the code from the email",
24
+ needsAnotherStep: "This account needs another step to sign in. Finish it on the web.",
25
+ } as const;
26
+
27
+ export type SignInScreenProps = AuthNavigationOptions & {
28
+ /** Default `"sign-in"`. `"sign-up"` creates the account and checks an emailed code. */
29
+ mode?: SignInMode;
30
+ /** Default "Welcome back", or "Create account" in `sign-up`. */
31
+ title?: string;
32
+ /** Default "Sign in to continue", or "Sign up to get started" in `sign-up`. */
33
+ subtitle?: string;
34
+ /** The app URL scheme, such as `"vibot"`, for the Google redirect `scheme://sso-callback`. */
35
+ scheme: string;
36
+ /** Default `"sso-callback"`. */
37
+ redirectPath?: string;
38
+ /** Default `true`. */
39
+ showGoogle?: boolean;
40
+ /** Default "Continue with Google". */
41
+ googleLabel?: string;
42
+ /** In `sign-in`: the "Sign up" link in the footer. No link without it. */
43
+ signUpHref?: string;
44
+ /** In `sign-up`: the "Sign in" link in the footer. No link without it. */
45
+ signInHref?: string;
46
+ /** Sign-up rejects a shorter password before sending it. Default: no check. */
47
+ minPasswordLength?: number;
48
+ /** Default `"toast"`. `"inline"` shows errors under the fields. */
49
+ errorDisplay?: "toast" | "inline";
50
+ /** Prefix for every `testID`: `sign-in-email`, `sign-in-password`, `sign-in-submit` and the rest. Default the mode. */
51
+ testIDPrefix?: string;
52
+ /** Above the title. App brand chrome goes here. */
53
+ header?: ReactNode;
54
+ /** Below the footer link row, such as an app credit. */
55
+ footer?: ReactNode;
56
+ };
57
+
58
+ /**
59
+ * Email and password, and Google, sign-in or sign-up. Sign-up sends an email
60
+ * code and checks it before the session starts. On success it calls
61
+ * `onSuccess`, or `router.replace(afterAuthHref, replaceOptions)`.
62
+ *
63
+ * Forgot-password is not here: an app that needs it keeps its own screen.
64
+ */
65
+ export function SignInScreen({
66
+ mode = "sign-in",
67
+ title,
68
+ subtitle,
69
+ scheme,
70
+ redirectPath,
71
+ afterAuthHref = "/",
72
+ replaceOptions,
73
+ onSuccess,
74
+ showGoogle = true,
75
+ googleLabel,
76
+ signUpHref,
77
+ signInHref,
78
+ minPasswordLength,
79
+ errorDisplay = "toast",
80
+ testIDPrefix,
81
+ header,
82
+ footer,
83
+ }: SignInScreenProps) {
84
+ const signInState = useSignIn();
85
+ const signUpState = useSignUp();
86
+ const router = useRouter();
87
+ const colorScheme = useScheme();
88
+ const [email, setEmail] = useState("");
89
+ const [password, setPassword] = useState("");
90
+ const [code, setCode] = useState("");
91
+ const [pendingVerification, setPendingVerification] = useState(false);
92
+ const [error, setError] = useState("");
93
+ const [submitting, setSubmitting] = useState(false);
94
+ const signUp = mode === "sign-up";
95
+ const id = (name: string) => `${testIDPrefix ?? mode}-${name}`;
96
+
97
+ const fail = (heading: string, message: string) => {
98
+ if (errorDisplay === "inline") setError(message);
99
+ else toast.error(heading, message === heading ? undefined : { description: message });
100
+ };
101
+
102
+ const done = () => {
103
+ if (onSuccess) onSuccess();
104
+ else router.replace(afterAuthHref, replaceOptions);
105
+ };
106
+
107
+ const onSignIn = async () => {
108
+ const { isLoaded, signIn, setActive } = signInState;
109
+ if (!isLoaded || !signIn || !setActive || submitting) return;
110
+ if (!email || !password) return fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.missingFields);
111
+ setSubmitting(true);
112
+ setError("");
113
+ try {
114
+ const attempt = await signIn.create({ identifier: email, password });
115
+ if (attempt.status === "complete" && attempt.createdSessionId) {
116
+ await setActive({ session: attempt.createdSessionId });
117
+ done();
118
+ } else {
119
+ fail(AUTH_MESSAGES.signInFailed, AUTH_MESSAGES.needsAnotherStep);
120
+ }
121
+ } catch (caught) {
122
+ fail(AUTH_MESSAGES.signInFailed, authErrorMessage(caught, AUTH_MESSAGES.invalidCredentials));
123
+ } finally {
124
+ setSubmitting(false);
125
+ }
126
+ };
127
+
128
+ const onSignUp = async () => {
129
+ const { isLoaded, signUp: attempt } = signUpState;
130
+ if (!isLoaded || !attempt || submitting) return;
131
+ if (!email || !password) return fail(AUTH_MESSAGES.signUpFailed, AUTH_MESSAGES.missingFields);
132
+ if (minPasswordLength && password.length < minPasswordLength) {
133
+ return fail(AUTH_MESSAGES.signUpFailed, `Use at least ${minPasswordLength} characters for the password`);
134
+ }
135
+ setSubmitting(true);
136
+ setError("");
137
+ try {
138
+ await attempt.create({ emailAddress: email, password });
139
+ await attempt.prepareEmailAddressVerification({ strategy: "email_code" });
140
+ setPendingVerification(true);
141
+ } catch (caught) {
142
+ fail(AUTH_MESSAGES.signUpFailed, authErrorMessage(caught, AUTH_MESSAGES.signUpFailed));
143
+ } finally {
144
+ setSubmitting(false);
145
+ }
146
+ };
147
+
148
+ const onVerify = async () => {
149
+ const { isLoaded, signUp: attempt, setActive } = signUpState;
150
+ if (!isLoaded || !attempt || !setActive || submitting) return;
151
+ if (!code) return fail(AUTH_MESSAGES.verifyFailed, AUTH_MESSAGES.missingCode);
152
+ setSubmitting(true);
153
+ setError("");
154
+ try {
155
+ const result = await attempt.attemptEmailAddressVerification({ code });
156
+ if (result.status === "complete" && result.createdSessionId) {
157
+ await setActive({ session: result.createdSessionId });
158
+ done();
159
+ } else {
160
+ fail(AUTH_MESSAGES.verifyFailed, AUTH_MESSAGES.needsAnotherStep);
161
+ }
162
+ } catch (caught) {
163
+ fail(AUTH_MESSAGES.verifyFailed, authErrorMessage(caught, AUTH_MESSAGES.verifyFailed));
164
+ } finally {
165
+ setSubmitting(false);
166
+ }
167
+ };
168
+
169
+ const linkHref = signUp ? signInHref : signUpHref;
170
+ const footerRow = (
171
+ <>
172
+ {linkHref && !pendingVerification ? (
173
+ <>
174
+ <Text className="font-instrument-sans text-sm" style={textStyle("muted", colorScheme)}>
175
+ {signUp ? "Already have an account?" : "Don't have an account?"}
176
+ </Text>
177
+ <Button
178
+ testID={id(signUp ? "sign-in-link" : "sign-up-link")}
179
+ variant="link"
180
+ size="sm"
181
+ fullWidth={false}
182
+ onPress={() => router.push(linkHref)}
183
+ >
184
+ {signUp ? "Sign in" : "Sign up"}
185
+ </Button>
186
+ </>
187
+ ) : null}
188
+ {footer}
189
+ </>
190
+ );
191
+ const hasFooter = Boolean((linkHref && !pendingVerification) || footer);
192
+
193
+ if (pendingVerification) {
194
+ return (
195
+ <AuthScreen
196
+ testID={id("screen")}
197
+ title="Check your email"
198
+ subtitle="Enter the code we sent to your email."
199
+ header={header}
200
+ footer={hasFooter ? footerRow : undefined}
201
+ >
202
+ <Input
203
+ testID={id("code")}
204
+ accessibilityLabel="Verification code"
205
+ value={code}
206
+ placeholder="Verification code"
207
+ keyboardType="number-pad"
208
+ autoComplete="one-time-code"
209
+ textContentType="oneTimeCode"
210
+ autoFocus
211
+ returnKeyType="go"
212
+ onChangeText={setCode}
213
+ onSubmitEditing={() => void onVerify()}
214
+ />
215
+ <FieldError testID={id("error")} message={error} />
216
+ <Button testID={id("verify")} loading={submitting} onPress={() => void onVerify()}>
217
+ Verify
218
+ </Button>
219
+ <Button
220
+ testID={id("back")}
221
+ variant="ghost"
222
+ onPress={() => {
223
+ setPendingVerification(false);
224
+ setCode("");
225
+ setError("");
226
+ }}
227
+ >
228
+ Use a different email
229
+ </Button>
230
+ </AuthScreen>
231
+ );
232
+ }
233
+
234
+ return (
235
+ <AuthScreen
236
+ testID={id("screen")}
237
+ title={title ?? (signUp ? "Create account" : "Welcome back")}
238
+ subtitle={subtitle ?? (signUp ? "Sign up to get started" : "Sign in to continue")}
239
+ header={header}
240
+ footer={hasFooter ? footerRow : undefined}
241
+ >
242
+ {showGoogle ? (
243
+ <>
244
+ <GoogleSignInButton
245
+ testID={id("google")}
246
+ scheme={scheme}
247
+ redirectPath={redirectPath}
248
+ afterAuthHref={afterAuthHref}
249
+ replaceOptions={replaceOptions}
250
+ onSuccess={onSuccess}
251
+ onError={(message) => fail(message, message)}
252
+ label={googleLabel}
253
+ disabled={submitting}
254
+ />
255
+ <OrDivider />
256
+ </>
257
+ ) : null}
258
+ <Input
259
+ testID={id("email")}
260
+ accessibilityLabel="Email"
261
+ autoCapitalize="none"
262
+ autoComplete="email"
263
+ textContentType="emailAddress"
264
+ keyboardType="email-address"
265
+ returnKeyType="next"
266
+ value={email}
267
+ placeholder="Email"
268
+ onChangeText={setEmail}
269
+ />
270
+ <Input
271
+ testID={id("password")}
272
+ accessibilityLabel="Password"
273
+ autoCapitalize="none"
274
+ autoComplete={signUp ? "new-password" : "password"}
275
+ textContentType={signUp ? "newPassword" : "password"}
276
+ secureTextEntry
277
+ returnKeyType="go"
278
+ value={password}
279
+ placeholder="Password"
280
+ onChangeText={setPassword}
281
+ onSubmitEditing={() => void (signUp ? onSignUp() : onSignIn())}
282
+ />
283
+ <FieldError testID={id("error")} message={error} />
284
+ <Button
285
+ testID={id("submit")}
286
+ loading={submitting}
287
+ onPress={() => void (signUp ? onSignUp() : onSignIn())}
288
+ >
289
+ {signUp ? "Create account" : "Sign in"}
290
+ </Button>
291
+ {/* Bot protection mounts its widget here on web sign-up. */}
292
+ {signUp ? <View nativeID="clerk-captcha" /> : null}
293
+ </AuthScreen>
294
+ );
295
+ }
296
+
297
+ export type SignUpScreenProps = Omit<SignInScreenProps, "mode">;
298
+
299
+ /** `SignInScreen` with `mode="sign-up"`. */
300
+ export function SignUpScreen(props: SignUpScreenProps) {
301
+ return <SignInScreen {...props} mode="sign-up" />;
302
+ }
@@ -0,0 +1,19 @@
1
+ import { isClerkAPIResponseError } from "@clerk/clerk-expo";
2
+
3
+ /** Words that must not reach the user: the auth provider and stack names. */
4
+ const PRODUCT_NAMES = /\b(clerk|convex)\b/i;
5
+
6
+ /**
7
+ * A message to show for an auth error. An API error gives its first
8
+ * `longMessage` or `message` (such as "Password is incorrect."); anything
9
+ * else, or a message that names the provider, gives `fallback`. Never shows
10
+ * a raw exception message.
11
+ */
12
+ export function authErrorMessage(error: unknown, fallback: string): string {
13
+ if (isClerkAPIResponseError(error)) {
14
+ const first = error.errors[0];
15
+ const message = first?.longMessage ?? first?.message;
16
+ if (message && !PRODUCT_NAMES.test(message)) return message;
17
+ }
18
+ return fallback;
19
+ }
@@ -0,0 +1,22 @@
1
+ // Clerk + Convex providers and the shared sign-in and sign-up screens. On
2
+ // `@vvedantb/native-ui/auth` only: they import `@clerk/clerk-expo`, `convex`,
3
+ // `expo-auth-session`, `expo-web-browser` and `expo-router`, and Metro does not
4
+ // tree-shake.
5
+ export { AuthProviders } from "./AuthProviders";
6
+ export type { AuthProvidersProps } from "./AuthProviders";
7
+ export { AuthScreen, OrDivider, FieldError } from "./AuthScreen";
8
+ export type { AuthScreenProps } from "./AuthScreen";
9
+ export { authErrorMessage } from "./authErrors";
10
+ export {
11
+ GoogleSignInButton,
12
+ useGoogleSignIn,
13
+ useWarmUpBrowser,
14
+ GOOGLE_ERROR_MESSAGE,
15
+ } from "./GoogleSignInButton";
16
+ export type {
17
+ AuthNavigationOptions,
18
+ GoogleSignInOptions,
19
+ GoogleSignInButtonProps,
20
+ } from "./GoogleSignInButton";
21
+ export { SignInScreen, SignUpScreen, AUTH_MESSAGES } from "./SignInScreen";
22
+ export type { SignInMode, SignInScreenProps, SignUpScreenProps } from "./SignInScreen";
@@ -1,5 +1,5 @@
1
- import { Platform, type StyleProp, type ViewStyle } from "react-native";
2
- import Svg, { G, Path } from "react-native-svg";
1
+ import { Platform, type ViewProps } from "react-native";
2
+ import Svg, { G, Path, type GProps } from "react-native-svg";
3
3
  import { svgA11yProps } from "../svgA11y";
4
4
  import { tokens } from "../tokens";
5
5
  import { useScheme } from "../useScheme";
@@ -26,7 +26,7 @@ export type AppIconProps = {
26
26
  align?: "center" | "start";
27
27
  /** Set as `data-testid` on web, where `Svg` does not map `testID`. */
28
28
  testID?: string;
29
- style?: StyleProp<ViewStyle>;
29
+ style?: ViewProps["style"];
30
30
  };
31
31
 
32
32
  export type AppIconComponent = ((props: AppIconProps) => React.JSX.Element) & {
@@ -54,7 +54,7 @@ function createAppIcon(app: AppName, displayName: string): AppIconComponent {
54
54
  stroke === undefined
55
55
  ? markStrokeWidth(app)
56
56
  : stroke * (viewBoxSide(app) / size);
57
- const glyphProps =
57
+ const glyphProps: GProps =
58
58
  kind === "fill"
59
59
  ? { fill }
60
60
  : {
@@ -1,5 +1,5 @@
1
- import { Platform, type StyleProp, type ViewStyle } from "react-native";
2
- import Svg, { G, Path, Rect } from "react-native-svg";
1
+ import { Platform, type ViewProps } from "react-native";
2
+ import Svg, { G, Path, Rect, type GProps } from "react-native-svg";
3
3
  import { svgA11yProps } from "../svgA11y";
4
4
  import { APP_ICONS } from "./AppIcons";
5
5
  import {
@@ -23,7 +23,7 @@ export type AppMarkProps = {
23
23
  title?: string;
24
24
  /** Set as `data-testid` on web, where `Svg` does not map `testID`. */
25
25
  testID?: string;
26
- style?: StyleProp<ViewStyle>;
26
+ style?: ViewProps["style"];
27
27
  };
28
28
 
29
29
  const TILE_TONES = {
@@ -50,7 +50,7 @@ export function AppMark({
50
50
  }
51
51
  const { kind, paths } = APP_MARKS[app];
52
52
  const tone = TILE_TONES[variant];
53
- const glyphProps =
53
+ const glyphProps: GProps =
54
54
  kind === "fill"
55
55
  ? { fill: tone.fg }
56
56
  : {
@@ -1,4 +1,4 @@
1
- import { Platform, type StyleProp, type ViewStyle } from "react-native";
1
+ import { Platform, type ViewProps } from "react-native";
2
2
  import Svg, { Path } from "react-native-svg";
3
3
  import { svgA11yProps } from "../svgA11y";
4
4
 
@@ -9,7 +9,7 @@ export type GoogleMarkProps = {
9
9
  title?: string;
10
10
  /** Set as `data-testid` on web, where `Svg` does not map `testID`. */
11
11
  testID?: string;
12
- style?: StyleProp<ViewStyle>;
12
+ style?: ViewProps["style"];
13
13
  };
14
14
 
15
15
  /** The four paths of the Google "G" on the 24 grid, each in its brand colour. */
@@ -0,0 +1,5 @@
1
+ // `@vvedantb/native-ui/auth`: `AuthProviders`, `SignInScreen`, `SignUpScreen`,
2
+ // `GoogleSignInButton` and the auth frame. Not on the root entry: it imports
3
+ // `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser` and
4
+ // `expo-router`, and Metro does not tree-shake.
5
+ export * from "../auth";
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/expo-web-root`: `ExpoWebRoot` for `app/+html.tsx`. Not on
2
+ // the root entry: it imports `expo-router/html`, and Metro does not tree-shake.
3
+ export { ExpoWebRoot, EXPO_WEB_VIEWPORT, expoWebBackgroundCss } from "../ExpoWebRoot";
4
+ export type { ExpoWebRootProps, ExpoWebRootViewport } from "../ExpoWebRoot";
package/src/floating.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Platform, type ViewStyle } from "react-native";
1
+ import { Platform } from "react-native";
2
2
  import { tokens } from "./tokens";
3
3
 
4
4
  export const FLOATING_INSET = tokens.spacing.unit;
@@ -14,7 +14,21 @@ export const FLOATING_PILL_RADIUS = tokens.radius.pill;
14
14
 
15
15
  const FLOATING_BOX_SHADOW = "0px 8px 24px rgba(10, 10, 10, 0.12)";
16
16
 
17
- export const floatingShadow: ViewStyle =
17
+ /**
18
+ * Only the shadow keys, not `ViewStyle`: an Expo-widened `ViewStyle`
19
+ * (`position: "fixed"`, `backgroundImage`) is not assignable to a React
20
+ * Native 0.88 View `style`. A plain inferred type would be `{}` and lose
21
+ * `floatingShadow.shadowColor` and the rest for callers.
22
+ */
23
+ type FloatingShadowStyle = {
24
+ shadowColor?: string;
25
+ shadowOffset?: { width: number; height: number };
26
+ shadowOpacity?: number;
27
+ shadowRadius?: number;
28
+ boxShadow?: string;
29
+ };
30
+
31
+ export const floatingShadow: FloatingShadowStyle =
18
32
  Platform.select({
19
33
  ios: {
20
34
  shadowColor: "#0a0a0a",
@@ -1,6 +1,6 @@
1
1
  import * as SwitchPrimitives from "@rn-primitives/switch";
2
2
  import * as React from "react";
3
- import { Platform, type ViewStyle } from "react-native";
3
+ import { Platform, type ViewProps, type ViewStyle } from "react-native";
4
4
  import { cn } from "../cn";
5
5
 
6
6
  type SwitchProps = React.ComponentPropsWithoutRef<
@@ -13,7 +13,7 @@ type SwitchProps = React.ComponentPropsWithoutRef<
13
13
  * which spreads `style`, so these are the only styles applied there.
14
14
  */
15
15
  trackStyle?: ViewStyle;
16
- thumbStyle?: ViewStyle;
16
+ thumbStyle?: ViewProps["style"];
17
17
  };
18
18
 
19
19
  const Switch = React.forwardRef<