@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.
- package/README.md +82 -19
- package/package.json +36 -3
- package/src/BottomSheetContainer.tsx +2 -2
- package/src/BulkActionBar.tsx +240 -105
- package/src/Button.tsx +11 -7
- package/src/ConfirmDialog.tsx +25 -35
- package/src/Dialog.tsx +47 -35
- package/src/DividedList.tsx +11 -7
- package/src/DropdownMenu.tsx +22 -8
- package/src/FloatingSurface.tsx +5 -2
- package/src/FloatingTabBar.tsx +13 -22
- package/src/HeaderIconButton.tsx +4 -3
- package/src/Input.tsx +2 -2
- package/src/LoadingState.tsx +132 -0
- package/src/PageTitle.tsx +9 -2
- package/src/Pager.tsx +62 -0
- package/src/PasswordInput.tsx +1 -1
- package/src/PillTabs.tsx +144 -34
- package/src/Progress.tsx +9 -7
- package/src/Select.tsx +4 -4
- package/src/Sheet.tsx +29 -22
- package/src/SheetRow.tsx +27 -9
- package/src/Skeleton.tsx +91 -9
- package/src/SurfaceRow.tsx +111 -0
- package/src/SwipeableRow.tsx +43 -0
- package/src/Switch.tsx +6 -4
- package/src/Tabs.tsx +68 -13
- package/src/Toast.tsx +107 -65
- package/src/ai/activity-step-detail.tsx +1 -3
- package/src/ai/activity-step-label.ts +981 -0
- package/src/ai/activity-step-types.ts +107 -0
- package/src/ai/activity-tasks-utils.ts +199 -0
- package/src/ai/activity-timeline.ts +45 -0
- package/src/ai/attachments.tsx +3 -1
- package/src/ai/card-button.tsx +49 -0
- package/src/ai/chat-text.ts +13 -7
- package/src/ai/chat-thread.tsx +34 -27
- package/src/ai/choice-question.tsx +154 -35
- package/src/ai/code-block.tsx +189 -49
- package/src/ai/collapsible.tsx +19 -13
- package/src/ai/conversation.tsx +390 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +52 -1
- package/src/ai/message-action-sheet.tsx +168 -0
- package/src/ai/message-quote.tsx +98 -0
- package/src/ai/message.tsx +226 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +308 -73
- package/src/ai/reply-quote-chip.tsx +90 -0
- package/src/ai/shimmer.tsx +4 -6
- package/src/ai/subagent-cards.tsx +209 -0
- package/src/ai/tool-approval-card.tsx +162 -0
- package/src/ai/trace.tsx +54 -17
- package/src/ai/unified-diff.tsx +182 -0
- package/src/auth/GoogleSignInButton.tsx +6 -3
- package/src/auth/SignInScreen.tsx +11 -2
- package/src/brand/ProviderIcon.tsx +136 -0
- package/src/brand/index.ts +2 -0
- package/src/configureTheme.ts +39 -11
- package/src/controlStyles.ts +46 -30
- package/src/entries/gesture-handler.ts +4 -0
- package/src/entries/keyboard-controller.ts +4 -0
- package/src/entries/markdown.ts +1 -1
- package/src/entries/primitives.ts +24 -3
- package/src/entries/theme.ts +2 -1
- package/src/entries/video.ts +4 -0
- package/src/entries/webview.ts +6 -0
- package/src/floating.ts +25 -4
- package/src/haptics.ts +30 -4
- package/src/highlight.ts +302 -1
- package/src/index.ts +27 -5
- package/src/loadingGrid.ts +75 -0
- package/src/markdown/Markdown.tsx +234 -32
- package/src/markdown/MarkdownTable.tsx +188 -0
- package/src/markdown/tableText.ts +58 -0
- package/src/marketing/UpgradeToProCard.tsx +4 -3
- package/src/media/ImageGallery.tsx +224 -0
- package/src/media/MediaCard.tsx +100 -0
- package/src/media/imageGrid.ts +51 -0
- package/src/motion.ts +33 -0
- package/src/pagerSwipe.ts +102 -0
- package/src/palette.ts +8 -1
- package/src/primitives/button.tsx +4 -1
- package/src/primitives/keyboard-controller.tsx +106 -0
- package/src/primitives/keyboard-parts.tsx +104 -0
- package/src/primitives/overlay.tsx +133 -45
- package/src/primitives/pager-gesture.tsx +131 -0
- package/src/primitives/sheet-drag.tsx +305 -0
- package/src/primitives/sheet-gesture.tsx +181 -0
- package/src/primitives/swipe-row-gesture.tsx +290 -0
- package/src/primitives/toast-drag-gesture.tsx +92 -0
- package/src/primitives/web-click-guard.ts +50 -0
- package/src/relativeTime.ts +94 -0
- package/src/router/RouterStackPageHeader.tsx +2 -2
- package/src/settings/SettingsEmptyState.tsx +20 -10
- package/src/settings/SettingsField.tsx +11 -5
- package/src/settings/SettingsSection.tsx +8 -5
- package/src/settings/SettingsToggleRow.tsx +55 -22
- package/src/settings/relaxedSizeStyle.ts +11 -0
- package/src/sheetDrag.ts +67 -0
- package/src/swipeRow.ts +169 -0
- package/src/tabBarRoutes.ts +18 -0
- package/src/tabIndicator.ts +71 -0
- package/src/tailwind-preset.cjs +13 -6
- package/src/tailwind-preset.d.cts +5 -0
- package/src/theme.tsx +6 -2
- package/src/toastDrag.ts +58 -0
- package/src/typography.ts +20 -3
- package/src/useScheme.ts +10 -1
- package/src/video/InlineVideo.tsx +91 -0
- package/src/webview/HtmlView.tsx +91 -0
- package/src/webview/HtmlView.web.tsx +21 -0
- 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 :
|
|
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,
|
|
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
|
-
:
|
|
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
|
|
143
|
-
hitSlop={touchSlop(MIN_TOUCH_TARGET,
|
|
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
|
-
|
|
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={[{
|
|
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 (
|
|
92
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
126
|
-
largerText ? "text-
|
|
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.
|
|
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
|
-
</
|
|
167
|
-
|
|
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
|