@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,58 @@
1
+ /**
2
+ * A markdown table as text, for the table's copy actions: the formats and
3
+ * escaping of the web table controls (Streamdown's Copy table: Markdown, CSV
4
+ * and TSV), from each cell's plain text.
5
+ */
6
+ export type MarkdownTableData = { headers: string[]; rows: string[][] };
7
+
8
+ export type MarkdownTableFormat = "markdown" | "csv" | "tsv";
9
+
10
+ /** CSV: a cell with a comma, quote or line break is quoted, its quotes doubled. Lines join with `\n`. */
11
+ function toCsv({ headers, rows }: MarkdownTableData): string {
12
+ const cell = (text: string) => (/[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text);
13
+ return [...(headers.length > 0 ? [headers] : []), ...rows].map((row) => row.map(cell).join(",")).join("\n");
14
+ }
15
+
16
+ /** TSV: tabs and line breaks in a cell are written as `\t`, `\n` and `\r`. */
17
+ function toTsv({ headers, rows }: MarkdownTableData): string {
18
+ const cell = (text: string) => text.replace(/\t/g, "\\t").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
19
+ return [...(headers.length > 0 ? [headers] : []), ...rows].map((row) => row.map(cell).join("\t")).join("\n");
20
+ }
21
+
22
+ const MARKDOWN_ESCAPES: Record<string, string> = { "\\": "\\\\", "|": "\\|", "\n": "<br>", "&": "&amp;", "<": "&lt;", ">": "&gt;" };
23
+
24
+ /** GitHub markdown: pipes and backslashes escaped, line breaks as `<br>`, short rows padded to the header. */
25
+ function toMarkdown({ headers, rows }: MarkdownTableData): string {
26
+ if (headers.length === 0) return "";
27
+ const cell = (text: string) => text.replace(/[\\|\n&<>]/g, (char) => MARKDOWN_ESCAPES[char] ?? char);
28
+ const line = (cells: string[]) => `| ${cells.join(" | ")} |`;
29
+ return [
30
+ line(headers.map(cell)),
31
+ line(headers.map(() => "---")),
32
+ ...rows.map((row) => line(row.length < headers.length ? headers.map((_, index) => cell(row[index] ?? "")) : row.map(cell))),
33
+ ].join("\n");
34
+ }
35
+
36
+ /** The table in one of the copy formats. */
37
+ export function markdownTableText(data: MarkdownTableData, format: MarkdownTableFormat): string {
38
+ if (format === "csv") return toCsv(data);
39
+ if (format === "tsv") return toTsv(data);
40
+ return toMarkdown(data);
41
+ }
42
+
43
+ /**
44
+ * Column widths for the full-screen table, where columns size to their text
45
+ * as the web table does: React Native has no table layout, so the longest
46
+ * line in each column, at `charWidth` a character plus the cell padding,
47
+ * clamped to `[min, max]`.
48
+ */
49
+ export function markdownTableColumnWidths(
50
+ data: MarkdownTableData,
51
+ { charWidth, padding, min, max }: { charWidth: number; padding: number; min: number; max: number },
52
+ ): number[] {
53
+ return data.headers.map((header, column) => {
54
+ const cells = [header, ...data.rows.map((row) => row[column] ?? "")];
55
+ const longest = Math.max(...cells.flatMap((text) => text.split("\n").map((line) => line.length)));
56
+ return Math.min(max, Math.max(min, Math.ceil(longest * charWidth + padding)));
57
+ });
58
+ }
@@ -46,8 +46,9 @@ export function UpgradeToProCard({
46
46
  const largerText = useLargerText();
47
47
  // Each class is also set as a style (`sizeStyle`): web `text-sm` is 14 / 20.
48
48
  const titleSize = largerText ? "text-lg" : "text-sm";
49
- const ctaSize = largerText ? "text-base" : "text-xs";
50
- // The web pill is a `Button` `sm` (`font-medium`) in `bg-foreground text-background`.
49
+ const ctaSize = largerText ? "text-base" : "text-sm";
50
+ // The web pill is a `Button` `sm` (`font-medium`) in `bg-foreground text-background`,
51
+ // at the phone `Button` `sm` size: 36 high with a 14 label (the web h-8 `text-xs`).
51
52
  const pill = (label: string, accessibilityLabel?: string) => (
52
53
  <Pressable
53
54
  testID={testID ? `${testID}-cta` : undefined}
@@ -57,7 +58,7 @@ export function UpgradeToProCard({
57
58
  hitSlop={HIT_SLOP}
58
59
  className={cn(
59
60
  "shrink-0 items-center justify-center rounded-full active:opacity-80",
60
- largerText ? "min-h-12 px-4" : "min-h-8 px-3",
61
+ largerText ? "min-h-12 px-4" : "min-h-9 px-3",
61
62
  )}
62
63
  style={{ backgroundColor: tokens.color.foreground[scheme] }}
63
64
  >
@@ -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 active:opacity-80",
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
  )}