@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,224 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { FlatList, Image, Modal, Pressable, Text, View, useWindowDimensions } from "react-native";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { IconExternalLink, IconX } from "@tabler/icons-react-native";
|
|
5
|
+
import { typeStyle } from "../controlStyles";
|
|
6
|
+
import { IconButton } from "../IconButton";
|
|
7
|
+
import { withAlpha } from "../palette";
|
|
8
|
+
import { tokens } from "../tokens";
|
|
9
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
10
|
+
import { useScheme } from "../useScheme";
|
|
11
|
+
import { imageAspect, imageGridTiles, imageViewerPage, MEDIA, type GalleryImage, type ImageGridTile } from "./imageGrid";
|
|
12
|
+
|
|
13
|
+
export type ImageGalleryProps = {
|
|
14
|
+
images: GalleryImage[];
|
|
15
|
+
/** With it, the viewer has an "Open in browser" button that hands it the image's URL. */
|
|
16
|
+
onOpenImage?: (url: string) => void;
|
|
17
|
+
testID?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The web `ImageGalleryPreview`: one image at its own shape (as wide as the
|
|
22
|
+
* column, at most 384 high), or up to a 2 x 2 grid of 4:3 tiles with "+N" on
|
|
23
|
+
* the fourth. A tap opens `ImageViewer` at that image. Images only; draw
|
|
24
|
+
* videos apart.
|
|
25
|
+
*/
|
|
26
|
+
export function ImageGallery({ images, onOpenImage, testID }: ImageGalleryProps) {
|
|
27
|
+
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
|
|
28
|
+
const first = images[0];
|
|
29
|
+
if (!first) return null;
|
|
30
|
+
return (
|
|
31
|
+
<View testID={testID} className="w-full">
|
|
32
|
+
{images.length === 1 ? (
|
|
33
|
+
<SingleImage image={first} onOpen={() => setViewerIndex(0)} />
|
|
34
|
+
) : (
|
|
35
|
+
<ImageGrid images={images} onOpen={setViewerIndex} />
|
|
36
|
+
)}
|
|
37
|
+
<ImageViewer
|
|
38
|
+
images={images}
|
|
39
|
+
index={viewerIndex}
|
|
40
|
+
onIndexChange={setViewerIndex}
|
|
41
|
+
onClose={() => setViewerIndex(null)}
|
|
42
|
+
onOpenImage={onOpenImage}
|
|
43
|
+
/>
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The hairline-ringed, rounded frame of a gallery image or grid. */
|
|
49
|
+
function useMediaFrame() {
|
|
50
|
+
const scheme = useScheme();
|
|
51
|
+
return {
|
|
52
|
+
borderRadius: MEDIA.radius,
|
|
53
|
+
borderWidth: 1,
|
|
54
|
+
borderColor: MEDIA.outline[scheme],
|
|
55
|
+
overflow: "hidden" as const,
|
|
56
|
+
backgroundColor: tokens.color.surfaceSecondary[scheme],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A lone image, sized from the column and the image's own shape and capped
|
|
62
|
+
* at 384 high, so a tall capture keeps its shape instead of being cropped.
|
|
63
|
+
* Until both are known it holds a full-width 4:3 box.
|
|
64
|
+
*/
|
|
65
|
+
function SingleImage({ image, onOpen }: { image: GalleryImage; onOpen: () => void }) {
|
|
66
|
+
const frame = useMediaFrame();
|
|
67
|
+
const [columnWidth, setColumnWidth] = useState(0);
|
|
68
|
+
const [loadedSize, setLoadedSize] = useState<{ width: number; height: number } | null>(null);
|
|
69
|
+
const aspect = imageAspect(loadedSize);
|
|
70
|
+
const width = columnWidth > 0 ? Math.min(columnWidth, MEDIA.singleMaxHeight * aspect) : undefined;
|
|
71
|
+
return (
|
|
72
|
+
<View className="w-full" onLayout={(event) => setColumnWidth(event.nativeEvent.layout.width)}>
|
|
73
|
+
<Pressable
|
|
74
|
+
accessibilityRole="imagebutton"
|
|
75
|
+
accessibilityLabel={image.alt ?? "Image"}
|
|
76
|
+
accessibilityHint="Opens full screen"
|
|
77
|
+
onPress={onOpen}
|
|
78
|
+
className="active:opacity-90"
|
|
79
|
+
style={[frame, width === undefined ? { width: "100%", aspectRatio: aspect } : { width, height: width / aspect }]}
|
|
80
|
+
>
|
|
81
|
+
<Image
|
|
82
|
+
source={{ uri: image.url }}
|
|
83
|
+
resizeMode="cover"
|
|
84
|
+
style={{ width: "100%", height: "100%" }}
|
|
85
|
+
onLoad={(event) => setLoadedSize(event.nativeEvent.source)}
|
|
86
|
+
/>
|
|
87
|
+
</Pressable>
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Two columns of 4:3 tiles, filled as the web grid's rows are: tiles 0 and 2
|
|
94
|
+
* on the left, 1 and 3 on the right. With three, the first fills the left
|
|
95
|
+
* column, as tall as the two on the right.
|
|
96
|
+
*/
|
|
97
|
+
function ImageGrid({ images, onOpen }: { images: GalleryImage[]; onOpen: (index: number) => void }) {
|
|
98
|
+
const frame = useMediaFrame();
|
|
99
|
+
const tiles = imageGridTiles(images.length);
|
|
100
|
+
const spans = tiles[0]?.spansColumn === true;
|
|
101
|
+
const left = spans ? tiles.slice(0, 1) : tiles.filter((tile) => tile.index % 2 === 0);
|
|
102
|
+
const right = spans ? tiles.slice(1) : tiles.filter((tile) => tile.index % 2 === 1);
|
|
103
|
+
const tileStyle = { width: "100%" as const, aspectRatio: MEDIA.tileAspect };
|
|
104
|
+
const cell = (tile: ImageGridTile) => {
|
|
105
|
+
const image = images[tile.index];
|
|
106
|
+
if (!image) return null;
|
|
107
|
+
const name = image.alt ?? `Image ${tile.index + 1}`;
|
|
108
|
+
return (
|
|
109
|
+
<Pressable
|
|
110
|
+
key={tile.index}
|
|
111
|
+
accessibilityRole="imagebutton"
|
|
112
|
+
accessibilityLabel={tile.overflow > 0 ? `${name}, ${tile.overflow} more` : name}
|
|
113
|
+
accessibilityHint="Opens full screen"
|
|
114
|
+
onPress={() => onOpen(tile.index)}
|
|
115
|
+
className="active:opacity-90"
|
|
116
|
+
style={tile.spansColumn ? { width: "100%", flex: 1 } : tileStyle}
|
|
117
|
+
>
|
|
118
|
+
<Image source={{ uri: image.url }} resizeMode="cover" style={{ width: "100%", height: "100%" }} />
|
|
119
|
+
{tile.overflow > 0 ? (
|
|
120
|
+
<View className="absolute inset-0 items-center justify-center" style={{ backgroundColor: withAlpha(tokens.color.black, 0.6) }}>
|
|
121
|
+
<Text className="font-instrument-sans-semibold text-xl" style={[{ color: tokens.color.white }, typeStyle("semibold")]}>
|
|
122
|
+
{`+${tile.overflow}`}
|
|
123
|
+
</Text>
|
|
124
|
+
</View>
|
|
125
|
+
) : null}
|
|
126
|
+
</Pressable>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
return (
|
|
130
|
+
<View style={[frame, { flexDirection: "row", gap: MEDIA.gap }]}>
|
|
131
|
+
<View style={{ flex: 1, gap: MEDIA.gap }}>{left.map(cell)}</View>
|
|
132
|
+
<View style={{ flex: 1, gap: MEDIA.gap }}>{right.map(cell)}</View>
|
|
133
|
+
</View>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type ImageViewerProps = {
|
|
138
|
+
images: GalleryImage[];
|
|
139
|
+
/** The image on screen; `null` while closed. */
|
|
140
|
+
index: number | null;
|
|
141
|
+
onIndexChange: (index: number) => void;
|
|
142
|
+
onClose: () => void;
|
|
143
|
+
/** With it, an "Open in browser" button hands it the image's URL. */
|
|
144
|
+
onOpenImage?: (url: string) => void;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The web `ImageLightbox` on a phone: the images full screen on black, one
|
|
149
|
+
* page each, swiped sideways (TalkBack and VoiceOver step with the adjustable
|
|
150
|
+
* actions). Close (X, or Android back) and "Open in browser" sit at the top,
|
|
151
|
+
* "2 / 5" at the bottom when there is more than one. No pinch zoom. It fades
|
|
152
|
+
* in, or appears at once with Reduce Motion.
|
|
153
|
+
*/
|
|
154
|
+
export function ImageViewer({ images, index, onIndexChange, onClose, onOpenImage }: ImageViewerProps) {
|
|
155
|
+
const reduceMotion = useReduceMotion();
|
|
156
|
+
if (index === null) return null;
|
|
157
|
+
return (
|
|
158
|
+
<Modal visible transparent statusBarTranslucent animationType={reduceMotion ? "none" : "fade"} onRequestClose={onClose}>
|
|
159
|
+
<ViewerPages images={images} index={index} onIndexChange={onIndexChange} onClose={onClose} onOpenImage={onOpenImage} />
|
|
160
|
+
</Modal>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ViewerPages({ images, index, onIndexChange, onClose, onOpenImage }: Omit<ImageViewerProps, "index"> & { index: number }) {
|
|
165
|
+
const { width, height } = useWindowDimensions();
|
|
166
|
+
const insets = useSafeAreaInsets();
|
|
167
|
+
const count = images.length;
|
|
168
|
+
const current = images[index];
|
|
169
|
+
const white = tokens.color.white;
|
|
170
|
+
return (
|
|
171
|
+
<View className="flex-1" style={{ backgroundColor: withAlpha(tokens.color.black, 0.92) }}>
|
|
172
|
+
<FlatList
|
|
173
|
+
data={images}
|
|
174
|
+
horizontal
|
|
175
|
+
pagingEnabled
|
|
176
|
+
showsHorizontalScrollIndicator={false}
|
|
177
|
+
initialScrollIndex={index}
|
|
178
|
+
getItemLayout={(_, item) => ({ length: width, offset: width * item, index: item })}
|
|
179
|
+
keyExtractor={(image, item) => `${item}:${image.url}`}
|
|
180
|
+
onMomentumScrollEnd={(event) => onIndexChange(imageViewerPage(event.nativeEvent.contentOffset.x, width, count))}
|
|
181
|
+
renderItem={({ item }) => (
|
|
182
|
+
<View style={{ width, height }} className="items-center justify-center">
|
|
183
|
+
<Image source={{ uri: item.url }} resizeMode="contain" style={{ width: "100%", height: "100%" }} accessibilityLabel={item.alt ?? "Image"} />
|
|
184
|
+
</View>
|
|
185
|
+
)}
|
|
186
|
+
accessibilityRole="adjustable"
|
|
187
|
+
accessibilityLabel={current?.alt ?? "Image"}
|
|
188
|
+
accessibilityValue={count > 1 ? { text: `${index + 1} of ${count}` } : undefined}
|
|
189
|
+
accessibilityActions={count > 1 ? [{ name: "increment" }, { name: "decrement" }] : undefined}
|
|
190
|
+
onAccessibilityAction={(event) => {
|
|
191
|
+
const next = event.nativeEvent.actionName === "increment" ? index + 1 : index - 1;
|
|
192
|
+
if (next >= 0 && next < count) onIndexChange(next);
|
|
193
|
+
}}
|
|
194
|
+
/>
|
|
195
|
+
<View className="absolute left-0 right-0 flex-row items-center justify-between px-3" style={{ top: insets.top + 8 }}>
|
|
196
|
+
{onOpenImage && current ? (
|
|
197
|
+
<Pressable
|
|
198
|
+
accessibilityRole="link"
|
|
199
|
+
onPress={() => onOpenImage(current.url)}
|
|
200
|
+
className="min-h-11 flex-row items-center gap-1.5 rounded-full px-3 active:opacity-70"
|
|
201
|
+
style={{ backgroundColor: withAlpha(tokens.color.black, 0.6) }}
|
|
202
|
+
>
|
|
203
|
+
<IconExternalLink size={16} color={white} />
|
|
204
|
+
<Text className="font-instrument-sans text-sm" style={{ color: white }}>
|
|
205
|
+
Open in browser
|
|
206
|
+
</Text>
|
|
207
|
+
</Pressable>
|
|
208
|
+
) : (
|
|
209
|
+
<View />
|
|
210
|
+
)}
|
|
211
|
+
<IconButton icon={IconX} label="Close" variant="overlay" size="lg" onPress={onClose} />
|
|
212
|
+
</View>
|
|
213
|
+
{count > 1 ? (
|
|
214
|
+
<View className="absolute left-0 right-0 items-center" style={{ bottom: insets.bottom + 16 }} pointerEvents="none">
|
|
215
|
+
<View className="rounded-full px-3 py-1" style={{ backgroundColor: withAlpha(tokens.color.black, 0.6) }}>
|
|
216
|
+
<Text className="font-instrument-sans text-sm" style={{ color: white, fontVariant: ["tabular-nums"] }}>
|
|
217
|
+
{`${index + 1} / ${count}`}
|
|
218
|
+
</Text>
|
|
219
|
+
</View>
|
|
220
|
+
</View>
|
|
221
|
+
) : null}
|
|
222
|
+
</View>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import type { IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
|
|
5
|
+
import { tokens } from "../tokens";
|
|
6
|
+
import { useScheme } from "../useScheme";
|
|
7
|
+
|
|
8
|
+
export type MediaCardProps = {
|
|
9
|
+
icon: ComponentType<IconProps>;
|
|
10
|
+
title: string;
|
|
11
|
+
/** One muted line under the title. */
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Makes the whole card one button. Then leave `action` out: a button cannot hold another. */
|
|
14
|
+
onPress?: () => void;
|
|
15
|
+
/** A control at the end, such as a delete button, for a card that is not itself a button. */
|
|
16
|
+
action?: ReactNode;
|
|
17
|
+
/** A glyph at the end of a pressable card, such as an open-in-browser arrow. Decorative. */
|
|
18
|
+
trailingIcon?: ComponentType<IconProps>;
|
|
19
|
+
/** Fill. Default `tokens.color.surface` (the web `bg-card`). */
|
|
20
|
+
backgroundColor?: string;
|
|
21
|
+
/** Title colour. Default the theme foreground. */
|
|
22
|
+
color?: string;
|
|
23
|
+
/** Icon and description colour. Default the theme muted. */
|
|
24
|
+
mutedColor?: string;
|
|
25
|
+
/** Names the pressable card. Default `title`. */
|
|
26
|
+
accessibilityLabel?: string;
|
|
27
|
+
accessibilityHint?: string;
|
|
28
|
+
testID?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A card for one thing a turn produced, such as a saved file or a report: an
|
|
33
|
+
* icon, a title and a muted line, on the surface fill at the surface radius
|
|
34
|
+
* (the web `Surface density="tight"`). It is at least 56 high. With `onPress`
|
|
35
|
+
* the whole card is a button that fades while pressed; otherwise `action`
|
|
36
|
+
* holds its control.
|
|
37
|
+
*/
|
|
38
|
+
export function MediaCard({
|
|
39
|
+
icon: Icon,
|
|
40
|
+
title,
|
|
41
|
+
description,
|
|
42
|
+
onPress,
|
|
43
|
+
action,
|
|
44
|
+
trailingIcon: Trailing,
|
|
45
|
+
backgroundColor,
|
|
46
|
+
color,
|
|
47
|
+
mutedColor,
|
|
48
|
+
accessibilityLabel,
|
|
49
|
+
accessibilityHint,
|
|
50
|
+
testID,
|
|
51
|
+
}: MediaCardProps) {
|
|
52
|
+
const scheme = useScheme();
|
|
53
|
+
const muted = mutedColor ?? tokens.color.muted[scheme];
|
|
54
|
+
const body = (
|
|
55
|
+
<>
|
|
56
|
+
<Icon size={18} color={muted} />
|
|
57
|
+
<View className="min-w-0 flex-1">
|
|
58
|
+
<Text
|
|
59
|
+
numberOfLines={1}
|
|
60
|
+
className="font-instrument-sans-medium"
|
|
61
|
+
style={[sizeStyle("text-sm"), typeStyle("medium"), color ? { color } : textStyle("foreground", scheme)]}
|
|
62
|
+
>
|
|
63
|
+
{title}
|
|
64
|
+
</Text>
|
|
65
|
+
{description ? (
|
|
66
|
+
<Text numberOfLines={1} className="font-instrument-sans" style={[sizeStyle("text-xs"), { color: muted }]}>
|
|
67
|
+
{description}
|
|
68
|
+
</Text>
|
|
69
|
+
) : null}
|
|
70
|
+
</View>
|
|
71
|
+
{Trailing ? <Trailing size={16} color={muted} /> : null}
|
|
72
|
+
</>
|
|
73
|
+
);
|
|
74
|
+
const frame = {
|
|
75
|
+
minHeight: 56,
|
|
76
|
+
borderRadius: tokens.radius.lg,
|
|
77
|
+
backgroundColor: backgroundColor ?? tokens.color.surface[scheme],
|
|
78
|
+
};
|
|
79
|
+
if (onPress) {
|
|
80
|
+
return (
|
|
81
|
+
<Pressable
|
|
82
|
+
testID={testID}
|
|
83
|
+
accessibilityRole="button"
|
|
84
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
85
|
+
accessibilityHint={accessibilityHint}
|
|
86
|
+
onPress={onPress}
|
|
87
|
+
className="flex-row items-center gap-3 p-3 active:opacity-70"
|
|
88
|
+
style={frame}
|
|
89
|
+
>
|
|
90
|
+
{body}
|
|
91
|
+
</Pressable>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return (
|
|
95
|
+
<View testID={testID} className="flex-row items-center gap-3 p-3" style={frame}>
|
|
96
|
+
{body}
|
|
97
|
+
{action}
|
|
98
|
+
</View>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { tokens } from "../tokens";
|
|
2
|
+
|
|
3
|
+
/** One image in a gallery or the full-screen viewer. */
|
|
4
|
+
export type GalleryImage = { url: string; alt?: string };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The web `ImageGalleryPreview` metrics: the surface radius, a 2 gap between
|
|
8
|
+
* tiles (`gap-0.5`), 4:3 tiles, a lone image at most 384 high (`max-h-96`) and
|
|
9
|
+
* the hairline ring (`ring-black/10`, `dark:ring-white/10`).
|
|
10
|
+
*/
|
|
11
|
+
export const MEDIA = {
|
|
12
|
+
radius: tokens.radius.lg,
|
|
13
|
+
gap: 2,
|
|
14
|
+
tileAspect: 4 / 3,
|
|
15
|
+
singleMaxHeight: 384,
|
|
16
|
+
outline: { light: "rgba(0, 0, 0, 0.1)", dark: "rgba(255, 255, 255, 0.1)" },
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/** One tile of the grid: which image, whether it fills the left column, and the "+N" it carries. */
|
|
20
|
+
export type ImageGridTile = { index: number; spansColumn: boolean; overflow: number };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The web gallery grid for `count` images: up to four tiles in two columns.
|
|
24
|
+
* With three, the first fills the left column. Past four, the fourth carries
|
|
25
|
+
* "+N" for the rest. One image is not a grid (see `ImageGallery`).
|
|
26
|
+
*/
|
|
27
|
+
export function imageGridTiles(count: number): ImageGridTile[] {
|
|
28
|
+
const shown = Math.min(count, 4);
|
|
29
|
+
const overflow = count - shown;
|
|
30
|
+
return Array.from({ length: shown }, (_, index) => ({
|
|
31
|
+
index,
|
|
32
|
+
spansColumn: count === 3 && index === 0,
|
|
33
|
+
overflow: index === shown - 1 ? overflow : 0,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Width over height of a lone image, from its own size once loaded, kept
|
|
39
|
+
* between 1:2 and 3:1 so a very tall or wide capture still reads. 4:3 until
|
|
40
|
+
* the size is known.
|
|
41
|
+
*/
|
|
42
|
+
export function imageAspect(size: { width: number; height: number } | null): number {
|
|
43
|
+
if (!size || size.width <= 0 || size.height <= 0) return MEDIA.tileAspect;
|
|
44
|
+
return Math.min(3, Math.max(0.5, size.width / size.height));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The viewer's page for a scroll offset, kept in range. */
|
|
48
|
+
export function imageViewerPage(offsetX: number, pageWidth: number, count: number): number {
|
|
49
|
+
if (pageWidth <= 0 || count <= 0) return 0;
|
|
50
|
+
return Math.min(count - 1, Math.max(0, Math.round(offsetX / pageWidth)));
|
|
51
|
+
}
|
package/src/motion.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Easing, ReduceMotion } from "react-native-reanimated";
|
|
2
|
+
import { useReduceMotion } from "./useReduceMotion";
|
|
3
|
+
|
|
4
|
+
// The kit's motion values, from the `animate-expo` tables, so every part
|
|
5
|
+
// eases and springs in one family.
|
|
6
|
+
|
|
7
|
+
/** Strong ease-out: things entering or leaving the screen. */
|
|
8
|
+
export const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1);
|
|
9
|
+
|
|
10
|
+
/** A sheet opened or closed by a tap: no finger, so no velocity and no bounce. */
|
|
11
|
+
export const SHEET_SPRING = {
|
|
12
|
+
duration: 300,
|
|
13
|
+
dampingRatio: 1,
|
|
14
|
+
overshootClamping: true,
|
|
15
|
+
reduceMotion: ReduceMotion.System,
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/** A dragged sheet that springs home: a little bounce, as the finger applied force. Add the release `velocity`. */
|
|
19
|
+
export const SHEET_SNAP_SPRING = {
|
|
20
|
+
duration: 300,
|
|
21
|
+
dampingRatio: 0.8,
|
|
22
|
+
reduceMotion: ReduceMotion.System,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Press feedback on press-in, as a class for a `Pressable`: the 3% scale the
|
|
27
|
+
* `animate-expo` press recipe allows for something pressed this often (the
|
|
28
|
+
* web buttons and composer chips scale by 3-4%). With Reduce Motion, a dim
|
|
29
|
+
* instead of the scale.
|
|
30
|
+
*/
|
|
31
|
+
export function usePressFeedback(): string {
|
|
32
|
+
return useReduceMotion() ? "active:opacity-80" : "active:scale-[0.97]";
|
|
33
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules of a swiped `Pager`, as plain numbers, so they stay testable
|
|
3
|
+
* apart from the gesture. The pager's position is in pages: 0 is the first
|
|
4
|
+
* page, 1.5 is halfway between the second and the third. Distances are in
|
|
5
|
+
* points and velocities in points per second (the Gesture Handler units),
|
|
6
|
+
* positive to the right. Each rule is a worklet, so the drag runs it on the
|
|
7
|
+
* UI thread.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sideways travel (pt) before a drag takes the pager: twice a row swipe's
|
|
12
|
+
* (`SWIPE_ACTIVE_OFFSET_X`), so a swipeable row under the finger takes its
|
|
13
|
+
* own swipe first, and only a clearly sideways drag moves the pages.
|
|
14
|
+
*/
|
|
15
|
+
export const PAGER_ACTIVE_OFFSET_X = 24;
|
|
16
|
+
|
|
17
|
+
/** Vertical travel (pt) that leaves the touch to the page's own scroll. */
|
|
18
|
+
export const PAGER_FAIL_OFFSET_Y = 14;
|
|
19
|
+
|
|
20
|
+
/** A sideways flick at this speed (pt/s) turns the page, however short the drag. */
|
|
21
|
+
const FLICK_VELOCITY = 500;
|
|
22
|
+
|
|
23
|
+
/** How far ahead (s) the release velocity carries the pages when deciding where they settle. */
|
|
24
|
+
const PROJECTION_S = 0.2;
|
|
25
|
+
|
|
26
|
+
/** How far the pages move past the first or last page: less and less the further the finger goes. */
|
|
27
|
+
function rubberband(overshoot: number, dimension: number): number {
|
|
28
|
+
"worklet";
|
|
29
|
+
const constant = 0.55;
|
|
30
|
+
return (overshoot * dimension * constant) / (dimension + constant * overshoot);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Where the pages sit for a finger `dx` points from where the drag caught
|
|
35
|
+
* them at `start`: they follow the finger 1:1 (a drag left shows the next
|
|
36
|
+
* page), and past the first or last page they meet growing resistance.
|
|
37
|
+
*/
|
|
38
|
+
export function pagerPosition(start: number, dx: number, width: number, count: number): number {
|
|
39
|
+
"worklet";
|
|
40
|
+
if (width <= 0) return start;
|
|
41
|
+
const raw = start - dx / width;
|
|
42
|
+
const last = count - 1;
|
|
43
|
+
if (raw < 0) return -rubberband(-raw * width, width) / width;
|
|
44
|
+
if (raw > last) return last + rubberband((raw - last) * width, width) / width;
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The page a released drag settles on, from the page it started on
|
|
50
|
+
* (`from`), where it is (`position`) and the finger's speed (`vx`): a flick
|
|
51
|
+
* past `FLICK_VELOCITY` the way it moved turns one page; else the position
|
|
52
|
+
* carried on by its speed rounds to the nearest page. Never more than one
|
|
53
|
+
* page from `from`, and never past the ends.
|
|
54
|
+
*/
|
|
55
|
+
export function pagerSettle(position: number, vx: number, width: number, from: number, count: number): number {
|
|
56
|
+
"worklet";
|
|
57
|
+
const moved = position - from;
|
|
58
|
+
// A drag left (negative `vx`) moves toward the next page (a larger position).
|
|
59
|
+
const toward = -Math.sign(vx);
|
|
60
|
+
let target: number;
|
|
61
|
+
if (Math.abs(vx) >= FLICK_VELOCITY && moved !== 0 && toward === Math.sign(moved)) {
|
|
62
|
+
target = from + toward;
|
|
63
|
+
} else {
|
|
64
|
+
target = Math.round(width > 0 ? position - (vx * PROJECTION_S) / width : position);
|
|
65
|
+
}
|
|
66
|
+
return Math.min(count - 1, Math.max(0, Math.min(from + 1, Math.max(from - 1, target))));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every page from `from` to `to`, both included: the pages a slide from
|
|
71
|
+
* outside (a tab press) passes, which the pager draws so it never crosses a
|
|
72
|
+
* blank page.
|
|
73
|
+
*/
|
|
74
|
+
export function pagesBetween(from: number, to: number): number[] {
|
|
75
|
+
const low = Math.min(from, to);
|
|
76
|
+
return Array.from({ length: Math.abs(to - from) + 1 }, (_, step) => low + step);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The scroll offset (pt) of a sideways tab row that centres the tab at
|
|
81
|
+
* `position` (in pages, as the pager's: 1.5 is halfway between the second
|
|
82
|
+
* and the third tab), given each tab's `x` and `width` in the row and the
|
|
83
|
+
* row's visible `viewport` width. Between two tabs it blends their centres;
|
|
84
|
+
* it never scrolls before the first tab or past the last.
|
|
85
|
+
*/
|
|
86
|
+
export function centredTabScroll(
|
|
87
|
+
tabs: ReadonlyArray<{ x: number; width: number }>,
|
|
88
|
+
position: number,
|
|
89
|
+
viewport: number,
|
|
90
|
+
): number {
|
|
91
|
+
"worklet";
|
|
92
|
+
const last = tabs[tabs.length - 1];
|
|
93
|
+
if (!last || viewport <= 0) return 0;
|
|
94
|
+
const at = Math.min(tabs.length - 1, Math.max(0, position));
|
|
95
|
+
const below = Math.floor(at);
|
|
96
|
+
const left = tabs[below] ?? last;
|
|
97
|
+
const right = tabs[Math.min(tabs.length - 1, below + 1)] ?? last;
|
|
98
|
+
const share = at - below;
|
|
99
|
+
const centre = left.x + left.width / 2 + (right.x + right.width / 2 - (left.x + left.width / 2)) * share;
|
|
100
|
+
const end = Math.max(0, last.x + last.width - viewport);
|
|
101
|
+
return Math.min(end, Math.max(0, centre - viewport / 2));
|
|
102
|
+
}
|
package/src/palette.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Web semantic colours for React Native, converted from `@vvedantb/ui`
|
|
3
3
|
* `tokens.css` (oklch → sRGB hex, clipped to sRGB). `tokens.ts` holds the
|
|
4
4
|
* neutrals the native app ships; these are the colours it lacks (primary,
|
|
5
|
-
* danger, success, warning, overlay, segment, field placeholder, backdrop,
|
|
5
|
+
* danger, success, warning, overlay, segment, tab bar pill, field placeholder, backdrop,
|
|
6
6
|
* dialog description).
|
|
7
7
|
*
|
|
8
8
|
* A test in this package converts `tokens.css` and fails when a value here
|
|
@@ -25,6 +25,13 @@ export const palette = {
|
|
|
25
25
|
overlayForeground: { light: "#181818", dark: "#fcfcfc" },
|
|
26
26
|
segment: { light: "#ffffff", dark: "#474747" },
|
|
27
27
|
segmentForeground: { light: "#181818", dark: "#fcfcfc" },
|
|
28
|
+
/**
|
|
29
|
+
* The `FloatingTabBar` active pill: `--default` in light (it shows on the
|
|
30
|
+
* near-white glass bar) and `--segment` in dark. Its own key, so an app that
|
|
31
|
+
* themes `segment` for the segmented `Tabs` thumb keeps a pill that shows on
|
|
32
|
+
* the dark glass bar.
|
|
33
|
+
*/
|
|
34
|
+
tabBarPill: { light: "#ebebeb", dark: "#474747" },
|
|
28
35
|
surfaceTertiary: { light: "#eaeaea", dark: "#282627" },
|
|
29
36
|
fieldPlaceholder: { light: "#727272", dark: "#a0a0a0" },
|
|
30
37
|
backdrop: { light: "rgba(0, 0, 0, 0.5)", dark: "rgba(0, 0, 0, 0.6)" },
|
|
@@ -3,10 +3,11 @@ import { cn } from "../cn";
|
|
|
3
3
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
4
|
import * as React from "react";
|
|
5
5
|
import { Platform, Pressable } from "react-native";
|
|
6
|
+
import { usePressFeedback } from "../motion";
|
|
6
7
|
|
|
7
8
|
const buttonVariants = cva(
|
|
8
9
|
cn(
|
|
9
|
-
"group flex-row items-center justify-center gap-2 shadow-none
|
|
10
|
+
"group flex-row items-center justify-center gap-2 shadow-none",
|
|
10
11
|
Platform.select({
|
|
11
12
|
web: "outline-none transition-all",
|
|
12
13
|
}),
|
|
@@ -88,12 +89,14 @@ const Button = React.forwardRef<
|
|
|
88
89
|
React.ElementRef<typeof Pressable>,
|
|
89
90
|
ButtonProps
|
|
90
91
|
>(({ className, variant, size, shape, ...props }, ref) => {
|
|
92
|
+
const pressed = usePressFeedback();
|
|
91
93
|
return (
|
|
92
94
|
<TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
|
|
93
95
|
<Pressable
|
|
94
96
|
ref={ref}
|
|
95
97
|
className={cn(
|
|
96
98
|
buttonVariants({ variant, size, shape }),
|
|
99
|
+
pressed,
|
|
97
100
|
props.disabled && "opacity-45",
|
|
98
101
|
className,
|
|
99
102
|
)}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
KeyboardAvoidingView,
|
|
5
|
+
KeyboardChatScrollView,
|
|
6
|
+
KeyboardGestureArea,
|
|
7
|
+
KeyboardStickyView,
|
|
8
|
+
useKeyboardHandler,
|
|
9
|
+
} from "react-native-keyboard-controller";
|
|
10
|
+
import { runOnJS } from "react-native-reanimated";
|
|
11
|
+
import { COMPOSER_INPUT_ID, KeyboardPartsContext, type KeyboardParts } from "./keyboard-parts";
|
|
12
|
+
|
|
13
|
+
// The keyboard parts on `react-native-keyboard-controller`: the keyboard's
|
|
14
|
+
// real position, frame by frame, on the UI thread. No timing of our own, so
|
|
15
|
+
// the composer can neither lag nor lead the keyboard; with Reduce Motion the
|
|
16
|
+
// system shortens the keyboard's own motion and the parts follow it.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A drag moves the keyboard on iOS (`keyboardDismissMode="interactive"`) and
|
|
20
|
+
* on Android 11+ (API 30, `WindowInsetsAnimation`). Android 10 and below has
|
|
21
|
+
* no interactive keyboard: `KeyboardGestureArea` then only renders its
|
|
22
|
+
* children, and the transcript keeps `on-drag`.
|
|
23
|
+
*/
|
|
24
|
+
const INTERACTIVE = Platform.OS === "ios" || (Platform.OS === "android" && Number(Platform.Version) >= 30);
|
|
25
|
+
|
|
26
|
+
function AvoidingView(props: ComponentProps<KeyboardParts["AvoidingView"]>) {
|
|
27
|
+
return <KeyboardAvoidingView {...props} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function StickyView({ bottomOffset, ...props }: ComponentProps<KeyboardParts["StickyView"]>) {
|
|
31
|
+
return <KeyboardStickyView {...props} offset={{ opened: bottomOffset }} />;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `always`: the content rises with the keyboard wherever the reader is, so
|
|
36
|
+
* what sat on the composer stays there (the newest message, at the end).
|
|
37
|
+
* Inverted, the keyboard's space is the content's top inset, the visual
|
|
38
|
+
* bottom: iOS scrolls below offset 0 into it, so the end moves there; Android
|
|
39
|
+
* moves the content down by it instead, so offset 0 stays the end. While the
|
|
40
|
+
* keyboard moves, the list scrolls with it, through offsets that are not the
|
|
41
|
+
* end on either platform.
|
|
42
|
+
*/
|
|
43
|
+
function ChatScrollView({
|
|
44
|
+
bottomOffset,
|
|
45
|
+
inverted = false,
|
|
46
|
+
onInsetChange,
|
|
47
|
+
onKeyboardMove,
|
|
48
|
+
...props
|
|
49
|
+
}: ComponentProps<KeyboardParts["ChatScrollView"]>) {
|
|
50
|
+
useKeyboardHandler(
|
|
51
|
+
{
|
|
52
|
+
onStart: () => {
|
|
53
|
+
"worklet";
|
|
54
|
+
runOnJS(onKeyboardMove)(true);
|
|
55
|
+
},
|
|
56
|
+
onEnd: () => {
|
|
57
|
+
"worklet";
|
|
58
|
+
runOnJS(onKeyboardMove)(false);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
[onKeyboardMove],
|
|
62
|
+
);
|
|
63
|
+
return (
|
|
64
|
+
<KeyboardChatScrollView
|
|
65
|
+
{...props}
|
|
66
|
+
inverted={inverted}
|
|
67
|
+
offset={bottomOffset}
|
|
68
|
+
keyboardLiftBehavior="always"
|
|
69
|
+
onContentInsetChange={(insets) =>
|
|
70
|
+
onInsetChange(inverted ? (Platform.OS === "ios" ? insets.top : 0) : insets.bottom)
|
|
71
|
+
}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `ios`: the keyboard follows once the finger reaches the composer, then 1:1,
|
|
78
|
+
* and a release carries the finger's velocity, on both platforms.
|
|
79
|
+
*/
|
|
80
|
+
function GestureArea({ offset, style, children }: ComponentProps<KeyboardParts["GestureArea"]>) {
|
|
81
|
+
return (
|
|
82
|
+
<KeyboardGestureArea interpolator="ios" offset={offset} textInputNativeID={COMPOSER_INPUT_ID} style={style}>
|
|
83
|
+
{children}
|
|
84
|
+
</KeyboardGestureArea>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PARTS: KeyboardParts = {
|
|
89
|
+
AvoidingView,
|
|
90
|
+
StickyView,
|
|
91
|
+
ChatScrollView,
|
|
92
|
+
GestureArea,
|
|
93
|
+
interactive: INTERACTIVE,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs the kit's keyboard handling on `react-native-keyboard-controller`:
|
|
98
|
+
* the chat composer (`ConversationDock`), its scroll button and the
|
|
99
|
+
* transcript (`ConversationList`) follow the keyboard frame by frame, a drag
|
|
100
|
+
* on the transcript moves the keyboard with the finger, and `PromptInput`,
|
|
101
|
+
* `ChatThread`, the overlays and `KeyboardAvoidingView` pad frame by frame.
|
|
102
|
+
* Put it inside the app's `KeyboardProvider`.
|
|
103
|
+
*/
|
|
104
|
+
export function KeyboardControllerProvider({ children }: { children: ReactNode }) {
|
|
105
|
+
return <KeyboardPartsContext.Provider value={PARTS}>{children}</KeyboardPartsContext.Provider>;
|
|
106
|
+
}
|