@vvedantb/native-ui 0.6.1 → 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.
- package/README.md +84 -9
- package/package.json +31 -2
- package/src/ExpoWebRoot.tsx +81 -0
- package/src/ai/code-block.tsx +1 -1
- package/src/auth/AuthProviders.tsx +55 -0
- package/src/auth/AuthScreen.tsx +97 -0
- package/src/auth/GoogleSignInButton.tsx +116 -0
- package/src/auth/SignInScreen.tsx +302 -0
- package/src/auth/authErrors.ts +19 -0
- package/src/auth/index.ts +22 -0
- package/src/entries/auth.ts +5 -0
- package/src/entries/expo-web-root.ts +4 -0
- package/src/tailwind-preset.cjs +248 -0
- package/src/tailwind-preset.d.cts +50 -0
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Optional, only if you import those components:
|
|
|
23
23
|
|
|
24
24
|
- `@gorhom/bottom-sheet` — `BottomSheetContainer`
|
|
25
25
|
- `@react-native-community/datetimepicker` and `dayjs` — `DateTimeField`
|
|
26
|
+
- `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser` and `expo-router` — `@vvedantb/native-ui/auth` (0.7.0). `expo-router` also for `@vvedantb/native-ui/expo-web-root`.
|
|
26
27
|
- `react-native-svg` — the app marks (`APP_ICONS`, `Icon*`, `AppMark`, `AppWordmark`), `GoogleMark` and `PricingModal`. Apps with the icon peers above already have it: `lucide-react-native` and `@tabler/icons-react-native` both require it.
|
|
27
28
|
|
|
28
29
|
Optional and loaded only when installed, so the package imports and runs without them:
|
|
@@ -75,10 +76,13 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
|
|
|
75
76
|
| `@vvedantb/native-ui/date-time` | `DateTimeField`, `DateTimeFieldProps` (0.5.0) | `@react-native-community/datetimepicker`, `dayjs` (import) |
|
|
76
77
|
| `@vvedantb/native-ui/ai` | The AI chat parts (from 0.4.0): `Conversation`, `Message`, `Reasoning`, `AgentTrace`, `ChainOfThought`, `ActivityStep`, `PromptInput`, `Suggestions`, `Sources`, `FileChip`, `Attachments`, `CodeBlock`, `ResponseText`, `Shimmer`, `Collapsible`, `useStreamingDisclosure` and their parts. See [AI chat](#ai-chat-040). Not on the root entry. | none |
|
|
77
78
|
| `@vvedantb/native-ui/ai-gallery` | `AiChatGallery`, `AiChatSamples` (from 0.4.0) | none |
|
|
79
|
+
| `@vvedantb/native-ui/auth` | From 0.7.0: `AuthProviders`, `SignInScreen`, `SignUpScreen`, `GoogleSignInButton`, `useGoogleSignIn`, `AuthScreen`, `OrDivider`, `FieldError`, `authErrorMessage` and their props types. See [Auth](#auth-070). Not on the root entry. | `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser`, `expo-router` (import) |
|
|
80
|
+
| `@vvedantb/native-ui/expo-web-root` | From 0.7.0: `ExpoWebRoot`, `ExpoWebRootProps`, `ExpoWebRootViewport`, `EXPO_WEB_VIEWPORT`, `expoWebBackgroundCss`. Not on the root entry. | `expo-router` (import of `expo-router/html`) |
|
|
81
|
+
| `@vvedantb/native-ui/tailwind-preset` | From 0.7.0, CommonJS for `tailwind.config.js`: `nativeUiPreset`, `nativeUiContent`, `cssVar`, `hslVar`, `rgbAlpha`, `cssVariables`, `schemeColors` and a default preset. See [Expo / NativeWind](#expo--nativewind). | `nativewind` (`require`, when installed) |
|
|
78
82
|
|
|
79
83
|
"Loader" means the `require` inside `try` described above, so the entry bundles and runs without that peer. `@vvedantb/native-ui/gallery` imports the root entry; `@vvedantb/native-ui/ai-gallery` imports `/ai` only.
|
|
80
84
|
|
|
81
|
-
The AI chat parts are only on `/ai`. Metro does not tree-shake by default, so on the root entry they would bundle into every app, with or without a chat.
|
|
85
|
+
The AI chat parts are only on `/ai`, and the auth screens and Expo web root only on `/auth` and `/expo-web-root`. Metro does not tree-shake by default, so on the root entry they would bundle into every app, with or without a chat or sign-in.
|
|
82
86
|
|
|
83
87
|
```tsx
|
|
84
88
|
import { FloatingTabBar } from "@vvedantb/native-ui/tab-bar";
|
|
@@ -93,18 +97,33 @@ The entries re-export the same source files, so they can be mixed: `NativeUiProv
|
|
|
93
97
|
|
|
94
98
|
## Expo / NativeWind
|
|
95
99
|
|
|
96
|
-
Source-exported.
|
|
100
|
+
Source-exported. From 0.7.0 the preset sets up Tailwind for the package: it nests `nativewind/preset`, sets `darkMode: "class"` (see [Theme](#theme)), maps the Instrument Sans font classes and adds the colour keys the components and apps share. `nativeUiContent()` points Tailwind at the package source, at its real path, so class names are generated:
|
|
97
101
|
|
|
98
102
|
```js
|
|
99
103
|
// tailwind.config.js
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"./components/**/*.{js,ts,tsx}",
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
const { nativeUiPreset, nativeUiContent } = require("@vvedantb/native-ui/tailwind-preset");
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
content: ["./app/**/*.{js,ts,tsx}", "./components/**/*.{js,ts,tsx}", nativeUiContent()],
|
|
108
|
+
presets: [nativeUiPreset()],
|
|
109
|
+
// App colours, display fonts, keyframes and plugins (such as tailwindcss-animate) stay here.
|
|
110
|
+
theme: { extend: {} },
|
|
111
|
+
};
|
|
106
112
|
```
|
|
107
113
|
|
|
114
|
+
`nativeUiPreset(options)`:
|
|
115
|
+
|
|
116
|
+
| Option | Default | Effect |
|
|
117
|
+
| --- | --- | --- |
|
|
118
|
+
| `darkMode` | `"class"` | `"media"` follows the system only. `ThemeToggle` and `setColorScheme` need `"class"`. |
|
|
119
|
+
| `color` | `cssVar`, or `rgbAlpha` with `injectCssVariables` | Maps a variable name to a colour value. `cssVar` gives `var(--name)`, `hslVar` gives `hsl(var(--name))` and `rgbAlpha` gives `rgb(var(--name) / <alpha-value>)`. For another prefix, pass your own, such as ``(name) => `rgb(var(--color-${name}) / <alpha-value>)` ``. |
|
|
120
|
+
| `injectCssVariables` | `false` | Writes every colour variable from `tokens.ts` and `palette.ts` as `r g b` channels, and `--radius` (16px), for light (`:root`) and dark (`.dark`, or the media query with `darkMode: "media"`). The app's `global.css` then needs no token block. |
|
|
121
|
+
| `radiusVariable` | `injectCssVariables` | `rounded-lg`, `-md` and `-sm` from `var(--radius)`. |
|
|
122
|
+
|
|
123
|
+
The colour keys are `background`, `foreground`, `border`, `input`, `ring`, `separator`, `surface` (`DEFAULT`, `foreground`, `secondary`, `tertiary`, `card`), `field` (`DEFAULT`, `placeholder`) and, each with `DEFAULT` and `foreground`, `primary`, `secondary`, `destructive`, `muted`, `accent`, `popover`, `card`, `danger`, `success`, `warning`, `default` and `overlay`. Each reads the variable of the same name (`surface.secondary` reads `--surface-secondary`; `muted.foreground` reads `--muted-foreground`). `borderWidth.hairline` is NativeWind's `hairlineWidth()`.
|
|
124
|
+
|
|
125
|
+
Before 0.7.0, list the package in `content` by hand (`"./node_modules/@vvedantb/native-ui/src/**/*.{js,ts,tsx}"`) and set `darkMode: "class"`.
|
|
126
|
+
|
|
108
127
|
Metro already resolves `node_modules`. In a pnpm monorepo, if a peer resolves to the wrong copy, add:
|
|
109
128
|
|
|
110
129
|
```js
|
|
@@ -116,7 +135,7 @@ config.resolver.nodeModulesPaths = [
|
|
|
116
135
|
];
|
|
117
136
|
```
|
|
118
137
|
|
|
119
|
-
|
|
138
|
+
Without the preset, the app must define NativeWind tokens (`bg-surface`, `bg-secondary`, pink utilities) and Instrument Sans font classes (`font-instrument-sans-*`). Accent pink stays in the app stylesheet. `Button`, `Input`, `Textarea`, the `Select` trigger, `Switch`, `Checkbox`, `Badge`, `Tabs` and `PillTabs` take their colours from `tokens` and `palette`, so they do not need `primary`, `input` or other theme keys. From 0.3.3 so does component text (titles, descriptions, labels, rows, toasts): it no longer uses `text-foreground` or `text-muted-foreground`, which stay light-mode colours in dark mode when an app defines `foreground` with a nested `dark` key. Override those colours with `style`, not `className`. App text needs the same care: `tokens.color.foreground[scheme]` reads in both schemes. From 0.5.0 the `ListRow`, `DateTimeField` and `HeaderIconButton` fills are styles too, so `ThemeToggle` and the `StackPageHeader` back button follow the scheme.
|
|
120
139
|
|
|
121
140
|
From 0.3.5 text that uses `font-instrument-sans-medium` or `-semibold` also sets `fontWeight` as a style (`typeStyle` in `src/controlStyles.ts`). Where an app maps every `font-instrument-sans-*` class to one family with no weight, as Expo web does, the class alone draws Regular. A weight without the face class does not pick the Instrument Sans Medium or Semibold file, so set both. From 0.3.13 every title in the package pairs the two, the internal primitives included, and `tests/fontWeight.test.ts` fails on a face class without its weight, a weight without its face class, or a bare Tailwind `font-medium`, `font-semibold` or `font-bold`.
|
|
122
141
|
|
|
@@ -136,6 +155,62 @@ fontFamily: {
|
|
|
136
155
|
|
|
137
156
|
A class mapped to a family the browser does not have, such as `["System"]`, with no generic fallback draws in the browser default, often a serif.
|
|
138
157
|
|
|
158
|
+
### Expo web root (0.7.0)
|
|
159
|
+
|
|
160
|
+
`app/+html.tsx` can be one component:
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
import { ExpoWebRoot } from "@vvedantb/native-ui/expo-web-root";
|
|
164
|
+
|
|
165
|
+
export default function Root({ children }: { children: React.ReactNode }) {
|
|
166
|
+
return <ExpoWebRoot>{children}</ExpoWebRoot>;
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
It renders `<html lang="en">`, the charset, `X-UA-Compatible` and viewport meta tags, `ScrollViewStyleReset` from `expo-router/html`, a body background style and `<body>`. Props: `lang`; `viewport` (`"native"`, the default, stops pinch-zoom; `"accessible"` allows it; both set `viewport-fit=cover`); `scrollReset` (default `true`); `lightBackground` and `darkBackground` (default `tokens.color.background`); `head` for more `<head>` nodes. The dark fill follows `prefers-color-scheme` and the `html.dark` class.
|
|
171
|
+
|
|
172
|
+
## Auth (0.7.0)
|
|
173
|
+
|
|
174
|
+
`@vvedantb/native-ui/auth` has the Clerk and Convex providers and a sign-in and sign-up screen. It needs `@clerk/clerk-expo`, `expo-auth-session`, `expo-web-browser` and `expo-router`, and `convex` when you pass a Convex client.
|
|
175
|
+
|
|
176
|
+
```tsx
|
|
177
|
+
// app/_layout.tsx
|
|
178
|
+
import { useAuth } from "@clerk/clerk-expo";
|
|
179
|
+
import { tokenCache } from "@clerk/clerk-expo/token-cache";
|
|
180
|
+
import { AuthProviders } from "@vvedantb/native-ui/auth";
|
|
181
|
+
|
|
182
|
+
<AuthProviders publishableKey={publishableKey} tokenCache={tokenCache} convex={convex} useAuth={useAuth}>
|
|
183
|
+
<Stack screenOptions={{ headerShown: false }} />
|
|
184
|
+
</AuthProviders>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`AuthProviders` nests `ClerkProvider`, `ConvexProviderWithClerk` (only with `convex`) and `ClerkLoaded` (with `loaded`, default `true`). Without `convex` the children sit under Clerk only. `clerk={false}` gives a plain `ConvexProvider`, for UI preview builds. The app reads its own key; the component checks nothing.
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
// app/(auth)/sign-in.tsx
|
|
191
|
+
import { SignInScreen } from "@vvedantb/native-ui/auth";
|
|
192
|
+
|
|
193
|
+
export default function SignIn() {
|
|
194
|
+
return <SignInScreen scheme="myapp" signUpHref="/sign-up" />;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// app/(auth)/sign-up.tsx
|
|
198
|
+
import { SignUpScreen } from "@vvedantb/native-ui/auth";
|
|
199
|
+
|
|
200
|
+
export default function SignUp() {
|
|
201
|
+
return <SignUpScreen scheme="myapp" signInHref="/sign-in" minPasswordLength={8} />;
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- Email and password, and "Continue with Google" (`useSSO`, `oauth_google`, redirect `AuthSession.makeRedirectUri({ scheme, path: redirectPath })`; `redirectPath` defaults to `"sso-callback"`). Sign-up sends an email code and checks it before the session starts.
|
|
206
|
+
- On success: `onSuccess()`, or `router.replace(afterAuthHref, replaceOptions)` (default `"/"`; for example `replaceOptions={{ withAnchor: true }}`).
|
|
207
|
+
- `title`, `subtitle`, `showGoogle`, `googleLabel`, `errorDisplay` (`"toast"`, default, needs a `<Toaster />`; or `"inline"`), `testIDPrefix` (default the mode: `sign-in-email`, `sign-in-password`, `sign-in-submit`, `sign-in-google` and the rest).
|
|
208
|
+
- `header` and `footer` slots for app chrome, such as a brand header or credit. The screen itself is neutral: background fill, title, subtitle, fields.
|
|
209
|
+
- Messages are generic ("Sign in failed", "Invalid email or password", "Could not continue with Google") and never name the auth provider. `authErrorMessage(error, fallback)` shows an API error's own text only when it does not name it.
|
|
210
|
+
- No forgot-password step. An app that needs one keeps its own screen.
|
|
211
|
+
|
|
212
|
+
`GoogleSignInButton` (`scheme`, `redirectPath`, `afterAuthHref`, `replaceOptions`, `onSuccess`, `onError`, `label` and the `Button` props), `AuthScreen`, `OrDivider` and `FieldError` build custom screens. The module calls `WebBrowser.maybeCompleteAuthSession()` once when it loads, so the app no longer needs its own call.
|
|
213
|
+
|
|
139
214
|
## Theme
|
|
140
215
|
|
|
141
216
|
Wrap the tree (inside your colour-scheme provider):
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts
|
|
5
|
+
"description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts, the family app marks, a NativeWind preset, the Expo web root and Clerk + Convex auth screens.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
@@ -93,6 +93,19 @@
|
|
|
93
93
|
"types": "./src/haptics.ts",
|
|
94
94
|
"default": "./src/haptics.ts"
|
|
95
95
|
},
|
|
96
|
+
"./auth": {
|
|
97
|
+
"types": "./src/entries/auth.ts",
|
|
98
|
+
"default": "./src/entries/auth.ts"
|
|
99
|
+
},
|
|
100
|
+
"./expo-web-root": {
|
|
101
|
+
"types": "./src/entries/expo-web-root.ts",
|
|
102
|
+
"default": "./src/entries/expo-web-root.ts"
|
|
103
|
+
},
|
|
104
|
+
"./tailwind-preset": {
|
|
105
|
+
"types": "./src/tailwind-preset.d.cts",
|
|
106
|
+
"require": "./src/tailwind-preset.cjs",
|
|
107
|
+
"default": "./src/tailwind-preset.cjs"
|
|
108
|
+
},
|
|
96
109
|
"./package.json": "./package.json"
|
|
97
110
|
},
|
|
98
111
|
"types": "./src/index.ts",
|
|
@@ -108,6 +121,7 @@
|
|
|
108
121
|
"prepack": "node -e \"require('node:fs').accessSync('src/index.ts')\""
|
|
109
122
|
},
|
|
110
123
|
"peerDependencies": {
|
|
124
|
+
"@clerk/clerk-expo": "*",
|
|
111
125
|
"@expo/vector-icons": "*",
|
|
112
126
|
"@gorhom/bottom-sheet": ">=5",
|
|
113
127
|
"@react-native-community/datetimepicker": "*",
|
|
@@ -117,11 +131,14 @@
|
|
|
117
131
|
"@tabler/icons-react-native": "*",
|
|
118
132
|
"class-variance-authority": ">=0.7.0",
|
|
119
133
|
"clsx": ">=2.0.0",
|
|
134
|
+
"convex": "*",
|
|
120
135
|
"dayjs": ">=1.11.0",
|
|
136
|
+
"expo-auth-session": "*",
|
|
121
137
|
"expo-blur": "*",
|
|
122
138
|
"expo-haptics": "*",
|
|
123
139
|
"expo-linear-gradient": "*",
|
|
124
140
|
"expo-router": "*",
|
|
141
|
+
"expo-web-browser": "*",
|
|
125
142
|
"lucide-react-native": "*",
|
|
126
143
|
"nativewind": ">=4.0.0",
|
|
127
144
|
"react": ">=19.0.0",
|
|
@@ -133,15 +150,24 @@
|
|
|
133
150
|
"tailwind-merge": ">=2.0.0"
|
|
134
151
|
},
|
|
135
152
|
"peerDependenciesMeta": {
|
|
153
|
+
"@clerk/clerk-expo": {
|
|
154
|
+
"optional": true
|
|
155
|
+
},
|
|
136
156
|
"@gorhom/bottom-sheet": {
|
|
137
157
|
"optional": true
|
|
138
158
|
},
|
|
139
159
|
"@react-native-community/datetimepicker": {
|
|
140
160
|
"optional": true
|
|
141
161
|
},
|
|
162
|
+
"convex": {
|
|
163
|
+
"optional": true
|
|
164
|
+
},
|
|
142
165
|
"dayjs": {
|
|
143
166
|
"optional": true
|
|
144
167
|
},
|
|
168
|
+
"expo-auth-session": {
|
|
169
|
+
"optional": true
|
|
170
|
+
},
|
|
145
171
|
"expo-blur": {
|
|
146
172
|
"optional": true
|
|
147
173
|
},
|
|
@@ -154,6 +180,9 @@
|
|
|
154
180
|
"expo-router": {
|
|
155
181
|
"optional": true
|
|
156
182
|
},
|
|
183
|
+
"expo-web-browser": {
|
|
184
|
+
"optional": true
|
|
185
|
+
},
|
|
157
186
|
"react-native-svg": {
|
|
158
187
|
"optional": true
|
|
159
188
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { ScrollViewStyleReset } from "expo-router/html";
|
|
3
|
+
import { tokens } from "./tokens";
|
|
4
|
+
|
|
5
|
+
/** `"native"` stops pinch-zoom so the page acts like the app; `"accessible"` allows it (WCAG 1.4.4). Both cover the notch. */
|
|
6
|
+
export type ExpoWebRootViewport = "native" | "accessible";
|
|
7
|
+
|
|
8
|
+
export const EXPO_WEB_VIEWPORT: Record<ExpoWebRootViewport, string> = {
|
|
9
|
+
native: "width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover",
|
|
10
|
+
accessible: "width=device-width, initial-scale=1.0, viewport-fit=cover",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ExpoWebRootProps = {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
/** `<html lang>`. Default `"en"`. */
|
|
16
|
+
lang?: string;
|
|
17
|
+
/** Default `"native"`. */
|
|
18
|
+
viewport?: ExpoWebRootViewport;
|
|
19
|
+
/** `ScrollViewStyleReset`: no body scroll, so `ScrollView` works as on native. Default `true`. */
|
|
20
|
+
scrollReset?: boolean;
|
|
21
|
+
/** Body fill in light. Default `tokens.color.background.light`. */
|
|
22
|
+
lightBackground?: string;
|
|
23
|
+
/** Body fill in dark. Default `tokens.color.background.dark`. */
|
|
24
|
+
darkBackground?: string;
|
|
25
|
+
/** More `<head>` nodes, after the defaults. */
|
|
26
|
+
head?: ReactNode;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The body fill as raw CSS, so the background does not flash in dark mode
|
|
31
|
+
* before the app paints. Dark follows the system, and the NativeWind `dark`
|
|
32
|
+
* class on `<html>` when the app sets the scheme (`darkMode: "class"`).
|
|
33
|
+
*/
|
|
34
|
+
export function expoWebBackgroundCss(light: string, dark: string): string {
|
|
35
|
+
return `
|
|
36
|
+
body {
|
|
37
|
+
background-color: ${light};
|
|
38
|
+
}
|
|
39
|
+
@media (prefers-color-scheme: dark) {
|
|
40
|
+
body {
|
|
41
|
+
background-color: ${dark};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
html.dark body {
|
|
45
|
+
background-color: ${dark};
|
|
46
|
+
}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The root HTML for Expo web static rendering. Use it as the whole of
|
|
51
|
+
* `app/+html.tsx`:
|
|
52
|
+
*
|
|
53
|
+
* export default function Root({ children }: { children: React.ReactNode }) {
|
|
54
|
+
* return <ExpoWebRoot>{children}</ExpoWebRoot>;
|
|
55
|
+
* }
|
|
56
|
+
*
|
|
57
|
+
* Runs in Node during the static render, with no DOM.
|
|
58
|
+
*/
|
|
59
|
+
export function ExpoWebRoot({
|
|
60
|
+
children,
|
|
61
|
+
lang = "en",
|
|
62
|
+
viewport = "native",
|
|
63
|
+
scrollReset = true,
|
|
64
|
+
lightBackground = tokens.color.background.light,
|
|
65
|
+
darkBackground = tokens.color.background.dark,
|
|
66
|
+
head,
|
|
67
|
+
}: ExpoWebRootProps) {
|
|
68
|
+
return (
|
|
69
|
+
<html lang={lang}>
|
|
70
|
+
<head>
|
|
71
|
+
<meta charSet="utf-8" />
|
|
72
|
+
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
|
73
|
+
<meta name="viewport" content={EXPO_WEB_VIEWPORT[viewport]} />
|
|
74
|
+
{scrollReset ? <ScrollViewStyleReset /> : null}
|
|
75
|
+
<style dangerouslySetInnerHTML={{ __html: expoWebBackgroundCss(lightBackground, darkBackground) }} />
|
|
76
|
+
{head}
|
|
77
|
+
</head>
|
|
78
|
+
<body>{children}</body>
|
|
79
|
+
</html>
|
|
80
|
+
);
|
|
81
|
+
}
|
package/src/ai/code-block.tsx
CHANGED
|
@@ -27,7 +27,7 @@ export type CodeBlockCopyButtonProps = {
|
|
|
27
27
|
/** Ghost copy button: the copy icon, then a check for `timeout` after a copy. */
|
|
28
28
|
export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Copy code" }: CodeBlockCopyButtonProps) {
|
|
29
29
|
const [copied, setCopied] = useState(false);
|
|
30
|
-
const timer = useRef<
|
|
30
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
31
31
|
|
|
32
32
|
useEffect(() => () => clearTimeout(timer.current), []);
|
|
33
33
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { ClerkLoaded, ClerkProvider, useAuth as useClerkAuth, type TokenCache } from "@clerk/clerk-expo";
|
|
3
|
+
import { ConvexProvider, type ConvexReactClient } from "convex/react";
|
|
4
|
+
import { ConvexProviderWithClerk } from "convex/react-clerk";
|
|
5
|
+
|
|
6
|
+
export type AuthProvidersProps = {
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
/** The Clerk publishable key. The app reads it from its own config; this component does not check it. */
|
|
9
|
+
publishableKey?: string;
|
|
10
|
+
/** `tokenCache` from `@clerk/clerk-expo/token-cache`, so the session survives a restart. */
|
|
11
|
+
tokenCache?: TokenCache;
|
|
12
|
+
/** A Convex client. Without one, the children sit under Clerk only (vmem). */
|
|
13
|
+
convex?: ConvexReactClient | null;
|
|
14
|
+
/** `useAuth` from `@clerk/clerk-expo`, for `ConvexProviderWithClerk`. Default: the one this package imports. */
|
|
15
|
+
useAuth?: typeof useClerkAuth;
|
|
16
|
+
/** Wrap the children in `ClerkLoaded`, so they wait for the session. Default `true`. */
|
|
17
|
+
loaded?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Default `true`. `false` drops Clerk: the children sit under a plain
|
|
20
|
+
* `ConvexProvider` (or nothing without `convex`), as the web
|
|
21
|
+
* `ConvexClerkProvider` does for UI preview builds.
|
|
22
|
+
*/
|
|
23
|
+
clerk?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The Clerk and Convex providers from the apps' root `_layout.tsx`:
|
|
28
|
+
* `ClerkProvider` › `ConvexProviderWithClerk` (with `convex`) › `ClerkLoaded`
|
|
29
|
+
* (with `loaded`) › children.
|
|
30
|
+
*/
|
|
31
|
+
export function AuthProviders({
|
|
32
|
+
children,
|
|
33
|
+
publishableKey,
|
|
34
|
+
tokenCache,
|
|
35
|
+
convex,
|
|
36
|
+
useAuth = useClerkAuth,
|
|
37
|
+
loaded = true,
|
|
38
|
+
clerk = true,
|
|
39
|
+
}: AuthProvidersProps) {
|
|
40
|
+
if (!clerk) {
|
|
41
|
+
return convex ? <ConvexProvider client={convex}>{children}</ConvexProvider> : <>{children}</>;
|
|
42
|
+
}
|
|
43
|
+
const inner = loaded ? <ClerkLoaded>{children}</ClerkLoaded> : children;
|
|
44
|
+
return (
|
|
45
|
+
<ClerkProvider publishableKey={publishableKey} tokenCache={tokenCache}>
|
|
46
|
+
{convex ? (
|
|
47
|
+
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
|
|
48
|
+
{inner}
|
|
49
|
+
</ConvexProviderWithClerk>
|
|
50
|
+
) : (
|
|
51
|
+
inner
|
|
52
|
+
)}
|
|
53
|
+
</ClerkProvider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
import { textStyle, typeStyle } from "../controlStyles";
|
|
5
|
+
import { palette } from "../palette";
|
|
6
|
+
import { tokens } from "../tokens";
|
|
7
|
+
import { useScheme } from "../useScheme";
|
|
8
|
+
|
|
9
|
+
export type AuthScreenProps = {
|
|
10
|
+
title: string;
|
|
11
|
+
subtitle?: string;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
/** Above the title, such as an app mark. App brand chrome goes here. */
|
|
14
|
+
header?: ReactNode;
|
|
15
|
+
/** Below the form, such as the "Don't have an account?" row. */
|
|
16
|
+
footer?: ReactNode;
|
|
17
|
+
testID?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The sign-in and sign-up frame: a keyboard-avoiding scroll view on the
|
|
23
|
+
* background colour, with a title, subtitle and the form in a column of 16.
|
|
24
|
+
*/
|
|
25
|
+
export function AuthScreen({ title, subtitle, children, header, footer, testID, className }: AuthScreenProps) {
|
|
26
|
+
const scheme = useScheme();
|
|
27
|
+
return (
|
|
28
|
+
<KeyboardAvoidingView
|
|
29
|
+
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
30
|
+
className="flex-1"
|
|
31
|
+
style={{ backgroundColor: tokens.color.background[scheme] }}
|
|
32
|
+
>
|
|
33
|
+
<ScrollView
|
|
34
|
+
testID={testID}
|
|
35
|
+
className="flex-1"
|
|
36
|
+
contentContainerClassName={cn("grow justify-center px-6 py-12", className)}
|
|
37
|
+
keyboardShouldPersistTaps="handled"
|
|
38
|
+
>
|
|
39
|
+
<View className="w-full max-w-sm self-center gap-6">
|
|
40
|
+
{header}
|
|
41
|
+
<View className="gap-2">
|
|
42
|
+
<Text
|
|
43
|
+
accessibilityRole="header"
|
|
44
|
+
className="font-instrument-sans-semibold text-2xl"
|
|
45
|
+
style={[textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
46
|
+
>
|
|
47
|
+
{title}
|
|
48
|
+
</Text>
|
|
49
|
+
{subtitle ? (
|
|
50
|
+
<Text className="font-instrument-sans text-base" style={textStyle("muted", scheme)}>
|
|
51
|
+
{subtitle}
|
|
52
|
+
</Text>
|
|
53
|
+
) : null}
|
|
54
|
+
</View>
|
|
55
|
+
<View className="gap-4">{children}</View>
|
|
56
|
+
{footer ? <View className="flex-row flex-wrap items-center justify-center gap-1">{footer}</View> : null}
|
|
57
|
+
</View>
|
|
58
|
+
</ScrollView>
|
|
59
|
+
</KeyboardAvoidingView>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A separator, "or", a separator: between Google and the email fields. */
|
|
64
|
+
export function OrDivider({ label = "or" }: { label?: string }) {
|
|
65
|
+
const scheme = useScheme();
|
|
66
|
+
const line = { height: StyleSheet.hairlineWidth, backgroundColor: tokens.color.separator[scheme] };
|
|
67
|
+
return (
|
|
68
|
+
<View
|
|
69
|
+
className="flex-row items-center gap-3"
|
|
70
|
+
accessibilityElementsHidden
|
|
71
|
+
importantForAccessibility="no-hide-descendants"
|
|
72
|
+
>
|
|
73
|
+
<View className="flex-1" style={line} />
|
|
74
|
+
<Text className="font-instrument-sans text-sm" style={textStyle("muted", scheme)}>
|
|
75
|
+
{label}
|
|
76
|
+
</Text>
|
|
77
|
+
<View className="flex-1" style={line} />
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** An inline error under a form, read out when it appears. Nothing without a message. */
|
|
83
|
+
export function FieldError({ message, testID }: { message?: string | null; testID?: string }) {
|
|
84
|
+
const scheme = useScheme();
|
|
85
|
+
if (!message) return null;
|
|
86
|
+
return (
|
|
87
|
+
<Text
|
|
88
|
+
testID={testID}
|
|
89
|
+
accessibilityLiveRegion="polite"
|
|
90
|
+
accessibilityRole="alert"
|
|
91
|
+
className="font-instrument-sans text-sm"
|
|
92
|
+
style={{ color: palette.danger[scheme] }}
|
|
93
|
+
>
|
|
94
|
+
{message}
|
|
95
|
+
</Text>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import { useSSO } from "@clerk/clerk-expo";
|
|
4
|
+
import * as AuthSession from "expo-auth-session";
|
|
5
|
+
import * as WebBrowser from "expo-web-browser";
|
|
6
|
+
import { useRouter } from "expo-router";
|
|
7
|
+
import { GoogleMark } from "../brand/GoogleMark";
|
|
8
|
+
import { Button, type ButtonProps } from "../Button";
|
|
9
|
+
import { toast } from "../toastStore";
|
|
10
|
+
import { authErrorMessage } from "./authErrors";
|
|
11
|
+
|
|
12
|
+
// Completes the Google browser session when the app reopens on the redirect.
|
|
13
|
+
WebBrowser.maybeCompleteAuthSession();
|
|
14
|
+
|
|
15
|
+
export const GOOGLE_ERROR_MESSAGE = "Could not continue with Google";
|
|
16
|
+
|
|
17
|
+
export type AuthNavigationOptions = {
|
|
18
|
+
/** Where to go after a session starts. Default `"/"`. */
|
|
19
|
+
afterAuthHref?: string;
|
|
20
|
+
/** Passed to `router.replace`, such as `{ withAnchor: true }`. */
|
|
21
|
+
replaceOptions?: object;
|
|
22
|
+
/** Called after a session starts, in place of `router.replace`. */
|
|
23
|
+
onSuccess?: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type GoogleSignInOptions = AuthNavigationOptions & {
|
|
27
|
+
/** The app URL scheme, such as `"vibot"`, for the redirect `scheme://sso-callback`. */
|
|
28
|
+
scheme: string;
|
|
29
|
+
/** Default `"sso-callback"`. */
|
|
30
|
+
redirectPath?: string;
|
|
31
|
+
/** Called with a message for the user. Default: an error toast. */
|
|
32
|
+
onError?: (message: string) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Warms the Android custom tab while the screen is up, as the Expo guide suggests. */
|
|
36
|
+
export function useWarmUpBrowser() {
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (Platform.OS !== "android") return;
|
|
39
|
+
void WebBrowser.warmUpAsync().catch(() => {});
|
|
40
|
+
return () => {
|
|
41
|
+
void WebBrowser.coolDownAsync().catch(() => {});
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Starts the Google flow, sets the session and navigates. Returns `[start, busy]`. */
|
|
47
|
+
export function useGoogleSignIn({
|
|
48
|
+
scheme,
|
|
49
|
+
redirectPath = "sso-callback",
|
|
50
|
+
afterAuthHref = "/",
|
|
51
|
+
replaceOptions,
|
|
52
|
+
onSuccess,
|
|
53
|
+
onError,
|
|
54
|
+
}: GoogleSignInOptions): [() => Promise<void>, boolean] {
|
|
55
|
+
const { startSSOFlow } = useSSO();
|
|
56
|
+
const router = useRouter();
|
|
57
|
+
const [busy, setBusy] = useState(false);
|
|
58
|
+
const start = async () => {
|
|
59
|
+
if (busy) return;
|
|
60
|
+
setBusy(true);
|
|
61
|
+
try {
|
|
62
|
+
const { createdSessionId, setActive } = await startSSOFlow({
|
|
63
|
+
strategy: "oauth_google",
|
|
64
|
+
redirectUrl: AuthSession.makeRedirectUri({ scheme, path: redirectPath }),
|
|
65
|
+
});
|
|
66
|
+
// No session: the user closed the browser, or more steps are needed.
|
|
67
|
+
if (createdSessionId && setActive) {
|
|
68
|
+
await setActive({ session: createdSessionId });
|
|
69
|
+
if (onSuccess) onSuccess();
|
|
70
|
+
else router.replace(afterAuthHref, replaceOptions);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const message = authErrorMessage(error, GOOGLE_ERROR_MESSAGE);
|
|
74
|
+
if (onError) onError(message);
|
|
75
|
+
else toast.error(message);
|
|
76
|
+
} finally {
|
|
77
|
+
setBusy(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return [start, busy];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type GoogleSignInButtonProps = GoogleSignInOptions &
|
|
84
|
+
Omit<ButtonProps, "onPress" | "children" | "leftIcon" | "loading"> & {
|
|
85
|
+
/** Default "Continue with Google". */
|
|
86
|
+
label?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** An outline button with the Google mark that runs `useGoogleSignIn`. */
|
|
90
|
+
export function GoogleSignInButton({
|
|
91
|
+
scheme,
|
|
92
|
+
redirectPath,
|
|
93
|
+
afterAuthHref,
|
|
94
|
+
replaceOptions,
|
|
95
|
+
onSuccess,
|
|
96
|
+
onError,
|
|
97
|
+
label = "Continue with Google",
|
|
98
|
+
testID = "google-sign-in",
|
|
99
|
+
variant = "outline",
|
|
100
|
+
...props
|
|
101
|
+
}: GoogleSignInButtonProps) {
|
|
102
|
+
useWarmUpBrowser();
|
|
103
|
+
const [start, busy] = useGoogleSignIn({ scheme, redirectPath, afterAuthHref, replaceOptions, onSuccess, onError });
|
|
104
|
+
return (
|
|
105
|
+
<Button
|
|
106
|
+
{...props}
|
|
107
|
+
testID={testID}
|
|
108
|
+
variant={variant}
|
|
109
|
+
leftIcon={<GoogleMark />}
|
|
110
|
+
loading={busy}
|
|
111
|
+
onPress={() => void start()}
|
|
112
|
+
>
|
|
113
|
+
{label}
|
|
114
|
+
</Button>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -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";
|
|
@@ -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";
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@vvedantb/native-ui/tailwind-preset`: the NativeWind / Tailwind preset the
|
|
3
|
+
* Expo apps share. CommonJS, because `tailwind.config.js` `require`s it and
|
|
4
|
+
* Tailwind loads it outside Metro.
|
|
5
|
+
*
|
|
6
|
+
* const { nativeUiPreset, nativeUiContent } = require("@vvedantb/native-ui/tailwind-preset");
|
|
7
|
+
* module.exports = {
|
|
8
|
+
* content: ["./app/**\/*.{js,ts,tsx}", nativeUiContent()],
|
|
9
|
+
* presets: [nativeUiPreset()],
|
|
10
|
+
* };
|
|
11
|
+
*
|
|
12
|
+
* It nests `nativewind/preset`, so callers pass only this one. It sets
|
|
13
|
+
* `darkMode`, the Instrument Sans font classes, the colour keys native-ui
|
|
14
|
+
* classes use, `borderWidth.hairline` and, when asked, `borderRadius` from
|
|
15
|
+
* `--radius`.
|
|
16
|
+
* App colours, display fonts, keyframes and font-weight plugins stay in the
|
|
17
|
+
* app config.
|
|
18
|
+
*
|
|
19
|
+
* `color` maps a CSS variable name (without `--`) to a Tailwind colour, as the
|
|
20
|
+
* web `tokensPreset({ color })` of `@vvedantb/ui`. With `injectCssVariables`,
|
|
21
|
+
* the preset also writes those variables (light and dark) from `tokens.ts` and
|
|
22
|
+
* `palette.ts`, as space-separated RGB channels, and the default `color` is
|
|
23
|
+
* `rgbAlpha`.
|
|
24
|
+
*/
|
|
25
|
+
"use strict";
|
|
26
|
+
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
const path = require("node:path");
|
|
29
|
+
|
|
30
|
+
/** `var(--name)`. */
|
|
31
|
+
const cssVar = (name) => `var(--${name})`;
|
|
32
|
+
/** `hsl(var(--name))`, for variables that hold `h s% l%` channels. */
|
|
33
|
+
const hslVar = (name) => `hsl(var(--${name}))`;
|
|
34
|
+
/** `rgb(var(--name) / <alpha-value>)`, for variables that hold `r g b` channels. Keeps opacity modifiers. */
|
|
35
|
+
const rgbAlpha = (name) => `rgb(var(--${name}) / <alpha-value>)`;
|
|
36
|
+
|
|
37
|
+
const INSTRUMENT_SANS = {
|
|
38
|
+
regular: "InstrumentSans_400Regular",
|
|
39
|
+
medium: "InstrumentSans_500Medium",
|
|
40
|
+
semibold: "InstrumentSans_600SemiBold",
|
|
41
|
+
bold: "InstrumentSans_700Bold",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The glob Tailwind scans for native-ui class names: this package's `src`, at its real path (pnpm symlinks resolve). */
|
|
45
|
+
function nativeUiContent() {
|
|
46
|
+
return path.join(__dirname, "**/*.{js,ts,tsx}").split(path.sep).join("/");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads one `export const name = { … } as const;` object from a sibling
|
|
51
|
+
* `.ts` file. Plain object literals only, so no TypeScript loader is needed:
|
|
52
|
+
* `tokens.ts` and `palette.ts` stay the single source of the colours.
|
|
53
|
+
*/
|
|
54
|
+
function readConst(file, name) {
|
|
55
|
+
const source = fs.readFileSync(path.join(__dirname, file), "utf8");
|
|
56
|
+
const match = source.match(new RegExp(`export const ${name} = (\\{[\\s\\S]*?\\n\\}) as const;`));
|
|
57
|
+
if (!match) throw new Error(`@vvedantb/native-ui/tailwind-preset: no \`${name}\` object in ${file}`);
|
|
58
|
+
return new Function(`return (${match[1]});`)();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cachedSchemeColors = null;
|
|
62
|
+
|
|
63
|
+
/** CSS variable name → `{ light, dark }` hex, from `tokens.ts` and `palette.ts`. */
|
|
64
|
+
function schemeColors() {
|
|
65
|
+
if (cachedSchemeColors) return cachedSchemeColors;
|
|
66
|
+
const tokens = readConst("tokens.ts", "tokens");
|
|
67
|
+
const palette = readConst("palette.ts", "palette");
|
|
68
|
+
const c = tokens.color;
|
|
69
|
+
cachedSchemeColors = {
|
|
70
|
+
background: c.background,
|
|
71
|
+
foreground: c.foreground,
|
|
72
|
+
border: c.border,
|
|
73
|
+
input: c.border,
|
|
74
|
+
ring: palette.primary,
|
|
75
|
+
separator: c.separator,
|
|
76
|
+
surface: c.surface,
|
|
77
|
+
"surface-foreground": c.foreground,
|
|
78
|
+
"surface-secondary": c.surfaceSecondary,
|
|
79
|
+
"surface-tertiary": palette.surfaceTertiary,
|
|
80
|
+
"surface-card": c.surfaceCard,
|
|
81
|
+
muted: c.muted,
|
|
82
|
+
"muted-foreground": c.muted,
|
|
83
|
+
primary: palette.primary,
|
|
84
|
+
"primary-foreground": palette.primaryForeground,
|
|
85
|
+
secondary: palette.default,
|
|
86
|
+
"secondary-foreground": palette.defaultForeground,
|
|
87
|
+
destructive: palette.danger,
|
|
88
|
+
"destructive-foreground": palette.dangerForeground,
|
|
89
|
+
accent: palette.primary,
|
|
90
|
+
"accent-foreground": palette.primaryForeground,
|
|
91
|
+
popover: palette.overlay,
|
|
92
|
+
"popover-foreground": palette.overlayForeground,
|
|
93
|
+
card: c.surfaceCard,
|
|
94
|
+
"card-foreground": c.foreground,
|
|
95
|
+
danger: palette.danger,
|
|
96
|
+
"danger-foreground": palette.dangerForeground,
|
|
97
|
+
success: palette.success,
|
|
98
|
+
"success-foreground": palette.successForeground,
|
|
99
|
+
warning: palette.warning,
|
|
100
|
+
"warning-foreground": palette.warningForeground,
|
|
101
|
+
default: palette.default,
|
|
102
|
+
"default-foreground": palette.defaultForeground,
|
|
103
|
+
overlay: palette.overlay,
|
|
104
|
+
"overlay-foreground": palette.overlayForeground,
|
|
105
|
+
field: c.surface,
|
|
106
|
+
"field-placeholder": palette.fieldPlaceholder,
|
|
107
|
+
};
|
|
108
|
+
return cachedSchemeColors;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `#rrggbb` as `r g b`. */
|
|
112
|
+
function channels(hex) {
|
|
113
|
+
const n = Number.parseInt(hex.slice(1, 7), 16);
|
|
114
|
+
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `--name: r g b` for every colour in one scheme. */
|
|
118
|
+
function cssVariables(scheme) {
|
|
119
|
+
return Object.fromEntries(
|
|
120
|
+
Object.entries(schemeColors()).map(([name, value]) => [`--${name}`, channels(value[scheme])]),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `--radius` from `tokens.radius.lg` (16), so `rounded-lg`, `-md` and `-sm` are 16, 14 and 12 as in `tokens.radius`. */
|
|
125
|
+
function radiusVariable() {
|
|
126
|
+
return { "--radius": `${readConst("tokens.ts", "tokens").radius.lg}px` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The Tailwind colour keys native-ui classes and the apps share. */
|
|
130
|
+
function themeColors(v) {
|
|
131
|
+
return {
|
|
132
|
+
background: v("background"),
|
|
133
|
+
foreground: v("foreground"),
|
|
134
|
+
border: v("border"),
|
|
135
|
+
input: v("input"),
|
|
136
|
+
ring: v("ring"),
|
|
137
|
+
separator: v("separator"),
|
|
138
|
+
surface: {
|
|
139
|
+
DEFAULT: v("surface"),
|
|
140
|
+
foreground: v("surface-foreground"),
|
|
141
|
+
secondary: v("surface-secondary"),
|
|
142
|
+
tertiary: v("surface-tertiary"),
|
|
143
|
+
card: v("surface-card"),
|
|
144
|
+
},
|
|
145
|
+
primary: { DEFAULT: v("primary"), foreground: v("primary-foreground") },
|
|
146
|
+
secondary: { DEFAULT: v("secondary"), foreground: v("secondary-foreground") },
|
|
147
|
+
destructive: { DEFAULT: v("destructive"), foreground: v("destructive-foreground") },
|
|
148
|
+
muted: { DEFAULT: v("muted"), foreground: v("muted-foreground") },
|
|
149
|
+
accent: { DEFAULT: v("accent"), foreground: v("accent-foreground") },
|
|
150
|
+
popover: { DEFAULT: v("popover"), foreground: v("popover-foreground") },
|
|
151
|
+
card: { DEFAULT: v("card"), foreground: v("card-foreground") },
|
|
152
|
+
danger: { DEFAULT: v("danger"), foreground: v("danger-foreground") },
|
|
153
|
+
success: { DEFAULT: v("success"), foreground: v("success-foreground") },
|
|
154
|
+
warning: { DEFAULT: v("warning"), foreground: v("warning-foreground") },
|
|
155
|
+
default: { DEFAULT: v("default"), foreground: v("default-foreground") },
|
|
156
|
+
overlay: { DEFAULT: v("overlay"), foreground: v("overlay-foreground") },
|
|
157
|
+
field: { DEFAULT: v("field"), placeholder: v("field-placeholder") },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** `nativewind/preset`, or none where NativeWind is not installed (this repository's tests). */
|
|
162
|
+
function nestedPresets() {
|
|
163
|
+
try {
|
|
164
|
+
return [require("nativewind/preset")];
|
|
165
|
+
} catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `{ hairline: hairlineWidth() }` from `nativewind/theme`, or none without NativeWind. */
|
|
171
|
+
function hairlineBorder() {
|
|
172
|
+
try {
|
|
173
|
+
const { hairlineWidth } = require("nativewind/theme");
|
|
174
|
+
return typeof hairlineWidth === "function" ? { hairline: hairlineWidth() } : {};
|
|
175
|
+
} catch {
|
|
176
|
+
return {};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A Tailwind plugin that writes the colour variables: `:root` (light) and the dark set for `darkMode`. */
|
|
181
|
+
function cssVariablesPlugin(darkMode) {
|
|
182
|
+
return function nativeUiCssVariables({ addBase }) {
|
|
183
|
+
const dark = cssVariables("dark");
|
|
184
|
+
addBase(
|
|
185
|
+
darkMode === "media"
|
|
186
|
+
? {
|
|
187
|
+
":root": { ...cssVariables("light"), ...radiusVariable() },
|
|
188
|
+
"@media (prefers-color-scheme: dark)": { ":root": dark },
|
|
189
|
+
}
|
|
190
|
+
: {
|
|
191
|
+
":root": { ...cssVariables("light"), ...radiusVariable() },
|
|
192
|
+
".dark:root, .dark": dark,
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The preset. Options:
|
|
200
|
+
* - `darkMode`: `"class"` (default; `ThemeToggle` and `setColorScheme` need it) or `"media"`.
|
|
201
|
+
* - `color`: CSS variable name → colour value. Default `cssVar`, or `rgbAlpha` with `injectCssVariables`.
|
|
202
|
+
* - `injectCssVariables`: write the colour variables and `--radius` from `tokens.ts` and `palette.ts`. Default `false`.
|
|
203
|
+
* - `radiusVariable`: `rounded-lg`, `-md` and `-sm` from `var(--radius)`, as vibot and Velth. Default: on with
|
|
204
|
+
* `injectCssVariables`, else off, so an app without `--radius` keeps the Tailwind radii.
|
|
205
|
+
*/
|
|
206
|
+
function nativeUiPreset(options = {}) {
|
|
207
|
+
const darkMode = options.darkMode === "media" ? "media" : "class";
|
|
208
|
+
const inject = options.injectCssVariables === true;
|
|
209
|
+
const color = options.color || (inject ? rgbAlpha : cssVar);
|
|
210
|
+
const radius = options.radiusVariable ?? inject;
|
|
211
|
+
return {
|
|
212
|
+
darkMode,
|
|
213
|
+
presets: nestedPresets(),
|
|
214
|
+
theme: {
|
|
215
|
+
extend: {
|
|
216
|
+
colors: themeColors(color),
|
|
217
|
+
fontFamily: {
|
|
218
|
+
sans: [INSTRUMENT_SANS.regular],
|
|
219
|
+
"instrument-sans": [INSTRUMENT_SANS.regular],
|
|
220
|
+
"instrument-sans-medium": [INSTRUMENT_SANS.medium],
|
|
221
|
+
"instrument-sans-semibold": [INSTRUMENT_SANS.semibold],
|
|
222
|
+
"instrument-sans-bold": [INSTRUMENT_SANS.bold],
|
|
223
|
+
},
|
|
224
|
+
borderRadius: radius
|
|
225
|
+
? {
|
|
226
|
+
lg: "var(--radius)",
|
|
227
|
+
md: "calc(var(--radius) - 2px)",
|
|
228
|
+
sm: "calc(var(--radius) - 4px)",
|
|
229
|
+
}
|
|
230
|
+
: {},
|
|
231
|
+
borderWidth: hairlineBorder(),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
plugins: inject ? [cssVariablesPlugin(darkMode)] : [],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
nativeUiPreset,
|
|
240
|
+
nativeUiContent,
|
|
241
|
+
cssVar,
|
|
242
|
+
hslVar,
|
|
243
|
+
rgbAlpha,
|
|
244
|
+
cssVariables,
|
|
245
|
+
schemeColors,
|
|
246
|
+
INSTRUMENT_SANS,
|
|
247
|
+
};
|
|
248
|
+
module.exports.default = nativeUiPreset();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Maps a CSS variable name (without `--`) to a Tailwind colour value. */
|
|
2
|
+
export type ColorFn = (name: string) => string;
|
|
3
|
+
|
|
4
|
+
type Colors = { [key: string]: string | Colors };
|
|
5
|
+
type CssDeclarations = Record<string, string>;
|
|
6
|
+
type AddBase = (base: Record<string, CssDeclarations | Record<string, CssDeclarations>>) => void;
|
|
7
|
+
|
|
8
|
+
export interface NativeUiPresetOptions {
|
|
9
|
+
/** Default `"class"`: `ThemeToggle` and NativeWind `setColorScheme` need it. */
|
|
10
|
+
darkMode?: "class" | "media";
|
|
11
|
+
/** Default `cssVar`, or `rgbAlpha` with `injectCssVariables`. */
|
|
12
|
+
color?: ColorFn;
|
|
13
|
+
/** Write the colour variables (as `r g b` channels) and `--radius` from `tokens.ts` and `palette.ts`. Default `false`. */
|
|
14
|
+
injectCssVariables?: boolean;
|
|
15
|
+
/** `rounded-lg`, `-md` and `-sm` from `var(--radius)`. Default: the value of `injectCssVariables`. */
|
|
16
|
+
radiusVariable?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NativeUiPreset {
|
|
20
|
+
darkMode: "class" | "media";
|
|
21
|
+
presets: unknown[];
|
|
22
|
+
theme: {
|
|
23
|
+
extend: {
|
|
24
|
+
colors: Colors;
|
|
25
|
+
fontFamily: Record<string, string[]>;
|
|
26
|
+
borderRadius: Record<string, string>;
|
|
27
|
+
borderWidth: Record<string, unknown>;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
plugins: Array<(api: { addBase: AddBase }) => void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export declare const cssVar: ColorFn;
|
|
34
|
+
export declare const hslVar: ColorFn;
|
|
35
|
+
export declare const rgbAlpha: ColorFn;
|
|
36
|
+
export declare const INSTRUMENT_SANS: {
|
|
37
|
+
regular: "InstrumentSans_400Regular";
|
|
38
|
+
medium: "InstrumentSans_500Medium";
|
|
39
|
+
semibold: "InstrumentSans_600SemiBold";
|
|
40
|
+
bold: "InstrumentSans_700Bold";
|
|
41
|
+
};
|
|
42
|
+
export declare function nativeUiPreset(options?: NativeUiPresetOptions): NativeUiPreset;
|
|
43
|
+
/** Absolute glob of this package's `src/**\/*.{js,ts,tsx}`, for Tailwind `content`. */
|
|
44
|
+
export declare function nativeUiContent(): string;
|
|
45
|
+
/** CSS variable name → `{ light, dark }` hex, from `tokens.ts` and `palette.ts`. */
|
|
46
|
+
export declare function schemeColors(): Record<string, { light: string; dark: string }>;
|
|
47
|
+
/** `{ "--name": "r g b" }` for one scheme. */
|
|
48
|
+
export declare function cssVariables(scheme: "light" | "dark"): CssDeclarations;
|
|
49
|
+
declare const preset: NativeUiPreset;
|
|
50
|
+
export default preset;
|