@vvedantb/native-ui 0.11.2 → 0.12.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 +38 -2
- package/package.json +5 -1
- package/src/ArtifactList.tsx +38 -0
- package/src/PinnedBotTile.tsx +88 -0
- package/src/ScreenContainer.tsx +31 -3
- package/src/StackPageHeader.tsx +24 -2
- package/src/ai/attachments.tsx +47 -12
- package/src/ai/chat-jump-rail.tsx +38 -0
- package/src/ai/chat-thread.tsx +96 -0
- package/src/ai/index.ts +5 -0
- package/src/auth/GoogleSignInButton.tsx +3 -3
- package/src/auth/SignInScreen.tsx +3 -3
- package/src/entries/expo-router.ts +5 -0
- package/src/entries/layout.ts +7 -0
- package/src/index.ts +7 -0
- package/src/router/RouterStackPageHeader.tsx +25 -0
- package/src/settings/SettingsEmptyState.tsx +44 -0
package/README.md
CHANGED
|
@@ -72,16 +72,17 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
|
|
|
72
72
|
| `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
|
|
73
73
|
| `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `configureTheme` (with the `ThemeConfig`, `ThemeColors` and `ThemePalette` types), `fontClass`, `cn`, `Colors` and the colour constants | none |
|
|
74
74
|
| `@vvedantb/native-ui/floating` | `FloatingSurface`, `GlassSheetBackground`, `LiquidGlass`, `GlassBlurProvider`, `GlassBlurTarget`, `FloatingFAB`, `useReduceTransparency`, the floating metrics and styles | `expo-blur` (loader) |
|
|
75
|
-
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader`, `StackPageHeader` (0.5.0) and their props types, `SectionLabel` (0.5.0), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `MoreFromProjectV` and `FromProjectV` (0.8.0) | `expo-blur` (loader) |
|
|
75
|
+
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader`, `StackPageHeader` (0.5.0) and their props types, `SectionLabel` (0.5.0), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `MoreFromProjectV` and `FromProjectV` (0.8.0), `ScreenContainerProps`, `SettingsEmptyState`, `PinnedBotTile`, `BotPinGrid`, `ArtifactList` (0.12.0). `ScreenContainer` takes `refreshing`, `onRefresh` and `refreshControl` (0.12.0). `StackPageHeader` takes `centre` (0.12.0) | `expo-blur` (loader) |
|
|
76
76
|
| `@vvedantb/native-ui/marketing` | `UpgradeToProCard`, `PricingModal` and the pricing types | none |
|
|
77
77
|
| `@vvedantb/native-ui/ambient` | `AmbientBackground` | `expo-linear-gradient` (loader) |
|
|
78
78
|
| `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
|
|
79
79
|
| `@vvedantb/native-ui/push` | `useExpoPushRegistration`, `getExpoPushToken`, `showPushesInForeground`, the three loaders and the types (0.9.0). Pass `register` from your backend (for `@vvedantb/server`: `useMutation(api.push.register)`) and route taps in `onNotificationTap` | `expo-notifications`, `expo-constants`, `expo-device` (loaders) |
|
|
80
80
|
| `@vvedantb/native-ui/bottom-sheet` | `BottomSheetContainer` | `@gorhom/bottom-sheet` (import), `expo-blur` (loader) |
|
|
81
81
|
| `@vvedantb/native-ui/date-time` | `DateTimeField`, `DateTimeFieldProps` (0.5.0) | `@react-native-community/datetimepicker`, `dayjs` (import) |
|
|
82
|
-
| `@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 |
|
|
82
|
+
| `@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.. From 0.12.0: `ChatThread`, `UIMessage`, `ChatJumpRail` and the `Attachment` props `onPress` and `accessibilityLabel` | none |
|
|
83
83
|
| `@vvedantb/native-ui/ai-gallery` | `AiChatGallery`, `AiChatSamples` (from 0.4.0) | none |
|
|
84
84
|
| `@vvedantb/native-ui/auth` | From 0.7.0: `AuthProviders`, `SignInScreen`, `SignUpScreen`, `GoogleSignInButton`, `useGoogleSignIn`, `AuthScreen`, `OrDivider`, `FieldError`, `authErrorMessage`, `SecondFactorScreen`, `useSecondFactor`, `submitTotpSecondFactor`, `submitSecondFactor`, `prepareSecondFactorIfNeeded`, `prepareFirstFactorIfNeeded`, `submitFirstFactor`, `pickFirstFactorStrategy`, `TOTP_MESSAGES` 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) |
|
|
85
|
+
| `@vvedantb/native-ui/expo-router` | From 0.12.0: `RouterStackPageHeader`, `RouterStackPageHeaderProps`, `goBackOrReplace`. Not on the root, `/layout` or `/page-title` entries. | `expo-router` (import) |
|
|
85
86
|
| `@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`) |
|
|
86
87
|
| `@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) |
|
|
87
88
|
|
|
@@ -426,6 +427,41 @@ Components and props that the Verve, vibot and Velth apps need. They follow the
|
|
|
426
427
|
</AmbientBackground>
|
|
427
428
|
```
|
|
428
429
|
|
|
430
|
+
### 0.12.0 additions
|
|
431
|
+
|
|
432
|
+
```tsx
|
|
433
|
+
// Typed routes: `afterAuthHref`, `signUpHref` and `signInHref` take expo-router's `Href`,
|
|
434
|
+
// and `replaceOptions` takes `Parameters<Router["replace"]>[1]`.
|
|
435
|
+
<SignInScreen scheme="app" afterAuthHref="/(app)/bots" signUpHref="/sign-up" replaceOptions={{ withAnchor: true }} />
|
|
436
|
+
|
|
437
|
+
// Router-aware back: router.canGoBack() ? router.back() : router.replace(fallbackHref).
|
|
438
|
+
import { RouterStackPageHeader } from "@vvedantb/native-ui/expo-router";
|
|
439
|
+
<RouterStackPageHeader title="Bot" fallbackHref="/bots" backTestID="screen-back" centre={<BotTitle />} />
|
|
440
|
+
|
|
441
|
+
// An attachment chip that opens its file. The remove button stays separate.
|
|
442
|
+
<Attachment data={file} onPress={() => open(file.url)} onRemove={() => remove(file.id)} />
|
|
443
|
+
|
|
444
|
+
// The chat column as one piece.
|
|
445
|
+
<ChatThread
|
|
446
|
+
messages={messages}
|
|
447
|
+
working={status === "streaming"}
|
|
448
|
+
renderMessage={(message, isStreaming) => <MessageItem message={message} streaming={isStreaming} />}
|
|
449
|
+
thinking={<Shimmer>Thinking</Shimmer>}
|
|
450
|
+
footer={<PromptInput onSubmit={send} status={status} />}
|
|
451
|
+
contentContainerClassName="px-4 py-4"
|
|
452
|
+
/>
|
|
453
|
+
|
|
454
|
+
// A two-across grid of pinned bots.
|
|
455
|
+
<BotPinGrid>
|
|
456
|
+
<PinnedBotTile title="Research" leading={<Avatar />} unread onPress={open} onLongPress={showActions} />
|
|
457
|
+
</BotPinGrid>
|
|
458
|
+
|
|
459
|
+
// Pull to refresh. `refreshing` is controlled. No control without `onRefresh` or `refreshControl`.
|
|
460
|
+
<ScreenContainer refreshing={refreshing} onRefresh={reload}>{children}</ScreenContainer>
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
`StackPageHeader` `centre` replaces the centre `PageTitle` (for example a 20 px avatar beside a name). `title` stays the accessible name and the fallback; `description` still shows under it. The two sides keep the same width and the centre shrinks and truncates. `RouterStackPageHeader` takes the `StackPageHeader` props without `onBack`, plus `fallbackHref` (default `"/"`). `ChatThread` shows `thinking` right after the last user message while `working` and marks the last assistant message as streaming. A `PromptInput` footer keeps its own keyboard lift; the outer `KeyboardAvoidingView` moves it clear first, so the lift is not doubled. `SettingsEmptyState` is a compact empty note for the inside of a `SettingsSection`; `ArtifactList` lists artifacts as pressable rows; `ChatJumpRail` is a horizontal rail of chips that jump between messages.
|
|
464
|
+
|
|
429
465
|
`Text` from `@vvedantb/native-ui/primitives` is the variant text (`h1`–`h4`, `p`, `lead`, `large`, `small`, `muted`, `code`, `blockquote`). It is not on the root entry. Unlike the components, it takes its colour from NativeWind classes (`text-foreground`, `text-muted-foreground`); where the app defines `foreground` with a nested `dark` key, give it `style={{ color: tokens.color.foreground[scheme] }}` in dark mode.
|
|
430
466
|
|
|
431
467
|
## AI chat (0.4.0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"license": "MIT",
|
|
5
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",
|
|
@@ -109,6 +109,10 @@
|
|
|
109
109
|
"types": "./src/entries/markdown.ts",
|
|
110
110
|
"default": "./src/entries/markdown.ts"
|
|
111
111
|
},
|
|
112
|
+
"./expo-router": {
|
|
113
|
+
"types": "./src/entries/expo-router.ts",
|
|
114
|
+
"default": "./src/entries/expo-router.ts"
|
|
115
|
+
},
|
|
112
116
|
"./expo-web-root": {
|
|
113
117
|
"types": "./src/entries/expo-web-root.ts",
|
|
114
118
|
"default": "./src/entries/expo-web-root.ts"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { DividedList } from "./DividedList";
|
|
4
|
+
import { ListRow } from "./ListRow";
|
|
5
|
+
|
|
6
|
+
export type ArtifactRow = { id: string; title: string; description?: string };
|
|
7
|
+
|
|
8
|
+
export type ArtifactListProps = {
|
|
9
|
+
rows: ArtifactRow[];
|
|
10
|
+
/** Called with the `id` of the pressed row. */
|
|
11
|
+
onOpen: (id: string) => void;
|
|
12
|
+
/** Shown when `rows` is empty, such as an `EmptyState`. Nothing without it. */
|
|
13
|
+
empty?: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
testID?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Artifacts as pressable `ListRow`s under hairlines (`DividedList`): the title
|
|
20
|
+
* and an optional description line. A row is a button named by its title.
|
|
21
|
+
* `empty` stands in for an empty list.
|
|
22
|
+
*/
|
|
23
|
+
export function ArtifactList({ rows, onOpen, empty, className, testID }: ArtifactListProps) {
|
|
24
|
+
if (rows.length === 0) return empty != null ? <View testID={testID}>{empty}</View> : null;
|
|
25
|
+
return (
|
|
26
|
+
<DividedList className={className} testID={testID}>
|
|
27
|
+
{rows.map((row) => (
|
|
28
|
+
<ListRow
|
|
29
|
+
key={row.id}
|
|
30
|
+
testID={`artifact-${row.id}`}
|
|
31
|
+
title={row.title}
|
|
32
|
+
meta={row.description}
|
|
33
|
+
onPress={() => onOpen(row.id)}
|
|
34
|
+
/>
|
|
35
|
+
))}
|
|
36
|
+
</DividedList>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Children, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, Text, View, type ViewProps } from "react-native";
|
|
3
|
+
import { cn } from "./cn";
|
|
4
|
+
import { cardStyle, textStyle, typeStyle } from "./controlStyles";
|
|
5
|
+
import { StatusDot } from "./StatusDot";
|
|
6
|
+
import { Spinner } from "./Spinner";
|
|
7
|
+
import { useLargerText } from "./theme";
|
|
8
|
+
import { useScheme } from "./useScheme";
|
|
9
|
+
|
|
10
|
+
export type PinnedBotTileProps = {
|
|
11
|
+
/** The bot name, under the avatar. Up to two lines. */
|
|
12
|
+
title: string;
|
|
13
|
+
/** The avatar, above the name. */
|
|
14
|
+
leading: ReactNode;
|
|
15
|
+
/** Shows an unread dot at the top start and adds "unread" to the label. */
|
|
16
|
+
unread?: boolean;
|
|
17
|
+
/** Shows a spinner at the top end and adds "generating" to the label. It can show with the unread dot. */
|
|
18
|
+
generating?: boolean;
|
|
19
|
+
onPress: () => void;
|
|
20
|
+
/** Opens actions on a long press. */
|
|
21
|
+
onLongPress?: () => void;
|
|
22
|
+
testID?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A large pinned-bot tile: the avatar above the name on a `Card` fill, an
|
|
27
|
+
* unread dot or a generating spinner at the top end. At least 112 high, so it
|
|
28
|
+
* is a comfortable touch target in a `BotPinGrid`. The tile is one button named
|
|
29
|
+
* by the title, plus "unread" or "generating".
|
|
30
|
+
*/
|
|
31
|
+
export function PinnedBotTile({ title, leading, unread = false, generating = false, onPress, onLongPress, testID }: PinnedBotTileProps) {
|
|
32
|
+
const scheme = useScheme();
|
|
33
|
+
const largerText = useLargerText();
|
|
34
|
+
const state = [unread ? "unread" : null, generating ? "generating" : null].filter(Boolean).join(", ");
|
|
35
|
+
return (
|
|
36
|
+
<Pressable
|
|
37
|
+
testID={testID}
|
|
38
|
+
accessibilityRole="button"
|
|
39
|
+
accessibilityLabel={state ? `${title}, ${state}` : title}
|
|
40
|
+
onPress={onPress}
|
|
41
|
+
onLongPress={onLongPress}
|
|
42
|
+
className={cn("min-w-0 items-center justify-center gap-2 px-3 py-4 active:opacity-70", largerText ? "min-h-36" : "min-h-28")}
|
|
43
|
+
style={cardStyle(scheme)}
|
|
44
|
+
>
|
|
45
|
+
{leading}
|
|
46
|
+
<Text
|
|
47
|
+
numberOfLines={2}
|
|
48
|
+
className={cn("text-center font-instrument-sans-semibold", largerText ? "text-lg" : "text-base")}
|
|
49
|
+
style={[textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
50
|
+
>
|
|
51
|
+
{title}
|
|
52
|
+
</Text>
|
|
53
|
+
{unread ? (
|
|
54
|
+
<View className="absolute left-3 top-3" testID={testID ? `${testID}-unread` : undefined}>
|
|
55
|
+
<StatusDot tone="active" size="md" />
|
|
56
|
+
</View>
|
|
57
|
+
) : null}
|
|
58
|
+
{generating ? (
|
|
59
|
+
<View className="absolute right-3 top-3" testID={testID ? `${testID}-generating` : undefined}>
|
|
60
|
+
<Spinner size="sm" decorative />
|
|
61
|
+
</View>
|
|
62
|
+
) : null}
|
|
63
|
+
</Pressable>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type BotPinGridProps = ViewProps & {
|
|
68
|
+
/** `PinnedBotTile`s. */
|
|
69
|
+
children?: ReactNode;
|
|
70
|
+
className?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lays `PinnedBotTile`s out two across, wrapping to new rows, with a 12 gap. A
|
|
75
|
+
* lone last tile keeps half the width. Each child gets a half-width cell.
|
|
76
|
+
*/
|
|
77
|
+
export function BotPinGrid({ children, className, ...props }: BotPinGridProps) {
|
|
78
|
+
const cells = Children.toArray(children);
|
|
79
|
+
return (
|
|
80
|
+
<View className={cn("flex-row flex-wrap gap-3", className)} {...props}>
|
|
81
|
+
{cells.map((child, index) => (
|
|
82
|
+
<View key={(child as { key?: string | null }).key ?? index} className="min-w-0" style={{ flexBasis: "47%", flexGrow: 1, maxWidth: "50%" }}>
|
|
83
|
+
{child}
|
|
84
|
+
</View>
|
|
85
|
+
))}
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
package/src/ScreenContainer.tsx
CHANGED
|
@@ -1,25 +1,52 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
RefreshControl,
|
|
3
|
+
ScrollView,
|
|
4
|
+
View,
|
|
5
|
+
type RefreshControlProps,
|
|
6
|
+
type ViewProps,
|
|
7
|
+
} from "react-native";
|
|
2
8
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
3
|
-
import { useLargerText } from "./theme";
|
|
9
|
+
import { useAccentColor, useLargerText } from "./theme";
|
|
4
10
|
import { tabBarReserve } from "./floating";
|
|
5
11
|
|
|
6
|
-
interface ScreenContainerProps extends ViewProps {
|
|
12
|
+
export interface ScreenContainerProps extends ViewProps {
|
|
7
13
|
children: React.ReactNode;
|
|
8
14
|
reserveTabBar?: boolean;
|
|
9
15
|
footer?: React.ReactNode;
|
|
16
|
+
/** Shows the pull-to-refresh spinner while true. Controlled: no timer, no minimum duration. */
|
|
17
|
+
refreshing?: boolean;
|
|
18
|
+
/** Called on pull. May return a promise. Adds a `RefreshControl` tinted with the accent colour. */
|
|
19
|
+
onRefresh?: () => void | Promise<void>;
|
|
20
|
+
/** Full control. Takes precedence over `refreshing` and `onRefresh`. */
|
|
21
|
+
refreshControl?: React.ReactElement<RefreshControlProps>;
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
export function ScreenContainer({
|
|
13
25
|
children,
|
|
14
26
|
reserveTabBar = false,
|
|
15
27
|
footer,
|
|
28
|
+
refreshing = false,
|
|
29
|
+
onRefresh,
|
|
30
|
+
refreshControl,
|
|
16
31
|
...props
|
|
17
32
|
}: ScreenContainerProps) {
|
|
18
33
|
const insets = useSafeAreaInsets();
|
|
19
34
|
const largerText = useLargerText();
|
|
35
|
+
const accent = useAccentColor();
|
|
20
36
|
const bottomPad = reserveTabBar
|
|
21
37
|
? tabBarReserve(largerText, insets.bottom)
|
|
22
38
|
: 40;
|
|
39
|
+
// No control unless the app asks for one.
|
|
40
|
+
const control =
|
|
41
|
+
refreshControl ??
|
|
42
|
+
(onRefresh ? (
|
|
43
|
+
<RefreshControl
|
|
44
|
+
refreshing={refreshing}
|
|
45
|
+
onRefresh={() => void onRefresh()}
|
|
46
|
+
tintColor={accent}
|
|
47
|
+
colors={[accent]}
|
|
48
|
+
/>
|
|
49
|
+
) : undefined);
|
|
23
50
|
|
|
24
51
|
return (
|
|
25
52
|
<View className="flex-1 bg-transparent" {...props}>
|
|
@@ -27,6 +54,7 @@ export function ScreenContainer({
|
|
|
27
54
|
showsVerticalScrollIndicator={false}
|
|
28
55
|
className="flex-1"
|
|
29
56
|
contentContainerStyle={{ paddingBottom: bottomPad }}
|
|
57
|
+
refreshControl={control}
|
|
30
58
|
>
|
|
31
59
|
<View className="flex-col gap-4 p-4">{children}</View>
|
|
32
60
|
{footer ? <View className="pt-4">{footer}</View> : null}
|
package/src/StackPageHeader.tsx
CHANGED
|
@@ -11,7 +11,15 @@ import { tokens } from "./tokens";
|
|
|
11
11
|
import { useScheme } from "./useScheme";
|
|
12
12
|
|
|
13
13
|
export type StackPageHeaderProps = {
|
|
14
|
+
/** The accessible name of the header, and the centre text when `centre` is not set. */
|
|
14
15
|
title: string;
|
|
16
|
+
/**
|
|
17
|
+
* Custom content for the centre, such as a 20 px avatar beside a name. It
|
|
18
|
+
* replaces the `PageTitle`. `title` stays the accessible name. Wrap long
|
|
19
|
+
* text in a `PageTitle` (or `Text` with `numberOfLines`) so it can truncate:
|
|
20
|
+
* the centre shrinks (`min-w-0`) and clips what does not fit.
|
|
21
|
+
*/
|
|
22
|
+
centre?: ReactNode;
|
|
15
23
|
/** Muted line under the title, up to two lines. */
|
|
16
24
|
description?: string;
|
|
17
25
|
/** Shows a back `HeaderIconButton` at the start that calls this. No button without it. */
|
|
@@ -36,11 +44,14 @@ export type StackPageHeaderProps = {
|
|
|
36
44
|
* a centred `PageTitle` (Instrument Sans 600 at 20 / 25) with an optional
|
|
37
45
|
* muted description. The two sides are the same width, the wider of the back
|
|
38
46
|
* button and `headerRight`, so the title stays centred on the screen and a
|
|
39
|
-
* long one is cut off before it reaches them.
|
|
47
|
+
* long one is cut off before it reaches them. `centre` (0.12.0) replaces the
|
|
48
|
+
* `PageTitle` with custom content; `title` then names the header for
|
|
49
|
+
* VoiceOver and TalkBack. The row is at least 56 high
|
|
40
50
|
* below the top inset, as `PageHeader`, with the `px-4` screen padding.
|
|
41
51
|
*/
|
|
42
52
|
export function StackPageHeader({
|
|
43
53
|
title,
|
|
54
|
+
centre,
|
|
44
55
|
description,
|
|
45
56
|
onBack,
|
|
46
57
|
backLabel = "Back",
|
|
@@ -74,7 +85,18 @@ export function StackPageHeader({
|
|
|
74
85
|
) : null}
|
|
75
86
|
</View>
|
|
76
87
|
<View className="min-w-0 shrink items-center py-2">
|
|
77
|
-
|
|
88
|
+
{centre != null ? (
|
|
89
|
+
<View
|
|
90
|
+
accessible
|
|
91
|
+
accessibilityRole="header"
|
|
92
|
+
accessibilityLabel={title}
|
|
93
|
+
className="min-w-0 max-w-full shrink flex-row items-center justify-center"
|
|
94
|
+
>
|
|
95
|
+
{centre}
|
|
96
|
+
</View>
|
|
97
|
+
) : (
|
|
98
|
+
<PageTitle className="text-center">{title}</PageTitle>
|
|
99
|
+
)}
|
|
78
100
|
{description ? (
|
|
79
101
|
<Text
|
|
80
102
|
numberOfLines={2}
|
package/src/ai/attachments.tsx
CHANGED
|
@@ -88,18 +88,31 @@ export type AttachmentProps = {
|
|
|
88
88
|
data: AttachmentData;
|
|
89
89
|
/** Shows a remove button, labelled "Remove" and the attachment name. */
|
|
90
90
|
onRemove?: () => void;
|
|
91
|
+
/**
|
|
92
|
+
* Makes the chip a button, such as to open the file. The remove button stays a
|
|
93
|
+
* separate target and does not call this. Without it the chip is not pressable.
|
|
94
|
+
*/
|
|
95
|
+
onPress?: () => void;
|
|
96
|
+
/** Names the chip button, with `onPress`. Default the attachment name (`getAttachmentLabel`). */
|
|
97
|
+
accessibilityLabel?: string;
|
|
91
98
|
testID?: string;
|
|
92
99
|
};
|
|
93
100
|
|
|
101
|
+
/** The hit target of a pressable chip, in points, on each axis. */
|
|
102
|
+
export const ATTACHMENT_PRESS_TARGET = 48;
|
|
103
|
+
|
|
94
104
|
/** A remove cross of 20 in a 32 row: the slop brings it to 44. */
|
|
95
105
|
const REMOVE_SIZE = 20;
|
|
96
106
|
|
|
97
107
|
/**
|
|
98
108
|
* One attachment as a chip: a 20 thumbnail for an image with a `url`, else
|
|
99
109
|
* its media icon, then the name and an optional remove button. Flat, on the
|
|
100
|
-
* web `flatPanelSubtle` fill (`bg-surface-secondary`). No hover card.
|
|
110
|
+
* web `flatPanelSubtle` fill (`bg-surface-secondary`). No hover card. With
|
|
111
|
+
* `onPress` the thumbnail or icon and the name are one button with a 48 point
|
|
112
|
+
* hit target (the chip stays 32, or 44 with larger text, and `hitSlop` does the
|
|
113
|
+
* rest); the remove button is a separate target that never calls `onPress`.
|
|
101
114
|
*/
|
|
102
|
-
export function Attachment({ data, onRemove, testID }: AttachmentProps) {
|
|
115
|
+
export function Attachment({ data, onRemove, onPress, accessibilityLabel, testID }: AttachmentProps) {
|
|
103
116
|
const variant = useContext(AttachmentsContext);
|
|
104
117
|
const scheme = useScheme();
|
|
105
118
|
const largerText = useLargerText();
|
|
@@ -108,16 +121,8 @@ export function Attachment({ data, onRemove, testID }: AttachmentProps) {
|
|
|
108
121
|
const Icon = MEDIA_ICONS[category];
|
|
109
122
|
const iconSize = largerText ? 20 : 16;
|
|
110
123
|
const muted = tokens.color.muted[scheme];
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
testID={testID}
|
|
114
|
-
className={cn(
|
|
115
|
-
"min-w-0 flex-row items-center gap-1.5 px-2",
|
|
116
|
-
largerText ? "min-h-11" : "min-h-8",
|
|
117
|
-
variant === "list" ? "w-full" : "max-w-full",
|
|
118
|
-
)}
|
|
119
|
-
style={{ borderRadius: tokens.radius.sm, backgroundColor: tokens.color.surfaceSecondary[scheme] }}
|
|
120
|
-
>
|
|
124
|
+
const content = (
|
|
125
|
+
<>
|
|
121
126
|
{category === "image" && data.url ? (
|
|
122
127
|
<Image
|
|
123
128
|
source={{ uri: data.url }}
|
|
@@ -136,6 +141,36 @@ export function Attachment({ data, onRemove, testID }: AttachmentProps) {
|
|
|
136
141
|
>
|
|
137
142
|
{label}
|
|
138
143
|
</ResponseText>
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
const chipHeight = largerText ? 44 : 32;
|
|
147
|
+
const slop = Math.max(0, Math.ceil((ATTACHMENT_PRESS_TARGET - chipHeight) / 2));
|
|
148
|
+
return (
|
|
149
|
+
<View
|
|
150
|
+
testID={testID}
|
|
151
|
+
className={cn(
|
|
152
|
+
"min-w-0 flex-row items-center gap-1.5 px-2",
|
|
153
|
+
largerText ? "min-h-11" : "min-h-8",
|
|
154
|
+
variant === "list" ? "w-full" : "max-w-full",
|
|
155
|
+
)}
|
|
156
|
+
style={{ borderRadius: tokens.radius.sm, backgroundColor: tokens.color.surfaceSecondary[scheme] }}
|
|
157
|
+
>
|
|
158
|
+
{onPress ? (
|
|
159
|
+
<Pressable
|
|
160
|
+
accessibilityRole="button"
|
|
161
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
162
|
+
hitSlop={{ top: slop, bottom: slop, left: 0, right: 0 }}
|
|
163
|
+
onPress={onPress}
|
|
164
|
+
className={cn(
|
|
165
|
+
"min-w-0 flex-row items-center gap-1.5 self-stretch active:opacity-70",
|
|
166
|
+
variant === "list" ? "flex-1" : "shrink",
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
{content}
|
|
170
|
+
</Pressable>
|
|
171
|
+
) : (
|
|
172
|
+
content
|
|
173
|
+
)}
|
|
139
174
|
{onRemove ? (
|
|
140
175
|
<Pressable
|
|
141
176
|
accessibilityRole="button"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ScrollView } from "react-native";
|
|
2
|
+
import { Chip } from "../Chip";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
|
|
5
|
+
export type ChatJumpRailItem = { id: string; label: string };
|
|
6
|
+
|
|
7
|
+
export type ChatJumpRailProps = {
|
|
8
|
+
/** The messages to jump between, such as the user messages of a long thread. */
|
|
9
|
+
items: ChatJumpRailItem[];
|
|
10
|
+
/** Called with the `id` of the pressed item. */
|
|
11
|
+
onJump: (id: string) => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
testID?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A rail to jump between messages (web `chat-jump-rail`): a horizontal row of
|
|
18
|
+
* chips, one per item. Each chip is a 48 high button named by its label. Draws
|
|
19
|
+
* nothing for an empty list.
|
|
20
|
+
*/
|
|
21
|
+
export function ChatJumpRail({ items, onJump, className, testID }: ChatJumpRailProps) {
|
|
22
|
+
if (items.length === 0) return null;
|
|
23
|
+
return (
|
|
24
|
+
<ScrollView
|
|
25
|
+
testID={testID}
|
|
26
|
+
horizontal
|
|
27
|
+
showsHorizontalScrollIndicator={false}
|
|
28
|
+
keyboardShouldPersistTaps="handled"
|
|
29
|
+
accessibilityRole="toolbar"
|
|
30
|
+
className={cn("grow-0", className)}
|
|
31
|
+
contentContainerClassName="gap-2 px-3 py-2"
|
|
32
|
+
>
|
|
33
|
+
{items.map((item) => (
|
|
34
|
+
<Chip key={item.id} label={item.label} selected={false} onPress={() => onJump(item.id)} testID={`jump-${item.id}`} />
|
|
35
|
+
))}
|
|
36
|
+
</ScrollView>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { KeyboardAvoidingView, View } from "react-native";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
import {
|
|
5
|
+
Conversation,
|
|
6
|
+
ConversationContent,
|
|
7
|
+
ConversationScrollButton,
|
|
8
|
+
} from "./conversation";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The least `ChatThread` reads from a message: an id and a role. The `ai` SDK
|
|
12
|
+
* `UIMessage` fits it, so the package takes no `ai` dependency.
|
|
13
|
+
*/
|
|
14
|
+
export type UIMessage = {
|
|
15
|
+
id: string;
|
|
16
|
+
role: "system" | "user" | "assistant";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ChatThreadProps<M extends UIMessage = UIMessage> = {
|
|
20
|
+
messages: M[];
|
|
21
|
+
/** True while a reply is on its way. Marks the last assistant message as streaming and shows `thinking`. */
|
|
22
|
+
working: boolean;
|
|
23
|
+
/** Draws one message. `isStreaming` is true for the last assistant message while `working`. */
|
|
24
|
+
renderMessage: (message: M, isStreaming: boolean) => ReactNode;
|
|
25
|
+
/** A row after the last user message while `working`, such as a `Shimmer` "Thinking". */
|
|
26
|
+
thinking?: ReactNode;
|
|
27
|
+
/** Shown in place of the list when there are no messages. */
|
|
28
|
+
emptyState?: ReactNode;
|
|
29
|
+
/** Last in the column, lifted over the keyboard. A `PromptInput`. */
|
|
30
|
+
footer: ReactNode;
|
|
31
|
+
/** Merged into the `ConversationContent` column classes (`gap-4 px-3 py-3`), such as `px-4 py-4`. */
|
|
32
|
+
contentContainerClassName?: string;
|
|
33
|
+
/** Height of anything above the thread, such as a navigation header, for the keyboard lift. Default 0. */
|
|
34
|
+
keyboardVerticalOffset?: number;
|
|
35
|
+
className?: string;
|
|
36
|
+
testID?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Index of the last message with `role`, or -1. A loop, not `findLastIndex` (Hermes). */
|
|
40
|
+
function lastIndexOfRole(messages: UIMessage[], role: UIMessage["role"]) {
|
|
41
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
42
|
+
if (messages[index]!.role === role) return index;
|
|
43
|
+
}
|
|
44
|
+
return -1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The chat column as one piece: `Conversation`, `ConversationContent`, the
|
|
49
|
+
* messages, a `thinking` row right after the last user message while
|
|
50
|
+
* `working`, `ConversationScrollButton` and the `footer` last. The whole
|
|
51
|
+
* column sits in a `KeyboardAvoidingView` (`padding`, as `PromptInput`), which
|
|
52
|
+
* also lifts the footer on Android edge-to-edge, where the window does not
|
|
53
|
+
* resize. A `PromptInput` footer has its own `KeyboardAvoidingView`; the outer
|
|
54
|
+
* lift moves it clear of the keyboard, so it adds no second lift.
|
|
55
|
+
*/
|
|
56
|
+
export function ChatThread<M extends UIMessage = UIMessage>({
|
|
57
|
+
messages,
|
|
58
|
+
working,
|
|
59
|
+
renderMessage,
|
|
60
|
+
thinking,
|
|
61
|
+
emptyState,
|
|
62
|
+
footer,
|
|
63
|
+
contentContainerClassName,
|
|
64
|
+
keyboardVerticalOffset = 0,
|
|
65
|
+
className,
|
|
66
|
+
testID,
|
|
67
|
+
}: ChatThreadProps<M>) {
|
|
68
|
+
const lastUser = working ? lastIndexOfRole(messages, "user") : -1;
|
|
69
|
+
const streaming = working ? lastIndexOfRole(messages, "assistant") : -1;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<KeyboardAvoidingView
|
|
73
|
+
testID={testID}
|
|
74
|
+
behavior="padding"
|
|
75
|
+
keyboardVerticalOffset={keyboardVerticalOffset}
|
|
76
|
+
className={cn("flex-1", className)}
|
|
77
|
+
>
|
|
78
|
+
<Conversation>
|
|
79
|
+
<ConversationContent contentContainerClassName={contentContainerClassName}>
|
|
80
|
+
{messages.length === 0
|
|
81
|
+
? emptyState
|
|
82
|
+
: messages.map((message, index) => (
|
|
83
|
+
<View key={message.id}>
|
|
84
|
+
{renderMessage(message, index === streaming)}
|
|
85
|
+
{index === lastUser && thinking != null ? (
|
|
86
|
+
<View className="mt-4">{thinking}</View>
|
|
87
|
+
) : null}
|
|
88
|
+
</View>
|
|
89
|
+
))}
|
|
90
|
+
</ConversationContent>
|
|
91
|
+
<ConversationScrollButton testID="chat-thread-scroll-bottom" />
|
|
92
|
+
</Conversation>
|
|
93
|
+
{footer}
|
|
94
|
+
</KeyboardAvoidingView>
|
|
95
|
+
);
|
|
96
|
+
}
|
package/src/ai/index.ts
CHANGED
|
@@ -115,6 +115,7 @@ export {
|
|
|
115
115
|
Attachment,
|
|
116
116
|
getMediaCategory,
|
|
117
117
|
getAttachmentLabel,
|
|
118
|
+
ATTACHMENT_PRESS_TARGET,
|
|
118
119
|
} from "./attachments";
|
|
119
120
|
export type {
|
|
120
121
|
AttachmentData,
|
|
@@ -123,6 +124,10 @@ export type {
|
|
|
123
124
|
AttachmentsProps,
|
|
124
125
|
AttachmentProps,
|
|
125
126
|
} from "./attachments";
|
|
127
|
+
export { ChatThread } from "./chat-thread";
|
|
128
|
+
export type { ChatThreadProps, UIMessage } from "./chat-thread";
|
|
129
|
+
export { ChatJumpRail } from "./chat-jump-rail";
|
|
130
|
+
export type { ChatJumpRailProps, ChatJumpRailItem } from "./chat-jump-rail";
|
|
126
131
|
export { CodeBlock, CodeBlockCopyButton } from "./code-block";
|
|
127
132
|
export type { CodeBlockProps, CodeBlockCopyButtonProps } from "./code-block";
|
|
128
133
|
export { ResponseText, ChatTextColorContext } from "./response-text";
|
|
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
|
|
|
3
3
|
import { useSSO } from "@clerk/clerk-expo";
|
|
4
4
|
import * as AuthSession from "expo-auth-session";
|
|
5
5
|
import * as WebBrowser from "expo-web-browser";
|
|
6
|
-
import { useRouter } from "expo-router";
|
|
6
|
+
import { useRouter, type Href, type Router } from "expo-router";
|
|
7
7
|
import { GoogleMark } from "../brand/GoogleMark";
|
|
8
8
|
import { Button, type ButtonProps } from "../Button";
|
|
9
9
|
import { toast } from "../toastStore";
|
|
@@ -16,9 +16,9 @@ export const GOOGLE_ERROR_MESSAGE = "Could not continue with Google";
|
|
|
16
16
|
|
|
17
17
|
export type AuthNavigationOptions = {
|
|
18
18
|
/** Where to go after a session starts. Default `"/"`. */
|
|
19
|
-
afterAuthHref?:
|
|
19
|
+
afterAuthHref?: Href;
|
|
20
20
|
/** Passed to `router.replace`, such as `{ withAnchor: true }`. */
|
|
21
|
-
replaceOptions?:
|
|
21
|
+
replaceOptions?: Parameters<Router["replace"]>[1];
|
|
22
22
|
/** Called after a session starts, in place of `router.replace`. */
|
|
23
23
|
onSuccess?: () => void;
|
|
24
24
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { Text, View } from "react-native";
|
|
3
3
|
import { useSignIn, useSignUp } from "@clerk/clerk-expo";
|
|
4
|
-
import { useRouter } from "expo-router";
|
|
4
|
+
import { useRouter, type Href } from "expo-router";
|
|
5
5
|
import { Button } from "../Button";
|
|
6
6
|
import { textStyle } from "../controlStyles";
|
|
7
7
|
import { Input } from "../Input";
|
|
@@ -119,9 +119,9 @@ export type SignInScreenProps = AuthNavigationOptions & {
|
|
|
119
119
|
/** Default "Continue with Google". */
|
|
120
120
|
googleLabel?: string;
|
|
121
121
|
/** In `sign-in`: the "Sign up" link in the footer. No link without it. */
|
|
122
|
-
signUpHref?:
|
|
122
|
+
signUpHref?: Href;
|
|
123
123
|
/** In `sign-up`: the "Sign in" link in the footer. No link without it. */
|
|
124
|
-
signInHref?:
|
|
124
|
+
signInHref?: Href;
|
|
125
125
|
/** Sign-up rejects a shorter password before sending it. Default: no check. */
|
|
126
126
|
minPasswordLength?: number;
|
|
127
127
|
/** Default `"toast"`. `"inline"` shows errors under the fields. */
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@vvedantb/native-ui/expo-router`: components that need `expo-router`, an
|
|
2
|
+
// optional peer. Not on the root, `/layout` or `/page-title` entries: they
|
|
3
|
+
// never import it.
|
|
4
|
+
export { RouterStackPageHeader, goBackOrReplace } from "../router/RouterStackPageHeader";
|
|
5
|
+
export type { RouterStackPageHeaderProps } from "../router/RouterStackPageHeader";
|
package/src/entries/layout.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// labels and settings chrome.
|
|
3
3
|
export { SafeAreaContainer } from "../SafeAreaContainer";
|
|
4
4
|
export { ScreenContainer } from "../ScreenContainer";
|
|
5
|
+
export type { ScreenContainerProps } from "../ScreenContainer";
|
|
5
6
|
export { PageTitle, PageHeader } from "../PageTitle";
|
|
6
7
|
export type { PageTitleProps, PageHeaderProps } from "../PageTitle";
|
|
7
8
|
export { StackPageHeader } from "../StackPageHeader";
|
|
@@ -12,6 +13,12 @@ export { SettingsSection } from "../settings/SettingsSection";
|
|
|
12
13
|
export { SettingsField } from "../settings/SettingsField";
|
|
13
14
|
export { SettingsToggleRow } from "../settings/SettingsToggleRow";
|
|
14
15
|
export { SettingsStack } from "../settings/SettingsStack";
|
|
16
|
+
export { SettingsEmptyState } from "../settings/SettingsEmptyState";
|
|
17
|
+
export type { SettingsEmptyStateProps } from "../settings/SettingsEmptyState";
|
|
18
|
+
export { PinnedBotTile, BotPinGrid } from "../PinnedBotTile";
|
|
19
|
+
export type { PinnedBotTileProps, BotPinGridProps } from "../PinnedBotTile";
|
|
20
|
+
export { ArtifactList } from "../ArtifactList";
|
|
21
|
+
export type { ArtifactListProps, ArtifactRow } from "../ArtifactList";
|
|
15
22
|
export { MoreFromProjectV, FromProjectV } from "../settings/MoreFromProjectV";
|
|
16
23
|
export type { MoreFromProjectVProps, FromProjectVProps } from "../settings/MoreFromProjectV";
|
|
17
24
|
export {
|
package/src/index.ts
CHANGED
|
@@ -182,6 +182,7 @@ export type { DateTimeFieldProps } from "./DateTimeField";
|
|
|
182
182
|
export { BottomSheetContainer } from "./BottomSheetContainer";
|
|
183
183
|
export { SafeAreaContainer } from "./SafeAreaContainer";
|
|
184
184
|
export { ScreenContainer } from "./ScreenContainer";
|
|
185
|
+
export type { ScreenContainerProps } from "./ScreenContainer";
|
|
185
186
|
export { PageTitle, PageHeader } from "./PageTitle";
|
|
186
187
|
export type { PageTitleProps, PageHeaderProps } from "./PageTitle";
|
|
187
188
|
export { StackPageHeader } from "./StackPageHeader";
|
|
@@ -190,6 +191,12 @@ export { SettingsSection } from "./settings/SettingsSection";
|
|
|
190
191
|
export { SettingsField } from "./settings/SettingsField";
|
|
191
192
|
export { SettingsToggleRow } from "./settings/SettingsToggleRow";
|
|
192
193
|
export { SettingsStack } from "./settings/SettingsStack";
|
|
194
|
+
export { SettingsEmptyState } from "./settings/SettingsEmptyState";
|
|
195
|
+
export type { SettingsEmptyStateProps } from "./settings/SettingsEmptyState";
|
|
196
|
+
export { PinnedBotTile, BotPinGrid } from "./PinnedBotTile";
|
|
197
|
+
export type { PinnedBotTileProps, BotPinGridProps } from "./PinnedBotTile";
|
|
198
|
+
export { ArtifactList } from "./ArtifactList";
|
|
199
|
+
export type { ArtifactListProps, ArtifactRow } from "./ArtifactList";
|
|
193
200
|
export { MoreFromProjectV, FromProjectV } from "./settings/MoreFromProjectV";
|
|
194
201
|
export type { MoreFromProjectVProps, FromProjectVProps } from "./settings/MoreFromProjectV";
|
|
195
202
|
export {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useRouter, type Href, type Router } from "expo-router";
|
|
2
|
+
import { StackPageHeader, type StackPageHeaderProps } from "../StackPageHeader";
|
|
3
|
+
|
|
4
|
+
export type RouterStackPageHeaderProps = Omit<StackPageHeaderProps, "onBack"> & {
|
|
5
|
+
/** Where the back button goes when there is nothing to go back to, such as after a deep link. Default `"/"`. */
|
|
6
|
+
fallbackHref?: Href;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Goes back when the stack has a screen below, else replaces the route with `fallbackHref`. */
|
|
10
|
+
export function goBackOrReplace(router: Pick<Router, "canGoBack" | "back" | "replace">, fallbackHref: Href = "/") {
|
|
11
|
+
if (router.canGoBack()) router.back();
|
|
12
|
+
else router.replace(fallbackHref);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `StackPageHeader` with a router-aware back button: `router.canGoBack() ?
|
|
17
|
+
* router.back() : router.replace(fallbackHref)`. Same props as
|
|
18
|
+
* `StackPageHeader` without `onBack`, and `fallbackHref` (default `"/"`).
|
|
19
|
+
* On `@vvedantb/native-ui/expo-router` only, so the other entries never import
|
|
20
|
+
* `expo-router`.
|
|
21
|
+
*/
|
|
22
|
+
export function RouterStackPageHeader({ fallbackHref = "/", ...props }: RouterStackPageHeaderProps) {
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
return <StackPageHeader {...props} onBack={() => goBackOrReplace(router, fallbackHref)} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
import type { IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { textStyle, typeStyle } from "../controlStyles";
|
|
5
|
+
import { useLargerText } from "../theme";
|
|
6
|
+
import { tokens } from "../tokens";
|
|
7
|
+
import { useScheme } from "../useScheme";
|
|
8
|
+
|
|
9
|
+
export type SettingsEmptyStateProps = {
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Optional icon above the title, as on the web. */
|
|
13
|
+
icon?: ComponentType<IconProps>;
|
|
14
|
+
/** Below the text, such as a `Button`. */
|
|
15
|
+
action?: ReactNode;
|
|
16
|
+
testID?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A compact empty note for the inside of a `SettingsSection` (web
|
|
21
|
+
* `SettingsEmptyState`): centred title, muted description and an optional
|
|
22
|
+
* action, with no large icon. Use `EmptyState` for a whole screen.
|
|
23
|
+
*/
|
|
24
|
+
export function SettingsEmptyState({ title, description, icon: Icon, action, testID }: SettingsEmptyStateProps) {
|
|
25
|
+
const scheme = useScheme();
|
|
26
|
+
const largerText = useLargerText();
|
|
27
|
+
return (
|
|
28
|
+
<View testID={testID} className="items-center gap-1 px-2 py-4">
|
|
29
|
+
{Icon ? <Icon size={largerText ? 24 : 20} color={tokens.color.muted[scheme]} /> : null}
|
|
30
|
+
<Text
|
|
31
|
+
className="text-center font-instrument-sans-semibold text-base"
|
|
32
|
+
style={[textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
33
|
+
>
|
|
34
|
+
{title}
|
|
35
|
+
</Text>
|
|
36
|
+
{description ? (
|
|
37
|
+
<Text className="text-center font-instrument-sans text-sm" style={textStyle("muted", scheme)}>
|
|
38
|
+
{description}
|
|
39
|
+
</Text>
|
|
40
|
+
) : null}
|
|
41
|
+
{action ? <View className="mt-2">{action}</View> : null}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|