@vvedantb/native-ui 0.11.2 → 0.12.1

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 CHANGED
@@ -21,6 +21,7 @@ Peers the app must already have (Expo SDK 57 / RN 0.76+):
21
21
 
22
22
  Optional, only if you import those components:
23
23
 
24
+ - `@rn-primitives/portal` — `Select`. Mount one `<PortalHost />` from this package at the app root (for example in the root layout), outside any `Modal`. The package does not re-export it, so install it with `@rn-primitives/select`.
24
25
  - `@gorhom/bottom-sheet` — `BottomSheetContainer`
25
26
  - `@react-native-community/datetimepicker` and `dayjs` — `DateTimeField`
26
27
  - `@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`.
@@ -72,16 +73,17 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
72
73
  | `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
73
74
  | `@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
75
  | `@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) |
76
+ | `@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
77
  | `@vvedantb/native-ui/marketing` | `UpgradeToProCard`, `PricingModal` and the pricing types | none |
77
78
  | `@vvedantb/native-ui/ambient` | `AmbientBackground` | `expo-linear-gradient` (loader) |
78
79
  | `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
79
80
  | `@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
81
  | `@vvedantb/native-ui/bottom-sheet` | `BottomSheetContainer` | `@gorhom/bottom-sheet` (import), `expo-blur` (loader) |
81
82
  | `@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 |
83
+ | `@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
84
  | `@vvedantb/native-ui/ai-gallery` | `AiChatGallery`, `AiChatSamples` (from 0.4.0) | none |
84
85
  | `@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) |
86
+ | `@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
87
  | `@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
88
  | `@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
89
 
@@ -426,6 +428,41 @@ Components and props that the Verve, vibot and Velth apps need. They follow the
426
428
  </AmbientBackground>
427
429
  ```
428
430
 
431
+ ### 0.12.0 additions
432
+
433
+ ```tsx
434
+ // Typed routes: `afterAuthHref`, `signUpHref` and `signInHref` take expo-router's `Href`,
435
+ // and `replaceOptions` takes `Parameters<Router["replace"]>[1]`.
436
+ <SignInScreen scheme="app" afterAuthHref="/(app)/bots" signUpHref="/sign-up" replaceOptions={{ withAnchor: true }} />
437
+
438
+ // Router-aware back: router.canGoBack() ? router.back() : router.replace(fallbackHref).
439
+ import { RouterStackPageHeader } from "@vvedantb/native-ui/expo-router";
440
+ <RouterStackPageHeader title="Bot" fallbackHref="/bots" backTestID="screen-back" centre={<BotTitle />} />
441
+
442
+ // An attachment chip that opens its file. The remove button stays separate.
443
+ <Attachment data={file} onPress={() => open(file.url)} onRemove={() => remove(file.id)} />
444
+
445
+ // The chat column as one piece.
446
+ <ChatThread
447
+ messages={messages}
448
+ working={status === "streaming"}
449
+ renderMessage={(message, isStreaming) => <MessageItem message={message} streaming={isStreaming} />}
450
+ thinking={<Shimmer>Thinking</Shimmer>}
451
+ footer={<PromptInput onSubmit={send} status={status} />}
452
+ contentContainerClassName="px-4 py-4"
453
+ />
454
+
455
+ // A two-across grid of pinned bots.
456
+ <BotPinGrid>
457
+ <PinnedBotTile title="Research" leading={<Avatar />} unread onPress={open} onLongPress={showActions} />
458
+ </BotPinGrid>
459
+
460
+ // Pull to refresh. `refreshing` is controlled. No control without `onRefresh` or `refreshControl`.
461
+ <ScreenContainer refreshing={refreshing} onRefresh={reload}>{children}</ScreenContainer>
462
+ ```
463
+
464
+ `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.
465
+
429
466
  `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
467
 
431
468
  ## AI chat (0.4.0)
@@ -482,8 +519,8 @@ import {
482
519
  | `AgentTrace`, `AgentTraceLine`, `AgentTraceIcon`, `AgentTraceTitle`, `AgentTraceDescription`, `AgentTraceCollapsible*` | One step per line, with an optional fold behind a rail. `AgentTraceTitle shimmer` pulses a running step. Spread `useStreamingDisclosure(isStreaming)` on `AgentTraceCollapsible` to open it while a step streams. |
483
520
  | `ActivityStep` | One tool step as a trace line: icon, label, a spinner while `running`, a check when `complete`, a warning on `error`, and a description such as `` `Worked for ${formatElapsed(12)}` ``. |
484
521
  | `ChainOfThought` and its parts | Simplified: header, steps with a rail, search-result chips. |
485
- | `PromptInput`, `PromptInputButton`, `PromptInputSubmit` | The web field surface with a `header` slot (attachments), a field, a `toolbar` slot and the send button. The field grows from one line to 160 high (about 6 lines), then scrolls. Send is primary with text and disabled without. While `status` is `submitted` or `streaming` it is a "Stop" square that calls `onStop`. It sits in a `KeyboardAvoidingView` (padding, on iOS and Android) and pads the safe-area insets: put it last in a full-height column. Under a navigation header, pass the header height as `keyboardVerticalOffset`; above a tab bar, pass `safeBottom={false}`. |
486
- | `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"` (Eva's chat pill): `leading` (such as a + button), the field (one line to start, then it grows), `toolbar`, then send, in a 20-radius field with `p-1.5`. 44 high on one line. |
522
+ | `PromptInput`, `PromptInputButton`, `PromptInputSubmit` | The web field surface with a `header` slot (attachments), a field, a `toolbar` slot and the send button. The field grows from one line to 160 high (about 6 lines), then scrolls (auto-grow in both layouts from 0.12.1; send resets the height). Send is primary with text and disabled without. While `status` is `submitted` or `streaming` it is a "Stop" square that calls `onStop`. It sits in a `KeyboardAvoidingView` (padding, on iOS and Android) and pads the safe-area insets: put it last in a full-height column. Under a navigation header, pass the header height as `keyboardVerticalOffset`; above a tab bar, pass `safeBottom={false}`. |
523
+ | `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"` (Eva's chat pill): `leading` (such as a + button), the field (one line to start, then it grows from 0.12.1), `toolbar`, then send, in a 20-radius field with `p-1.5`. 44 high on one line. |
487
524
  | `Suggestions`, `Suggestion` | A horizontal row of round chips (or `orientation="vertical"`). `onPress` gets the suggestion text. |
488
525
  | `Sources`, `SourcesTrigger`, `SourcesContent`, `Source` | A fold of link rows. A row opens its address with `Linking.openURL`, or hands it to `onPress`. |
489
526
  | `FileChip`, `Attachments`, `Attachment` | A monospace file chip (a button with `onOpenFile`), and attachment chips with a media icon or image thumbnail and a labelled remove button. No hover cards and no grid previews. |
@@ -507,7 +544,7 @@ Every control has a role, an accessible name and a 44 pt target. Folds report `e
507
544
 
508
545
  ## DropdownMenu (Unreleased)
509
546
 
510
- A small menu anchored to its trigger, the native counterpart of the web `DropdownMenu`. On `/primitives` and the root.
547
+ A small menu anchored to its trigger, the native counterpart of the web `DropdownMenu`. On `/primitives` and the root. From 0.12.1 the backdrop and menu sit in a full-size `View` inside the `Modal`, so the menu stays above tab bars and glass layers.
511
548
 
512
549
  ```tsx
513
550
  <DropdownMenu>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.11.2",
3
+ "version": "0.12.1",
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
+ }
@@ -217,36 +217,41 @@ export function DropdownMenuContent({
217
217
  statusBarTranslucent
218
218
  onRequestClose={close}
219
219
  >
220
- <Pressable
221
- testID="DropdownMenu-backdrop"
222
- accessibilityRole="button"
223
- accessibilityLabel="Close menu"
224
- onPress={close}
225
- style={StyleSheet.absoluteFill}
226
- />
227
- <View
228
- accessibilityRole="menu"
229
- accessibilityViewIsModal
230
- onAccessibilityEscape={close}
231
- style={{
232
- position: "absolute",
233
- ...position,
234
- minWidth: DROPDOWN_MENU.minWidth,
235
- maxWidth: window.width - insets.left - insets.right - 2 * DROPDOWN_MENU.edge,
236
- }}
237
- >
238
- <FloatingSurface blur={false} radius={DROPDOWN_MENU.radius} style={{ maxHeight: position.maxHeight }}>
239
- <ScrollView
240
- bounces={false}
241
- showsVerticalScrollIndicator={false}
242
- onContentSizeChange={(_width, height) => setContentHeight(height)}
243
- contentContainerStyle={{ padding: DROPDOWN_MENU.padding }}
244
- >
245
- <View className={cn(className)} style={style} {...props}>
246
- {children}
247
- </View>
248
- </ScrollView>
249
- </FloatingSurface>
220
+ {/* A full-size host, as `OverlayModal` has: without it the backdrop and the
221
+ menu are bare Modal children with no box to size or hit-test against,
222
+ and the menu can stack under tab bars and glass layers. */}
223
+ <View style={{ flex: 1 }} pointerEvents="box-none">
224
+ <Pressable
225
+ testID="DropdownMenu-backdrop"
226
+ accessibilityRole="button"
227
+ accessibilityLabel="Close menu"
228
+ onPress={close}
229
+ style={StyleSheet.absoluteFill}
230
+ />
231
+ <View
232
+ accessibilityRole="menu"
233
+ accessibilityViewIsModal
234
+ onAccessibilityEscape={close}
235
+ style={{
236
+ position: "absolute",
237
+ ...position,
238
+ minWidth: DROPDOWN_MENU.minWidth,
239
+ maxWidth: window.width - insets.left - insets.right - 2 * DROPDOWN_MENU.edge,
240
+ }}
241
+ >
242
+ <FloatingSurface blur={false} radius={DROPDOWN_MENU.radius} style={{ maxHeight: position.maxHeight }}>
243
+ <ScrollView
244
+ bounces={false}
245
+ showsVerticalScrollIndicator={false}
246
+ onContentSizeChange={(_width, height) => setContentHeight(height)}
247
+ contentContainerStyle={{ padding: DROPDOWN_MENU.padding }}
248
+ >
249
+ <View className={cn(className)} style={style} {...props}>
250
+ {children}
251
+ </View>
252
+ </ScrollView>
253
+ </FloatingSurface>
254
+ </View>
250
255
  </View>
251
256
  </Modal>
252
257
  );
@@ -2,6 +2,7 @@ import { Platform, StyleSheet, View, type ViewProps } from "react-native";
2
2
  import { useColorScheme } from "nativewind";
3
3
  import { glassMode, loadBlur } from "./blur";
4
4
  import { cn } from "./cn";
5
+ import { hasDefiniteHeight } from "./definiteSize";
5
6
  import {
6
7
  floatingFill,
7
8
  floatingHairline,
@@ -65,7 +66,7 @@ export function FloatingSurface({
65
66
  backgroundColor: mode === "blur" ? "transparent" : fill,
66
67
  },
67
68
  Platform.OS === "ios" ? styles.continuous : null,
68
- style != null ? styles.fill : null,
69
+ hasDefiniteHeight(style) ? styles.fill : null,
69
70
  ]}
70
71
  >
71
72
  {mode === "blur" && BlurView ? (
@@ -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
+ }
@@ -1,25 +1,52 @@
1
- import { ScrollView, View, type ViewProps } from "react-native";
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}
@@ -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. The row is at least 56 high
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
- <PageTitle className="text-center">{title}</PageTitle>
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}
@@ -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
- return (
112
- <View
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";
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import { useState, type ReactNode } from "react";
2
2
  import { KeyboardAvoidingView, View } from "react-native";
3
3
  import { useSafeAreaInsets } from "react-native-safe-area-context";
4
4
  import { IconArrowUp, IconPlayerStopFilled } from "@tabler/icons-react-native";
@@ -151,11 +151,18 @@ export function PromptInput({
151
151
  const message = text.trim();
152
152
  const canSubmit = message.length > 0 && !disabled && !isGenerating(status);
153
153
  const bottom = safeBottom ? insets.bottom : 0;
154
+ // The field grows with its content up to `maxHeight`, then scrolls. A fixed
155
+ // `numberOfLines` would lock the line count on Android, so neither layout sets it.
156
+ const [contentHeight, setContentHeight] = useState(0);
157
+ const maxHeight = largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular;
158
+ const growStyle = contentHeight > 0 ? { height: Math.min(contentHeight, maxHeight) } : null;
159
+ const scrollEnabled = contentHeight > maxHeight;
154
160
 
155
161
  const submit = () => {
156
162
  if (!canSubmit) return;
157
163
  onSubmit(message);
158
164
  setText("");
165
+ setContentHeight(0);
159
166
  };
160
167
 
161
168
  return (
@@ -184,8 +191,8 @@ export function PromptInput({
184
191
  accessibilityLabel={accessibilityLabel}
185
192
  editable={!disabled}
186
193
  textAlignVertical="center"
187
- // One line to start (a multiline field is two rows on react-native-web); it grows from there.
188
- numberOfLines={1}
194
+ scrollEnabled={scrollEnabled}
195
+ onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
189
196
  className={cn(
190
197
  "min-h-8 flex-1 border-0 px-2 py-1 text-base font-instrument-sans",
191
198
  largerText && "min-h-11 py-2 text-xl font-instrument-sans-medium",
@@ -195,7 +202,8 @@ export function PromptInput({
195
202
  largerText ? typeStyle("medium") : null,
196
203
  {
197
204
  backgroundColor: "transparent",
198
- maxHeight: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
205
+ maxHeight,
206
+ ...growStyle,
199
207
  },
200
208
  ]}
201
209
  />
@@ -217,6 +225,8 @@ export function PromptInput({
217
225
  accessibilityLabel={accessibilityLabel}
218
226
  editable={!disabled}
219
227
  textAlignVertical="top"
228
+ scrollEnabled={scrollEnabled}
229
+ onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
220
230
  className={cn(
221
231
  "min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
222
232
  largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
@@ -226,7 +236,8 @@ export function PromptInput({
226
236
  largerText ? typeStyle("medium") : null,
227
237
  {
228
238
  backgroundColor: "transparent",
229
- maxHeight: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
239
+ maxHeight,
240
+ ...growStyle,
230
241
  },
231
242
  ]}
232
243
  />
@@ -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?: string;
19
+ afterAuthHref?: Href;
20
20
  /** Passed to `router.replace`, such as `{ withAnchor: true }`. */
21
- replaceOptions?: object;
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?: string;
122
+ signUpHref?: Href;
123
123
  /** In `sign-up`: the "Sign in" link in the footer. No link without it. */
124
- signInHref?: string;
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,12 @@
1
+ import { StyleSheet, type StyleProp, type ViewStyle } from "react-native";
2
+
3
+ /**
4
+ * True when `style` gives a view a definite height or a flex grow. A style with
5
+ * only `maxHeight`, `maxWidth` or `minHeight` does not: a `flex: 1` child of
6
+ * such a parent collapses to height 0 on Android.
7
+ */
8
+ export function hasDefiniteHeight(style: StyleProp<ViewStyle>): boolean {
9
+ const flat = StyleSheet.flatten(style);
10
+ if (flat == null) return false;
11
+ return flat.height != null || flat.flex != null || flat.flexGrow != null;
12
+ }
@@ -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";
@@ -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
+ }