@vvedantb/native-ui 0.12.1 → 0.17.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.
Files changed (113) hide show
  1. package/README.md +82 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/card-button.tsx +49 -0
  36. package/src/ai/chat-text.ts +13 -7
  37. package/src/ai/chat-thread.tsx +34 -27
  38. package/src/ai/choice-question.tsx +154 -35
  39. package/src/ai/code-block.tsx +189 -49
  40. package/src/ai/collapsible.tsx +19 -13
  41. package/src/ai/conversation.tsx +390 -57
  42. package/src/ai/icon-button.tsx +41 -6
  43. package/src/ai/index.ts +52 -1
  44. package/src/ai/message-action-sheet.tsx +168 -0
  45. package/src/ai/message-quote.tsx +98 -0
  46. package/src/ai/message.tsx +226 -31
  47. package/src/ai/prompt-input-menu.tsx +197 -0
  48. package/src/ai/prompt-input.tsx +308 -73
  49. package/src/ai/reply-quote-chip.tsx +90 -0
  50. package/src/ai/shimmer.tsx +4 -6
  51. package/src/ai/subagent-cards.tsx +209 -0
  52. package/src/ai/tool-approval-card.tsx +162 -0
  53. package/src/ai/trace.tsx +54 -17
  54. package/src/ai/unified-diff.tsx +182 -0
  55. package/src/auth/GoogleSignInButton.tsx +6 -3
  56. package/src/auth/SignInScreen.tsx +11 -2
  57. package/src/brand/ProviderIcon.tsx +136 -0
  58. package/src/brand/index.ts +2 -0
  59. package/src/configureTheme.ts +39 -11
  60. package/src/controlStyles.ts +46 -30
  61. package/src/entries/gesture-handler.ts +4 -0
  62. package/src/entries/keyboard-controller.ts +4 -0
  63. package/src/entries/markdown.ts +1 -1
  64. package/src/entries/primitives.ts +24 -3
  65. package/src/entries/theme.ts +2 -1
  66. package/src/entries/video.ts +4 -0
  67. package/src/entries/webview.ts +6 -0
  68. package/src/floating.ts +25 -4
  69. package/src/haptics.ts +30 -4
  70. package/src/highlight.ts +302 -1
  71. package/src/index.ts +27 -5
  72. package/src/loadingGrid.ts +75 -0
  73. package/src/markdown/Markdown.tsx +234 -32
  74. package/src/markdown/MarkdownTable.tsx +188 -0
  75. package/src/markdown/tableText.ts +58 -0
  76. package/src/marketing/UpgradeToProCard.tsx +4 -3
  77. package/src/media/ImageGallery.tsx +224 -0
  78. package/src/media/MediaCard.tsx +100 -0
  79. package/src/media/imageGrid.ts +51 -0
  80. package/src/motion.ts +33 -0
  81. package/src/pagerSwipe.ts +102 -0
  82. package/src/palette.ts +8 -1
  83. package/src/primitives/button.tsx +4 -1
  84. package/src/primitives/keyboard-controller.tsx +106 -0
  85. package/src/primitives/keyboard-parts.tsx +104 -0
  86. package/src/primitives/overlay.tsx +133 -45
  87. package/src/primitives/pager-gesture.tsx +131 -0
  88. package/src/primitives/sheet-drag.tsx +305 -0
  89. package/src/primitives/sheet-gesture.tsx +181 -0
  90. package/src/primitives/swipe-row-gesture.tsx +290 -0
  91. package/src/primitives/toast-drag-gesture.tsx +92 -0
  92. package/src/primitives/web-click-guard.ts +50 -0
  93. package/src/relativeTime.ts +94 -0
  94. package/src/router/RouterStackPageHeader.tsx +2 -2
  95. package/src/settings/SettingsEmptyState.tsx +20 -10
  96. package/src/settings/SettingsField.tsx +11 -5
  97. package/src/settings/SettingsSection.tsx +8 -5
  98. package/src/settings/SettingsToggleRow.tsx +55 -22
  99. package/src/settings/relaxedSizeStyle.ts +11 -0
  100. package/src/sheetDrag.ts +67 -0
  101. package/src/swipeRow.ts +169 -0
  102. package/src/tabBarRoutes.ts +18 -0
  103. package/src/tabIndicator.ts +71 -0
  104. package/src/tailwind-preset.cjs +13 -6
  105. package/src/tailwind-preset.d.cts +5 -0
  106. package/src/theme.tsx +6 -2
  107. package/src/toastDrag.ts +58 -0
  108. package/src/typography.ts +20 -3
  109. package/src/useScheme.ts +10 -1
  110. package/src/video/InlineVideo.tsx +91 -0
  111. package/src/webview/HtmlView.tsx +91 -0
  112. package/src/webview/HtmlView.web.tsx +21 -0
  113. package/src/webview/htmlView.ts +45 -0
@@ -1,6 +1,8 @@
1
- import { createContext, useContext, type ComponentType, type ReactNode } from "react";
2
- import { Text, View, type ViewProps } from "react-native";
1
+ import { createContext, useContext, useEffect, useState, type ComponentType, type ReactNode } from "react";
2
+ import { Pressable, StyleSheet, Text, View, type ViewProps } from "react-native";
3
+ import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
3
4
  import {
5
+ IconArrowBackUp,
4
6
  IconCopy,
5
7
  IconRefresh,
6
8
  IconThumbDown,
@@ -8,13 +10,15 @@ import {
8
10
  type IconProps,
9
11
  } from "@tabler/icons-react-native";
10
12
  import { cn } from "../cn";
11
- import { sizeStyle } from "../controlStyles";
13
+ import { sizeStyle, typeStyle } from "../controlStyles";
12
14
  import { palette, withAlpha } from "../palette";
13
15
  import { useLargerText } from "../theme";
14
16
  import { tokens } from "../tokens";
17
+ import { useReduceMotion } from "../useReduceMotion";
15
18
  import { useScheme } from "../useScheme";
16
- import { chatTextSize } from "./chat-text";
17
- import { ChatIconButton } from "./icon-button";
19
+ import { META_TEXT_STYLE, chatTextSize } from "./chat-text";
20
+ import { ChatIconButton, type ChatIconButtonSize } from "./icon-button";
21
+ import { MESSAGE_ACTIONS_TITLE, MessageActionSheet, type MessageSheetAction } from "./message-action-sheet";
18
22
  import { ChatTextColorContext, ResponseText } from "./response-text";
19
23
 
20
24
  export type MessageRole = "user" | "assistant" | "system";
@@ -24,25 +28,146 @@ const MessageContext = createContext<MessageRole>("assistant");
24
28
  export type MessageProps = ViewProps & {
25
29
  from: MessageRole;
26
30
  className?: string;
31
+ /**
32
+ * Called on a long press of the message. Without it (and without `actions`)
33
+ * the message is a plain `View`, as before.
34
+ */
35
+ onLongPress?: () => void;
36
+ /**
37
+ * Actions for a `MessageActionSheet` that opens on a long press, such as
38
+ * Reply and Copy. `onLongPress` also runs, before the sheet opens.
39
+ */
40
+ actions?: MessageSheetAction[];
41
+ /** Title of the action sheet. Default "Message actions". */
42
+ actionSheetTitle?: string;
43
+ /**
44
+ * True while the message is the target of a quote tap: a primary wash that
45
+ * fades over about 1.4 seconds, and a ring while it stays true. Set it back
46
+ * to `false` after the scroll, about 1.5 seconds later.
47
+ */
48
+ highlighted?: boolean;
49
+ /**
50
+ * For group chats: which bot sent this message. Draws a `MessageBotLabel`
51
+ * (avatar and name) above the content. Only when set; assistant messages only.
52
+ */
53
+ bot?: MessageBot;
27
54
  };
28
55
 
56
+ /** The bot behind an incoming message in a group chat. */
57
+ export type MessageBot = { name: string; avatar?: ReactNode };
58
+
59
+ /** How long the highlight wash takes to fade, in milliseconds. */
60
+ export const MESSAGE_HIGHLIGHT_MS = 1400;
61
+
62
+ /** Peak opacity of the highlight wash (the primary colour). */
63
+ const HIGHLIGHT_WASH = 0.18;
64
+
65
+ function MessageHighlight() {
66
+ const scheme = useScheme();
67
+ const reduceMotion = useReduceMotion();
68
+ const wash = useSharedValue(reduceMotion ? 0.5 : 1);
69
+ useEffect(() => {
70
+ if (reduceMotion) {
71
+ wash.set(0.5);
72
+ return;
73
+ }
74
+ wash.set(1);
75
+ wash.set(withTiming(0, { duration: MESSAGE_HIGHLIGHT_MS }));
76
+ }, [wash, reduceMotion]);
77
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: wash.get() }));
78
+ return (
79
+ <Animated.View
80
+ testID="message-highlight"
81
+ pointerEvents="none"
82
+ style={[
83
+ StyleSheet.absoluteFill,
84
+ {
85
+ borderRadius: tokens.radius.lg,
86
+ borderWidth: 1.5,
87
+ borderColor: withAlpha(palette.primary[scheme], 0.5),
88
+ backgroundColor: withAlpha(palette.primary[scheme], HIGHLIGHT_WASH),
89
+ },
90
+ animatedStyle,
91
+ ]}
92
+ />
93
+ );
94
+ }
95
+
29
96
  /**
30
97
  * One message: the web `w-full max-w-[95%] flex-col gap-2`. A user message
31
98
  * sits at the end of the row with its parts at the end; an assistant message
32
99
  * at the start, its parts full width.
100
+ *
101
+ * With `onLongPress` or `actions` the message is a `Pressable` (same layout,
102
+ * `className`, `style` and view props) and a long press opens the
103
+ * `MessageActionSheet`; TalkBack reaches it as the `longpress` action. It stays
104
+ * a non-accessible container so its buttons and links keep their own focus:
105
+ * also offer the actions in a `MessageActionsBar` for screen reader users. `highlighted` draws the quote-jump highlight.
33
106
  */
34
- export function Message({ from, className, style, ...props }: MessageProps) {
107
+ export function Message({
108
+ from,
109
+ className,
110
+ style,
111
+ onLongPress,
112
+ actions,
113
+ actionSheetTitle,
114
+ highlighted = false,
115
+ bot,
116
+ children,
117
+ ...props
118
+ }: MessageProps) {
35
119
  const user = from === "user";
120
+ const [sheetOpen, setSheetOpen] = useState(false);
121
+ const hasActions = actions != null && actions.length > 0;
122
+ const longPressable = onLongPress != null || hasActions;
123
+ const longPress = () => {
124
+ onLongPress?.();
125
+ if (hasActions) setSheetOpen(true);
126
+ };
127
+ const layout: ViewProps["style"] = [
128
+ { alignSelf: user ? "flex-end" : "flex-start", alignItems: user ? "flex-end" : "stretch" },
129
+ style,
130
+ ];
131
+ const merged = cn("w-full max-w-[95%] gap-2", className);
132
+ const parts = (
133
+ <>
134
+ {bot && !user ? <MessageBotLabel name={bot.name} avatar={bot.avatar} /> : null}
135
+ {children}
136
+ {highlighted ? <MessageHighlight /> : null}
137
+ </>
138
+ );
36
139
  return (
37
140
  <MessageContext.Provider value={from}>
38
- <View
39
- className={cn("w-full max-w-[95%] gap-2", className)}
40
- style={[
41
- { alignSelf: user ? "flex-end" : "flex-start", alignItems: user ? "flex-end" : "stretch" },
42
- style,
43
- ]}
44
- {...props}
45
- />
141
+ {longPressable ? (
142
+ <>
143
+ <Pressable
144
+ accessible={false}
145
+ className={merged}
146
+ style={layout}
147
+ onLongPress={longPress}
148
+ // TalkBack's actions menu reaches the same sheet as a long press.
149
+ accessibilityActions={[{ name: "longpress", label: actionSheetTitle ?? MESSAGE_ACTIONS_TITLE }]}
150
+ onAccessibilityAction={(event) => {
151
+ if (event.nativeEvent.actionName === "longpress") longPress();
152
+ }}
153
+ {...props}
154
+ >
155
+ {parts}
156
+ </Pressable>
157
+ {hasActions ? (
158
+ <MessageActionSheet
159
+ open={sheetOpen}
160
+ onClose={() => setSheetOpen(false)}
161
+ actions={actions}
162
+ title={actionSheetTitle}
163
+ />
164
+ ) : null}
165
+ </>
166
+ ) : (
167
+ <View className={merged} style={layout} {...props}>
168
+ {parts}
169
+ </View>
170
+ )}
46
171
  </MessageContext.Provider>
47
172
  );
48
173
  }
@@ -62,8 +187,8 @@ export function userBubbleStyle(scheme: "light" | "dark") {
62
187
  /**
63
188
  * A user bubble's look. `primary` is the web default (`bg-primary` with the
64
189
  * label colour). `tint` is the chat override Eva uses (`rounded-surface
65
- * bg-primary/10 px-3 py-2 text-foreground`): a 10% wash of the primary colour
66
- * with foreground text and every corner at 16.
190
+ * bg-primary/10 text-foreground`, still `px-4 py-3`): a 10% wash of the
191
+ * primary colour with foreground text and every corner at 16.
67
192
  */
68
193
  export type MessageBubble = "primary" | "tint";
69
194
 
@@ -75,54 +200,112 @@ export function tintBubbleStyle(scheme: "light" | "dark") {
75
200
  };
76
201
  }
77
202
 
203
+ export type MessageBotLabelProps = {
204
+ name: string;
205
+ /** An avatar node, such as `<Avatar size="xs" />`. Drawn before the name. */
206
+ avatar?: ReactNode;
207
+ className?: string;
208
+ testID?: string;
209
+ };
210
+
211
+ /**
212
+ * Which bot sent a message, for group chats: an optional avatar and the name
213
+ * in the caption size. `Message` draws it for its `bot` prop. To compose it
214
+ * yourself, render it above `MessageContent` (for example in `renderMessage`).
215
+ */
216
+ export function MessageBotLabel({ name, avatar, className, testID = "message-bot-label" }: MessageBotLabelProps) {
217
+ const scheme = useScheme();
218
+ const largerText = useLargerText();
219
+ const size = chatTextSize("caption", largerText);
220
+ return (
221
+ <View
222
+ testID={testID}
223
+ accessible
224
+ accessibilityLabel={`From ${name}`}
225
+ className={cn("flex-row items-center gap-1.5", className)}
226
+ >
227
+ {avatar}
228
+ <Text
229
+ numberOfLines={1}
230
+ className={cn(size, "font-instrument-sans-semibold")}
231
+ style={[sizeStyle(size), typeStyle("semibold"), { color: tokens.color.muted[scheme] }]}
232
+ >
233
+ {name}
234
+ </Text>
235
+ </View>
236
+ );
237
+ }
238
+
78
239
  export type MessageContentProps = ViewProps & {
79
240
  /** User messages only. Default `"primary"`. */
80
241
  bubble?: MessageBubble;
242
+ /** A `MessageQuote` drawn above the content, inside the bubble. */
243
+ quote?: ReactNode;
81
244
  className?: string;
82
245
  };
83
246
 
84
247
  /**
85
- * The message body. A user message is a filled bubble: by default `px-4 py-3`
86
- * with the primary fill and label colour, or with `bubble="tint"` `px-3 py-2`
87
- * on a 10% primary wash with the foreground colour. An assistant message has
248
+ * The message body. A user message is a filled bubble, `px-4 py-3`: by
249
+ * default with the primary fill and label colour, or with `bubble="tint"` on
250
+ * a 10% primary wash with the foreground colour. An assistant message has
88
251
  * no fill and the foreground colour. A string child is drawn as
89
252
  * `ResponseText`, and `ResponseText` inside takes the colour.
90
253
  */
91
- export function MessageContent({ children, bubble = "primary", className, style, ...props }: MessageContentProps) {
254
+ export function MessageContent({ children, bubble = "primary", quote, className, style, ...props }: MessageContentProps) {
92
255
  const from = useContext(MessageContext);
93
256
  const scheme = useScheme();
94
257
  const user = from === "user";
95
258
  const tint = user && bubble === "tint";
96
259
  const color = user && !tint ? palette.primaryForeground[scheme] : tokens.color.foreground[scheme];
97
260
  const bubbleStyle = !user ? null : tint ? tintBubbleStyle(scheme) : userBubbleStyle(scheme);
261
+ const body = typeof children === "string" ? <ResponseText>{children}</ResponseText> : children;
98
262
  return (
99
263
  <ChatTextColorContext.Provider value={color}>
100
264
  <View
101
- className={cn("min-w-0 max-w-full gap-2", user && (tint ? "px-3 py-2" : "px-4 py-3"), className)}
265
+ className={cn("min-w-0 max-w-full gap-2", user && "px-4 py-3", className)}
102
266
  style={[bubbleStyle, style]}
103
267
  {...props}
104
268
  >
105
- {typeof children === "string" ? <ResponseText>{children}</ResponseText> : children}
269
+ {quote ? (
270
+ <>
271
+ {quote}
272
+ {body}
273
+ </>
274
+ ) : (
275
+ body
276
+ )}
106
277
  </View>
107
278
  </ChatTextColorContext.Provider>
108
279
  );
109
280
  }
110
281
 
111
- export type MessageMetaProps = { children: ReactNode; className?: string };
282
+ export type MessageMetaProps = {
283
+ children: ReactNode;
284
+ /**
285
+ * Default `caption` (`text-xs`, 12 / 16). `2xs` is the web `text-2xs` token
286
+ * (11 / 16), as Eva's web chat. Larger text draws both at the caption step.
287
+ */
288
+ size?: "caption" | "2xs";
289
+ className?: string;
290
+ };
112
291
 
113
292
  /**
114
293
  * The muted clock and duration after a message's actions, as Eva's web chat
115
- * (`text-2xs tabular-nums text-muted-foreground/60`, "10:21 PM · 1m"): the
116
- * caption size on phones, the muted colour at 60%, tabular figures.
294
+ * (`tabular-nums text-muted-foreground/60`, "10:21 PM · 1m"): the caption
295
+ * size (or `size="2xs"`), the muted colour at 60%, tabular figures.
117
296
  */
118
- export function MessageMeta({ children, className }: MessageMetaProps) {
297
+ export function MessageMeta({ children, size = "caption", className }: MessageMetaProps) {
119
298
  const scheme = useScheme();
120
299
  const largerText = useLargerText();
121
- const size = chatTextSize("caption", largerText);
300
+ const caption = chatTextSize("caption", largerText);
301
+ const tiny = size === "2xs" && !largerText;
122
302
  return (
123
303
  <Text
124
- className={cn(size, "font-instrument-sans", className)}
125
- style={[sizeStyle(size), { color: withAlpha(tokens.color.muted[scheme], 0.6), fontVariant: ["tabular-nums"] }]}
304
+ className={cn(!tiny && caption, "font-instrument-sans", className)}
305
+ style={[
306
+ tiny ? META_TEXT_STYLE : sizeStyle(caption),
307
+ { color: withAlpha(tokens.color.muted[scheme], 0.6), fontVariant: ["tabular-nums"] },
308
+ ]}
126
309
  >
127
310
  {children}
128
311
  </Text>
@@ -138,6 +321,7 @@ export function MessageActionsBar({ className, ...props }: MessageActionsBarProp
138
321
 
139
322
  /** The usual actions, with their icons and accessibility labels. */
140
323
  export const MESSAGE_ACTIONS = {
324
+ reply: { icon: IconArrowBackUp, label: "Reply" },
141
325
  copy: { icon: IconCopy, label: "Copy" },
142
326
  retry: { icon: IconRefresh, label: "Retry" },
143
327
  like: { icon: IconThumbUp, label: "Good response" },
@@ -159,12 +343,20 @@ export type MessageActionProps = {
159
343
  */
160
344
  selected?: boolean;
161
345
  disabled?: boolean;
346
+ /**
347
+ * Default `sm`, the web `icon-sm` (32, 16 icon). `xs` is the web `icon-xs`
348
+ * (28, 14 icon), as Eva's web chat; `touch` is a whole 44 square (16 icon)
349
+ * for a phone, so the row needs no slop.
350
+ */
351
+ size?: ChatIconButtonSize;
162
352
  testID?: string;
163
353
  };
164
354
 
165
355
  /**
166
- * A ghost icon button under a message: the web `icon-sm` (32, 16 icon) with
167
- * the slop to 44. Copying is up to the app (`onPress`): React Native's own
356
+ * A ghost icon button under a message: the web `icon-sm` (32, 16 icon), or
357
+ * `icon-xs` with `size="xs"`, with the slop to 44 high. It has no sideways
358
+ * slop: the actions sit 2 to 4 apart, and slop there would overlap the next
359
+ * one, so a tap on Copy could fork or rate instead. Copying is up to the app (`onPress`): React Native's own
168
360
  * `Clipboard` is deprecated, and native-ui adds no clipboard package.
169
361
  */
170
362
  export function MessageAction({
@@ -174,6 +366,7 @@ export function MessageAction({
174
366
  onPress,
175
367
  selected = false,
176
368
  disabled,
369
+ size,
177
370
  testID,
178
371
  }: MessageActionProps) {
179
372
  const scheme = useScheme();
@@ -187,6 +380,8 @@ export function MessageAction({
187
380
  accessibilityState={{ selected }}
188
381
  onPress={onPress}
189
382
  disabled={disabled}
383
+ size={size}
384
+ slop="vertical"
190
385
  testID={testID}
191
386
  />
192
387
  );
@@ -0,0 +1,197 @@
1
+ import { Children, useEffect, type ReactNode } from "react";
2
+ import { BackHandler, Pressable, ScrollView, Text, View, useWindowDimensions } from "react-native";
3
+ import { cn } from "../cn";
4
+ import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
5
+ import { palette, withAlpha } from "../palette";
6
+ import { useLargerText } from "../theme";
7
+ import { tokens } from "../tokens";
8
+ import { useScheme } from "../useScheme";
9
+
10
+ /**
11
+ * The web panel picker height (`computePanelPopupPlacement`): at most 340 and
12
+ * at least 160, here a share of the window, as a phone has the keyboard up.
13
+ */
14
+ export const PROMPT_INPUT_MENU = { maxHeight: 340, minHeight: 160, windowShare: 0.35 } as const;
15
+
16
+ /** Menu height for a window height: the share, kept inside the web's 160 to 340. */
17
+ export function promptInputMenuHeight(windowHeight: number): number {
18
+ return Math.round(
19
+ Math.max(PROMPT_INPUT_MENU.minHeight, Math.min(PROMPT_INPUT_MENU.maxHeight, windowHeight * PROMPT_INPUT_MENU.windowShare)),
20
+ );
21
+ }
22
+
23
+ export type PromptInputMenuProps = {
24
+ /** Names the list for VoiceOver and TalkBack, as the web listbox `aria-label` (such as "Mentions" or "Skills"). */
25
+ title: string;
26
+ /** `PromptInputMenuItem`s. With none, `empty` shows instead. */
27
+ children?: ReactNode;
28
+ /** Shown when there are no rows. Default "No matches.", as the web. */
29
+ empty?: ReactNode;
30
+ /**
31
+ * Closes the picker, as Escape does on the web. Android back calls it while
32
+ * the picker is mounted, before the screen behind gets the press.
33
+ */
34
+ onDismiss?: () => void;
35
+ testID?: string;
36
+ };
37
+
38
+ /**
39
+ * The web `@` / `/` picker (`MentionPickerPopup`, `panel` layout) for touch:
40
+ * the composer's full width, docked right above the field, so it rides the
41
+ * keyboard with it. Give it to `PromptInput` as `menu`. The popover surface
42
+ * (`rounded-surface bg-popover`, the field border in place of the web shadow
43
+ * ring, as the AI parts draw no shadow) holds a `py-1` list that scrolls past
44
+ * `promptInputMenuHeight`. Taps reach a row while the keyboard
45
+ * is up, so picking never drops the keyboard.
46
+ */
47
+ export function PromptInputMenu({ title, children, empty = "No matches.", onDismiss, testID }: PromptInputMenuProps) {
48
+ const scheme = useScheme();
49
+ // Back closes the picker first. An effect: the subscription is a native one.
50
+ useEffect(() => {
51
+ if (!onDismiss) return;
52
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
53
+ onDismiss();
54
+ return true;
55
+ });
56
+ return () => subscription.remove();
57
+ }, [onDismiss]);
58
+ const { height } = useWindowDimensions();
59
+ const hasRows = Children.toArray(children).length > 0;
60
+ return (
61
+ <View
62
+ testID={testID}
63
+ className="overflow-hidden border"
64
+ style={{
65
+ backgroundColor: palette.overlay[scheme],
66
+ borderColor: tokens.color.border[scheme],
67
+ borderRadius: tokens.radius.lg,
68
+ maxHeight: promptInputMenuHeight(height),
69
+ }}
70
+ >
71
+ {hasRows ? (
72
+ <ScrollView
73
+ accessibilityRole="list"
74
+ accessibilityLabel={title}
75
+ keyboardShouldPersistTaps="always"
76
+ contentContainerClassName="py-1"
77
+ >
78
+ {children}
79
+ </ScrollView>
80
+ ) : (
81
+ // Web: `px-3 py-2.5 text-xs leading-relaxed text-muted-foreground`.
82
+ <Text
83
+ className="px-3 py-2.5 font-instrument-sans text-xs"
84
+ style={[sizeStyle("text-xs", "relaxed"), textStyle("muted", scheme)]}
85
+ >
86
+ {empty}
87
+ </Text>
88
+ )}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ /** Web badge chip: `rounded-md bg-muted px-1.5 py-0.5 text-3xs font-medium` (10). */
94
+ const BADGE_TEXT = { fontSize: 10, lineHeight: 12 } as const;
95
+
96
+ export type PromptInputMenuItemProps = {
97
+ /** The character that inserts this row, muted before the label: `@` or `/`. Left out when `leading` names a person. */
98
+ prefix?: string;
99
+ label: string;
100
+ /** One muted line under the label, such as a skill's description. */
101
+ description?: string;
102
+ /** Before the text, such as a teammate's `Avatar` (24). */
103
+ leading?: ReactNode;
104
+ /** At the end in place of `badge`, such as a 16 kind icon. */
105
+ trailing?: ReactNode;
106
+ /** A small source chip at the end (such as "Eva" or "Claude"), with an optional 11 `badgeIcon`. */
107
+ badge?: string;
108
+ badgeIcon?: ReactNode;
109
+ /** The row Return picks, tinted as the web's keyboard-selected row. */
110
+ selected?: boolean;
111
+ onPress?: () => void;
112
+ testID?: string;
113
+ };
114
+
115
+ /** A description longer than this is cut with "…", as the web `previewOneLine`. */
116
+ const DESCRIPTION_MAX = 72;
117
+
118
+ function oneLine(text: string): string {
119
+ const line = text.replace(/\s+/g, " ").trim();
120
+ return line.length <= DESCRIPTION_MAX ? line : `${line.slice(0, DESCRIPTION_MAX - 1)}…`;
121
+ }
122
+
123
+ /**
124
+ * One picker row, the web `MentionRow` in its option button
125
+ * (`mx-1 rounded-lg px-2 text-sm`), at least 44 high. `selected` takes the
126
+ * web `bg-primary/15` (`/25` dark) and a medium label; a press fades to 70%,
127
+ * as the composer chips, in place of the web hover fill.
128
+ */
129
+ export function PromptInputMenuItem({
130
+ prefix,
131
+ label,
132
+ description,
133
+ leading,
134
+ trailing,
135
+ badge,
136
+ badgeIcon,
137
+ selected = false,
138
+ onPress,
139
+ testID,
140
+ }: PromptInputMenuItemProps) {
141
+ const scheme = useScheme();
142
+ const largerText = useLargerText();
143
+ const muted = textStyle("muted", scheme);
144
+ const labelSize = sizeStyle(largerText ? "text-lg" : "text-sm");
145
+ const detail = description ? oneLine(description) : undefined;
146
+ const end =
147
+ trailing ??
148
+ (badge ? (
149
+ <View
150
+ className="shrink-0 flex-row items-center gap-1 px-1.5 py-0.5"
151
+ style={{ backgroundColor: tokens.color.surfaceSecondary[scheme], borderRadius: 6 }}
152
+ >
153
+ {badgeIcon}
154
+ <Text className="font-instrument-sans-medium" style={[BADGE_TEXT, muted, typeStyle("medium")]}>
155
+ {badge}
156
+ </Text>
157
+ </View>
158
+ ) : null);
159
+ return (
160
+ <Pressable
161
+ testID={testID}
162
+ accessibilityRole="button"
163
+ accessibilityState={{ selected }}
164
+ accessibilityLabel={[`${prefix ?? ""}${label}`, detail, badge].filter(Boolean).join(", ")}
165
+ onPress={onPress}
166
+ // Already 44 high and the list's width: no slop past the row.
167
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
168
+ // A plain style: NativeWind drops a function `style` beside `className`.
169
+ className={cn("mx-1 flex-row items-center gap-2 px-2 py-1.5 active:opacity-70", largerText && "py-2")}
170
+ style={{
171
+ minHeight: MIN_TOUCH_TARGET,
172
+ borderRadius: 8,
173
+ backgroundColor: selected ? withAlpha(palette.primary[scheme], scheme === "dark" ? 0.25 : 0.15) : "transparent",
174
+ }}
175
+ >
176
+ {leading}
177
+ <View className="min-w-0 flex-1 gap-0.5">
178
+ <View className="min-w-0 flex-row items-center gap-1.5">
179
+ <Text
180
+ numberOfLines={1}
181
+ className={cn("min-w-0 flex-1", selected ? "font-instrument-sans-medium" : "font-instrument-sans")}
182
+ style={[labelSize, textStyle("foreground", scheme), selected ? typeStyle("medium") : null]}
183
+ >
184
+ {prefix ? <Text style={muted}>{prefix}</Text> : null}
185
+ {label}
186
+ </Text>
187
+ {end}
188
+ </View>
189
+ {detail ? (
190
+ <Text numberOfLines={1} className="font-instrument-sans text-xs" style={[sizeStyle("text-xs"), muted]}>
191
+ {detail}
192
+ </Text>
193
+ ) : null}
194
+ </View>
195
+ </Pressable>
196
+ );
197
+ }