@vvedantb/native-ui 0.13.0 → 0.17.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.
Files changed (111) hide show
  1. package/README.md +42 -21
  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/chat-text.ts +13 -7
  36. package/src/ai/chat-thread.tsx +34 -27
  37. package/src/ai/choice-question.tsx +154 -35
  38. package/src/ai/code-block.tsx +189 -49
  39. package/src/ai/collapsible.tsx +23 -13
  40. package/src/ai/conversation.tsx +394 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +55 -4
  43. package/src/ai/message-action-sheet.tsx +4 -1
  44. package/src/ai/message.tsx +60 -31
  45. package/src/ai/prompt-input-menu.tsx +197 -0
  46. package/src/ai/prompt-input.tsx +301 -75
  47. package/src/ai/shimmer.tsx +4 -6
  48. package/src/ai/subagent-cards.tsx +84 -14
  49. package/src/ai/tool-approval-card.tsx +139 -8
  50. package/src/ai/trace.tsx +54 -17
  51. package/src/ai/unified-diff.tsx +182 -0
  52. package/src/auth/GoogleSignInButton.tsx +6 -3
  53. package/src/auth/SignInScreen.tsx +11 -2
  54. package/src/brand/ProviderIcon.tsx +136 -0
  55. package/src/brand/index.ts +2 -0
  56. package/src/configureTheme.ts +39 -11
  57. package/src/controlStyles.ts +46 -30
  58. package/src/entries/gesture-handler.ts +4 -0
  59. package/src/entries/keyboard-controller.ts +4 -0
  60. package/src/entries/markdown.ts +1 -1
  61. package/src/entries/primitives.ts +24 -3
  62. package/src/entries/theme.ts +2 -1
  63. package/src/entries/video.ts +4 -0
  64. package/src/entries/webview.ts +6 -0
  65. package/src/examples/AiChatGallery.tsx +70 -0
  66. package/src/floating.ts +25 -4
  67. package/src/haptics.ts +30 -4
  68. package/src/highlight.ts +302 -1
  69. package/src/index.ts +27 -5
  70. package/src/loadingGrid.ts +75 -0
  71. package/src/markdown/Markdown.tsx +234 -32
  72. package/src/markdown/MarkdownTable.tsx +188 -0
  73. package/src/markdown/tableText.ts +58 -0
  74. package/src/marketing/UpgradeToProCard.tsx +4 -3
  75. package/src/media/ImageGallery.tsx +224 -0
  76. package/src/media/MediaCard.tsx +100 -0
  77. package/src/media/imageGrid.ts +51 -0
  78. package/src/motion.ts +33 -0
  79. package/src/pagerSwipe.ts +102 -0
  80. package/src/palette.ts +8 -1
  81. package/src/primitives/button.tsx +4 -1
  82. package/src/primitives/keyboard-controller.tsx +106 -0
  83. package/src/primitives/keyboard-parts.tsx +104 -0
  84. package/src/primitives/overlay.tsx +133 -45
  85. package/src/primitives/pager-gesture.tsx +131 -0
  86. package/src/primitives/sheet-drag.tsx +305 -0
  87. package/src/primitives/sheet-gesture.tsx +181 -0
  88. package/src/primitives/swipe-row-gesture.tsx +290 -0
  89. package/src/primitives/toast-drag-gesture.tsx +92 -0
  90. package/src/primitives/web-click-guard.ts +50 -0
  91. package/src/relativeTime.ts +94 -0
  92. package/src/router/RouterStackPageHeader.tsx +2 -2
  93. package/src/settings/SettingsEmptyState.tsx +20 -10
  94. package/src/settings/SettingsField.tsx +11 -5
  95. package/src/settings/SettingsSection.tsx +8 -5
  96. package/src/settings/SettingsToggleRow.tsx +55 -22
  97. package/src/settings/relaxedSizeStyle.ts +11 -0
  98. package/src/sheetDrag.ts +67 -0
  99. package/src/swipeRow.ts +169 -0
  100. package/src/tabBarRoutes.ts +18 -0
  101. package/src/tabIndicator.ts +71 -0
  102. package/src/tailwind-preset.cjs +13 -6
  103. package/src/tailwind-preset.d.cts +5 -0
  104. package/src/theme.tsx +6 -2
  105. package/src/toastDrag.ts +58 -0
  106. package/src/typography.ts +20 -3
  107. package/src/useScheme.ts +10 -1
  108. package/src/video/InlineVideo.tsx +91 -0
  109. package/src/webview/HtmlView.tsx +91 -0
  110. package/src/webview/HtmlView.web.tsx +21 -0
  111. package/src/webview/htmlView.ts +45 -0
@@ -0,0 +1,111 @@
1
+ import type { ReactNode } from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import { impactHaptic } from "./haptics";
4
+ import { tokens } from "./tokens";
5
+ import { useScheme } from "./useScheme";
6
+
7
+ /** The web `ListRow` metrics: a 4 wide stripe 8 in from top and bottom, the surface radius. */
8
+ export const SURFACE_ROW = {
9
+ accentWidth: 4,
10
+ accentInset: 8,
11
+ radius: tokens.radius.lg,
12
+ };
13
+
14
+ export type SurfaceRowProps = {
15
+ children: ReactNode;
16
+ /** Colour of the stripe down the leading edge: the row's status. Omit for a row with no status. */
17
+ accentColor?: string;
18
+ /** Fill. Default `tokens.color.surface`, the web `bg-card`. */
19
+ backgroundColor?: string;
20
+ /** A 1 wide hairline in this colour, such as an error row's tinted border. */
21
+ borderColor?: string;
22
+ /** Names the row for VoiceOver and TalkBack; the row is one button. */
23
+ accessibilityLabel: string;
24
+ /** Read after the label, such as the status and age. */
25
+ accessibilityHint?: string;
26
+ onPress: () => void;
27
+ /**
28
+ * A long press, such as the row menu (the web right-click), with a light
29
+ * haptic as it fires. VoiceOver and TalkBack reach it as the `longpress`
30
+ * action, named by `longPressLabel`.
31
+ */
32
+ onLongPress?: () => void;
33
+ /** The spoken name of the long-press action. Default "More actions". */
34
+ longPressLabel?: string;
35
+ /** Classes on the body, in place of the web row inset `p-2.5 pl-3`. */
36
+ className?: string;
37
+ testID?: string;
38
+ };
39
+
40
+ /**
41
+ * The shell of a status list row, as the web `ListRow`: a card surface with
42
+ * the surface radius, a 4 wide status stripe on the leading edge (inset 8 top
43
+ * and bottom, rounded on its outer side) and the body over it. The whole row
44
+ * is one button that fades while pressed, so put no other control in it; a
45
+ * long press (`onLongPress`) can open its menu.
46
+ */
47
+ export function SurfaceRow({
48
+ children,
49
+ accentColor,
50
+ backgroundColor,
51
+ borderColor,
52
+ accessibilityLabel,
53
+ accessibilityHint,
54
+ onPress,
55
+ onLongPress,
56
+ longPressLabel = "More actions",
57
+ className,
58
+ testID,
59
+ }: SurfaceRowProps) {
60
+ const scheme = useScheme();
61
+ const style = {
62
+ backgroundColor: backgroundColor ?? tokens.color.surface[scheme],
63
+ borderRadius: SURFACE_ROW.radius,
64
+ borderWidth: borderColor ? 1 : 0,
65
+ borderColor,
66
+ };
67
+ const body = (
68
+ <>
69
+ {accentColor ? (
70
+ <View
71
+ testID={testID ? `${testID}-accent` : undefined}
72
+ className="absolute left-0"
73
+ style={{
74
+ top: SURFACE_ROW.accentInset,
75
+ bottom: SURFACE_ROW.accentInset,
76
+ width: SURFACE_ROW.accentWidth,
77
+ backgroundColor: accentColor,
78
+ borderTopRightRadius: SURFACE_ROW.accentWidth,
79
+ borderBottomRightRadius: SURFACE_ROW.accentWidth,
80
+ }}
81
+ />
82
+ ) : null}
83
+ <View className={className ?? "p-2.5 pl-3"}>{children}</View>
84
+ </>
85
+ );
86
+ return (
87
+ <Pressable
88
+ testID={testID}
89
+ accessibilityRole="button"
90
+ accessibilityLabel={accessibilityLabel}
91
+ accessibilityHint={accessibilityHint}
92
+ onPress={onPress}
93
+ onLongPress={
94
+ onLongPress
95
+ ? () => {
96
+ impactHaptic("light");
97
+ onLongPress();
98
+ }
99
+ : undefined
100
+ }
101
+ accessibilityActions={onLongPress ? [{ name: "longpress", label: longPressLabel }] : undefined}
102
+ onAccessibilityAction={(event) => {
103
+ if (event.nativeEvent.actionName === "longpress") onLongPress?.();
104
+ }}
105
+ className="overflow-hidden active:opacity-80"
106
+ style={style}
107
+ >
108
+ {body}
109
+ </Pressable>
110
+ );
111
+ }
@@ -0,0 +1,43 @@
1
+ import { createContext, useContext, type ComponentType, type ReactNode } from "react";
2
+ import type { SwipeAction } from "./swipeRow";
3
+
4
+ export type SwipeableRowProps = {
5
+ /** Revealed at the left by a swipe right. The first one commits on a full swipe and sits at the screen edge. */
6
+ leadingActions?: readonly SwipeAction[];
7
+ /** Revealed at the right by a swipe left. The first one commits on a full swipe and sits at the screen edge. */
8
+ trailingActions?: readonly SwipeAction[];
9
+ /** `false` (such as in select mode) leaves the row still under the finger. */
10
+ enabled?: boolean;
11
+ /** The row's fill while it moves, so the actions behind do not show through. Default the screen background. */
12
+ backgroundColor?: string;
13
+ children: ReactNode;
14
+ };
15
+
16
+ /**
17
+ * The Gesture Handler row, from `@vvedantb/native-ui/gesture-handler`
18
+ * (`GestureHandlerProvider`). A context, so only that entry imports
19
+ * `react-native-gesture-handler`.
20
+ */
21
+ export const SwipeRowGestureContext = createContext<ComponentType<SwipeableRowProps> | null>(null);
22
+
23
+ /**
24
+ * A list row that a horizontal swipe moves aside, to reveal its actions
25
+ * behind it, as the iOS and Android mail apps. The row follows the finger
26
+ * 1:1 and springs on at the finger's speed when it lifts: past half its
27
+ * buttons it stays open; a full swipe (half the row, with a light haptic as
28
+ * it arms) runs the first action, and an action that `exits` slides the row
29
+ * out. Toward a side with no actions it gives a little, with resistance. A
30
+ * vertical drag scrolls the list first. One row is open at a time: another
31
+ * row opening, a tap on any row, or `closeSwipeRows` (pass it to the list's
32
+ * `onScrollBeginDrag`) closes it. With Reduce Motion on it still follows the
33
+ * finger, and the release settles at once.
34
+ *
35
+ * The swipe needs `GestureHandlerProvider`; without it the row is drawn as
36
+ * is, with no swipe. Either way, spread `swipeActionsAccessibility(actions)`
37
+ * on the row's main pressable, so screen reader users get the same actions.
38
+ */
39
+ export function SwipeableRow(props: SwipeableRowProps) {
40
+ const GestureRow = useContext(SwipeRowGestureContext);
41
+ if (!GestureRow) return <>{props.children}</>;
42
+ return <GestureRow {...props} />;
43
+ }
package/src/Switch.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as React from "react";
2
2
  import { Switch as PrimitiveSwitch } from "./primitives/switch";
3
3
  import { cn } from "./cn";
4
- import { switchColors, switchSize, touchSlop } from "./controlStyles";
4
+ import { switchColors, switchSize, touchSlop, type SwitchSize } from "./controlStyles";
5
5
  import { useLargerText } from "./theme";
6
6
  import { useScheme } from "./useScheme";
7
7
 
@@ -16,16 +16,18 @@ type SwitchProps = Omit<
16
16
  /** Track fill when on or off, in place of the `palette` colours. */
17
17
  onColor?: string;
18
18
  offColor?: string;
19
+ /** `sm` (default) is the web `Switch`, 36 × 20; `md` is 40 × 24 with a 20 thumb. */
20
+ size?: SwitchSize;
19
21
  };
20
22
 
21
23
  const Switch = React.forwardRef<
22
24
  React.ElementRef<typeof PrimitiveSwitch>,
23
25
  SwitchProps
24
- >(({ value, checked, onValueChange, onCheckedChange, onColor, offColor, ...props }, ref) => {
26
+ >(({ value, checked, onValueChange, onCheckedChange, onColor, offColor, size: sizeName, ...props }, ref) => {
25
27
  const resolvedValue = checked ?? value ?? false;
26
28
  const colors = switchColors(resolvedValue, useScheme());
27
29
  const trackColor = resolvedValue ? onColor : offColor;
28
- const size = switchSize(useLargerText());
30
+ const size = switchSize(useLargerText(), sizeName);
29
31
 
30
32
  const handleCheckedChange = (nextValue: boolean) => {
31
33
  onCheckedChange?.(nextValue);
@@ -45,7 +47,7 @@ const Switch = React.forwardRef<
45
47
  trackColor ? { backgroundColor: trackColor, borderColor: trackColor } : colors.track
46
48
  }
47
49
  thumbStyle={colors.thumb}
48
- thumbClassName={cn(size.thumb, resolvedValue ? size.on : "translate-x-0")}
50
+ thumbClassName={cn(size.thumb, resolvedValue ? size.on : size.off)}
49
51
  {...props}
50
52
  accessibilityState={{
51
53
  ...props.accessibilityState,
package/src/Tabs.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import { createContext, useContext, type ReactNode } from "react";
2
- import { Pressable, Text, View, type ViewProps } from "react-native";
1
+ import { Children, createContext, isValidElement, useContext, type ReactNode } from "react";
2
+ import { Pressable, View, type ViewProps } from "react-native";
3
+ import Animated, { type SharedValue } from "react-native-reanimated";
3
4
  import { cn } from "./cn";
4
5
  import {
5
6
  MIN_TOUCH_TARGET,
@@ -13,6 +14,7 @@ import {
13
14
  } from "./controlStyles";
14
15
  import { selectionHaptic } from "./haptics";
15
16
  import { useControllableState } from "./primitives/use-controllable-state";
17
+ import { useTabIndicator, useTabLabelStyle } from "./tabIndicator";
16
18
  import { useLargerText } from "./theme";
17
19
  import { useScheme } from "./useScheme";
18
20
 
@@ -27,8 +29,13 @@ const TabsContext = createContext<{
27
29
  const TabsListContext = createContext<{
28
30
  variant: TabsListVariant;
29
31
  size: TabsSize;
32
+ progress?: SharedValue<number>;
33
+ indicator?: ReturnType<typeof useTabIndicator>;
30
34
  }>({ variant: "pills", size: "default" });
31
35
 
36
+ /** A trigger's place in its list, for the linked indicator and label colour. */
37
+ const TabIndexContext = createContext(0);
38
+
32
39
  function useTabs(part: string) {
33
40
  const context = useContext(TabsContext);
34
41
  if (!context) throw new Error(`${part} must be inside Tabs.`);
@@ -68,36 +75,67 @@ export type TabsListProps = ViewProps & {
68
75
  variant?: TabsListVariant;
69
76
  /** `sm` makes the triggers smaller. */
70
77
  size?: TabsSize;
78
+ /**
79
+ * Where a linked `Pager` is, in pages: the first trigger is page 0. With it,
80
+ * as in `PillTabs`, the selected fill is one indicator that slides with the
81
+ * pages and each label blends to the selected colour with it. The triggers
82
+ * are the list's direct children, in page order.
83
+ */
84
+ progress?: SharedValue<number>;
71
85
  className?: string;
72
86
  };
73
87
 
74
88
  export function TabsList({
75
89
  variant = "pills",
76
90
  size = "default",
91
+ progress,
77
92
  className,
78
93
  style,
94
+ children,
79
95
  ...props
80
96
  }: TabsListProps) {
81
97
  const scheme = useScheme();
98
+ const triggers = Children.toArray(children);
99
+ const indicator = useTabIndicator(progress, triggers.length);
82
100
  return (
83
- <TabsListContext.Provider value={{ variant, size }}>
101
+ <TabsListContext.Provider value={{ variant, size, progress, indicator }}>
84
102
  <View
85
103
  accessibilityRole="tablist"
86
104
  className={cn(
87
105
  "flex-row",
88
106
  // The web lists: `segmented` and `pills` are `inline-flex` (as wide
89
107
  // as their tabs), `soft` a full-width row of centred, wrapping pills.
90
- // Pills wrap rather than run off a phone screen.
108
+ // Pills wrap rather than run off a phone screen, except under a
109
+ // linked indicator, which slides along one line.
91
110
  variant === "segmented"
92
111
  ? "max-w-full gap-[3px] self-start rounded-full p-[3px]"
93
112
  : variant === "soft"
94
113
  ? "flex-wrap items-center justify-center gap-1.5"
95
- : "max-w-full flex-wrap gap-1.5 self-start",
114
+ : progress
115
+ ? "max-w-full gap-1.5 self-start"
116
+ : "max-w-full flex-wrap gap-1.5 self-start",
96
117
  className,
97
118
  )}
98
119
  style={variant === "segmented" ? [tabTrackStyle(scheme), style] : style}
99
120
  {...props}
100
- />
121
+ >
122
+ {indicator.measured ? (
123
+ // The selected fill under the triggers, as `PillTabs`.
124
+ <Animated.View
125
+ pointerEvents="none"
126
+ style={[
127
+ { position: "absolute", left: 0, borderRadius: 9999 },
128
+ { backgroundColor: tabColors(variant, true, scheme).backgroundColor },
129
+ indicator.style,
130
+ ]}
131
+ />
132
+ ) : null}
133
+ {triggers.map((trigger, index) => (
134
+ <TabIndexContext.Provider key={isValidElement(trigger) && trigger.key !== null ? trigger.key : index} value={index}>
135
+ {trigger}
136
+ </TabIndexContext.Provider>
137
+ ))}
138
+ </View>
101
139
  </TabsListContext.Provider>
102
140
  );
103
141
  }
@@ -107,6 +145,12 @@ export type TabsTriggerProps = {
107
145
  children: ReactNode;
108
146
  disabled?: boolean;
109
147
  className?: string;
148
+ /**
149
+ * The trigger height on screen, for an app whose web call site sizes its
150
+ * list (a `h-8` list with `p-1` has 24-high triggers). Default: the web
151
+ * `.t-tab` 30, or the larger-text size. The hit slop still reaches 44.
152
+ */
153
+ minHeight?: number;
110
154
  testID?: string;
111
155
  accessibilityLabel?: string;
112
156
  };
@@ -116,18 +160,27 @@ export function TabsTrigger({
116
160
  children,
117
161
  disabled,
118
162
  className,
163
+ minHeight,
119
164
  testID,
120
165
  accessibilityLabel,
121
166
  }: TabsTriggerProps) {
122
167
  const { value: current, setValue } = useTabs("TabsTrigger");
123
- const { variant, size } = useContext(TabsListContext);
168
+ const { variant, size, progress, indicator } = useContext(TabsListContext);
169
+ const index = useContext(TabIndexContext);
124
170
  const largerText = useLargerText();
125
171
  const scheme = useScheme();
126
172
  const selected = current === value;
127
173
  // Fill and label are styles from `tabColors`: apps' `bg-secondary` and
128
174
  // `bg-surface` can stay light in dark mode, under a light label.
129
175
  const colors = tabColors(variant, selected, scheme);
176
+ const labelStyle = useTabLabelStyle(
177
+ progress,
178
+ index,
179
+ { selected: tabColors(variant, true, scheme).color, muted: tabColors(variant, false, scheme).color },
180
+ colors.color,
181
+ );
130
182
  const trigger = tabTriggerSize(variant, size, largerText);
183
+ const height = minHeight ?? trigger.minHeight;
131
184
  // The web `.t-tab` is `font-medium`; the soft trigger is regular.
132
185
  const medium = variant !== "soft";
133
186
 
@@ -139,8 +192,8 @@ export function TabsTrigger({
139
192
  accessibilityLabel ?? (typeof children === "string" ? children : undefined)
140
193
  }
141
194
  accessibilityState={{ selected, disabled: Boolean(disabled) }}
142
- // The web height (30) on screen, a 44 target to touch.
143
- hitSlop={touchSlop(MIN_TOUCH_TARGET, trigger.minHeight)}
195
+ // The trigger height on screen (the web 30 by default), a 44 target to touch.
196
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, height)}
144
197
  disabled={disabled}
145
198
  onPress={() => {
146
199
  if (selected) return;
@@ -156,20 +209,22 @@ export function TabsTrigger({
156
209
  )}
157
210
  // A plain object: NativeWind puts the class styles and `style` in an
158
211
  // array, where a `({ pressed }) => ...` style is dropped.
159
- style={{ backgroundColor: colors.backgroundColor, minHeight: trigger.minHeight }}
212
+ // When linked and measured, the list's indicator draws the selected fill.
213
+ style={{ backgroundColor: indicator?.measured ? "transparent" : colors.backgroundColor, minHeight: height }}
214
+ onLayout={progress ? indicator?.onLayout(index) : undefined}
160
215
  >
161
216
  {typeof children === "string" || typeof children === "number" ? (
162
- <Text
217
+ <Animated.Text
163
218
  numberOfLines={1}
164
219
  className={cn(
165
220
  "text-center",
166
221
  medium ? "font-instrument-sans-medium" : "font-instrument-sans",
167
222
  trigger.textClassName,
168
223
  )}
169
- style={[{ color: colors.color }, medium ? typeStyle("medium") : null]}
224
+ style={[{ letterSpacing: trigger.letterSpacing }, medium ? typeStyle("medium") : null, labelStyle]}
170
225
  >
171
226
  {children}
172
- </Text>
227
+ </Animated.Text>
173
228
  ) : (
174
229
  children
175
230
  )}
package/src/Toast.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useSyncExternalStore } from "react";
1
+ import { Fragment, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
  import {
3
3
  AccessibilityInfo,
4
4
  ActivityIndicator,
@@ -9,6 +9,8 @@ import {
9
9
  } from "react-native";
10
10
  import { FadeInUp, FadeOutUp, LinearTransition } from "react-native-reanimated";
11
11
  import { useSafeAreaInsets } from "react-native-safe-area-context";
12
+ import { notificationHaptic } from "./haptics";
13
+ import { EASE_OUT } from "./motion";
12
14
  import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
13
15
  import Svg, { Path } from "react-native-svg";
14
16
  import { cn } from "./cn";
@@ -25,6 +27,7 @@ import { tokens } from "./tokens";
25
27
  import { useLargerText } from "./theme";
26
28
  import { useScheme } from "./useScheme";
27
29
  import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
30
+ import { ToastDragContext } from "./toastDrag";
28
31
  import { getToasts, subscribeToasts, toast, type ToastItem } from "./toastStore";
29
32
 
30
33
  // On iOS the overlay draws above RN `Modal`s, so toasts show over dialogs (as
@@ -33,6 +36,11 @@ const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : Fragment
33
36
 
34
37
  const toastShadow = toastShadowStyle(Platform.OS);
35
38
 
39
+ // Down from the top edge and back up the way it came, the exit 20% quicker:
40
+ // the reader is done with it.
41
+ const TOAST_ENTER = FadeInUp.duration(200).easing(EASE_OUT);
42
+ const TOAST_EXIT = FadeOutUp.duration(160).easing(EASE_OUT);
43
+
36
44
  /**
37
45
  * Renders the `toast()` queue at the top of the screen, inside the safe-area
38
46
  * insets. Mount once, inside `SafeAreaProvider` and after the navigator.
@@ -83,15 +91,39 @@ function ToastIcon({ item }: { item: ToastItem }) {
83
91
  );
84
92
  }
85
93
 
94
+ /**
95
+ * Closes `item` after its `duration`, counting only while it is not `held`:
96
+ * a finger on the toast pauses the time left, and lifting it resumes. A
97
+ * toast replaced in place (same id, new item) starts its own time afresh.
98
+ */
99
+ function useToastTimer(item: ToastItem, held: boolean) {
100
+ // The time left, and the item it counts for. Only the effect reads and writes it.
101
+ const left = useRef({ item, ms: item.duration });
102
+ useEffect(() => {
103
+ if (left.current.item !== item) left.current = { item, ms: item.duration };
104
+ if (held || !Number.isFinite(item.duration)) return;
105
+ const startedAt = Date.now();
106
+ const timer = setTimeout(() => toast.dismiss(item.id), left.current.ms);
107
+ return () => {
108
+ clearTimeout(timer);
109
+ left.current.ms -= Date.now() - startedAt;
110
+ };
111
+ }, [item, held]);
112
+ }
113
+
86
114
  function ToastCard({ item }: { item: ToastItem }) {
87
115
  const largerText = useLargerText();
88
116
  const scheme = useScheme();
117
+ // The swipe up to dismiss, under `GestureHandlerProvider`.
118
+ const Drag = useContext(ToastDragContext);
119
+ const [held, setHeld] = useState(false);
120
+ useToastTimer(item, held);
89
121
 
122
+ // A success or error toast reports an operation the user started: one
123
+ // notification haptic as it shows (or turns from loading into a result).
90
124
  useEffect(() => {
91
- if (!Number.isFinite(item.duration)) return;
92
- const timer = setTimeout(() => toast.dismiss(item.id), item.duration);
93
- return () => clearTimeout(timer);
94
- }, [item]);
125
+ if (item.variant === "success" || item.variant === "error") notificationHaptic(item.variant);
126
+ }, [item.id, item.variant]);
95
127
 
96
128
  useEffect(() => {
97
129
  AccessibilityInfo.announceForAccessibility(
@@ -99,72 +131,82 @@ function ToastCard({ item }: { item: ToastItem }) {
99
131
  );
100
132
  }, [item.title, item.description]);
101
133
 
102
- return (
103
- <NativeOnlyAnimatedView
104
- entering={FadeInUp.duration(200)}
105
- exiting={FadeOutUp.duration(160)}
106
- layout={LinearTransition}
107
- >
108
- {/* The web toast: flat overlay fill, radius 16, ring and a light shadow. */}
109
- <View style={[toastSurfaceStyle(scheme), toastShadow]}>
110
- <Pressable
111
- testID={`toast-${item.id}`}
112
- // With an action, the text and the action button stay separate
113
- // elements for VoiceOver and TalkBack instead of one grouped button.
114
- accessible={!item.action}
115
- accessibilityRole="button"
116
- accessibilityHint="Closes the message"
117
- onPress={() => toast.dismiss(item.id)}
118
- className="flex-row items-center gap-3 px-4 py-3"
119
- >
120
- <ToastIcon item={item} />
121
- <View className="min-w-0 flex-1">
122
- {/* Web `text-sm font-medium leading-snug` (14 / 19.25, 500); `text-lg` with larger text. */}
134
+ const surface = (
135
+ // The web toast: flat overlay fill, radius 16, ring and a light shadow.
136
+ <View style={[toastSurfaceStyle(scheme), toastShadow]}>
137
+ <Pressable
138
+ testID={`toast-${item.id}`}
139
+ // With an action, the text and the action button stay separate
140
+ // elements for VoiceOver and TalkBack instead of one grouped button.
141
+ accessible={!item.action}
142
+ accessibilityRole="button"
143
+ accessibilityHint="Closes the message"
144
+ onPress={() => toast.dismiss(item.id)}
145
+ className="flex-row items-center gap-3 px-4 py-3"
146
+ >
147
+ <ToastIcon item={item} />
148
+ <View className="min-w-0 flex-1">
149
+ {/* Web `text-sm font-medium leading-snug` (14 / 19.25, 500); `text-lg` with larger text. */}
150
+ <Text
151
+ className={cn(
152
+ "font-instrument-sans-medium",
153
+ largerText ? "text-lg" : "text-sm",
154
+ )}
155
+ style={[
156
+ textStyle("foreground", scheme),
157
+ typeStyle("medium"),
158
+ largerText ? sizeStyle("text-lg") : sizeStyle("text-sm", "snug"),
159
+ ]}
160
+ >
161
+ {item.title}
162
+ </Text>
163
+ {item.description ? (
123
164
  <Text
124
165
  className={cn(
125
- "font-instrument-sans-medium",
126
- largerText ? "text-lg" : "text-sm",
166
+ "font-instrument-sans",
167
+ largerText ? "text-base" : "text-sm",
127
168
  )}
128
- style={[
129
- textStyle("foreground", scheme),
130
- typeStyle("medium"),
131
- largerText ? sizeStyle("text-lg") : sizeStyle("text-sm", "snug"),
132
- ]}
169
+ style={textStyle("muted", scheme)}
133
170
  >
134
- {item.title}
171
+ {item.description}
135
172
  </Text>
136
- {item.description ? (
137
- <Text
138
- className={cn(
139
- "font-instrument-sans",
140
- largerText ? "text-base" : "text-sm",
141
- )}
142
- style={textStyle("muted", scheme)}
143
- >
144
- {item.description}
145
- </Text>
146
- ) : null}
147
- </View>
148
- {item.action ? (
149
- <Pressable
150
- accessibilityRole="button"
151
- hitSlop={8}
152
- onPress={() => {
153
- item.action?.onPress();
154
- toast.dismiss(item.id);
155
- }}
156
- className="rounded-full bg-secondary px-3 py-1.5 active:opacity-70"
157
- >
158
- <Text
159
- className="font-instrument-sans-semibold text-sm"
160
- style={[textStyle("foreground", scheme), typeStyle("semibold")]}
161
- >
162
- {item.action.label}
163
- </Text>
164
- </Pressable>
165
173
  ) : null}
166
- </Pressable>
167
- </View>
174
+ </View>
175
+ {item.action ? (
176
+ <Pressable
177
+ accessibilityRole="button"
178
+ hitSlop={8}
179
+ onPress={() => {
180
+ item.action?.onPress();
181
+ toast.dismiss(item.id);
182
+ }}
183
+ className="rounded-full bg-secondary px-3 py-1.5 active:opacity-70"
184
+ >
185
+ <Text
186
+ className="font-instrument-sans-semibold text-sm"
187
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
188
+ >
189
+ {item.action.label}
190
+ </Text>
191
+ </Pressable>
192
+ ) : null}
193
+ </Pressable>
194
+ </View>
195
+ );
196
+
197
+ return (
198
+ <NativeOnlyAnimatedView
199
+ entering={TOAST_ENTER}
200
+ exiting={TOAST_EXIT}
201
+ layout={LinearTransition}
202
+ >
203
+ {Drag ? (
204
+ <Drag onDismiss={() => toast.dismiss(item.id)} onHoldChange={setHeld}>
205
+ {surface}
206
+ </Drag>
207
+ ) : (
208
+ surface
209
+ )}
168
210
  </NativeOnlyAnimatedView>
169
211
  );
170
212
  }
@@ -6,6 +6,7 @@ import { useLargerText } from "../theme";
6
6
  import { tokens } from "../tokens";
7
7
  import { useScheme } from "../useScheme";
8
8
  import { MONO_FONT, chatTextSize } from "./chat-text";
9
+ import { EMERALD } from "./unified-diff";
9
10
  import { CodeBlock } from "./code-block";
10
11
 
11
12
  /** The step fields the detail reads: the web `ActivityStep` subset. */
@@ -30,9 +31,6 @@ export type ActivityStepDetailProps = {
30
31
  loadingLabel?: string;
31
32
  };
32
33
 
33
- /** Web `bg-emerald-500/10` and `text-emerald-700` (dark `text-emerald-300`). */
34
- const EMERALD = { fill: "#10b981", light: "#047857", dark: "#6ee7b7" } as const;
35
-
36
34
  /**
37
35
  * A tool step's body, as the web `ActivityStepDetail`: exit code and
38
36
  * "Truncated" badges, the shell transcript (`$ command`, a blank line, then