@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 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.11.2",
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
+ }
@@ -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";
@@ -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,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
+ }