@vvedantb/native-ui 0.13.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +40 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/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 +19 -13
  40. package/src/ai/conversation.tsx +390 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +38 -1
  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/trace.tsx +54 -17
  49. package/src/ai/unified-diff.tsx +182 -0
  50. package/src/auth/GoogleSignInButton.tsx +6 -3
  51. package/src/auth/SignInScreen.tsx +11 -2
  52. package/src/brand/ProviderIcon.tsx +136 -0
  53. package/src/brand/index.ts +2 -0
  54. package/src/configureTheme.ts +39 -11
  55. package/src/controlStyles.ts +46 -30
  56. package/src/entries/gesture-handler.ts +4 -0
  57. package/src/entries/keyboard-controller.ts +4 -0
  58. package/src/entries/markdown.ts +1 -1
  59. package/src/entries/primitives.ts +24 -3
  60. package/src/entries/theme.ts +2 -1
  61. package/src/entries/video.ts +4 -0
  62. package/src/entries/webview.ts +6 -0
  63. package/src/floating.ts +25 -4
  64. package/src/haptics.ts +30 -4
  65. package/src/highlight.ts +302 -1
  66. package/src/index.ts +27 -5
  67. package/src/loadingGrid.ts +75 -0
  68. package/src/markdown/Markdown.tsx +234 -32
  69. package/src/markdown/MarkdownTable.tsx +188 -0
  70. package/src/markdown/tableText.ts +58 -0
  71. package/src/marketing/UpgradeToProCard.tsx +4 -3
  72. package/src/media/ImageGallery.tsx +224 -0
  73. package/src/media/MediaCard.tsx +100 -0
  74. package/src/media/imageGrid.ts +51 -0
  75. package/src/motion.ts +33 -0
  76. package/src/pagerSwipe.ts +102 -0
  77. package/src/palette.ts +8 -1
  78. package/src/primitives/button.tsx +4 -1
  79. package/src/primitives/keyboard-controller.tsx +106 -0
  80. package/src/primitives/keyboard-parts.tsx +104 -0
  81. package/src/primitives/overlay.tsx +133 -45
  82. package/src/primitives/pager-gesture.tsx +131 -0
  83. package/src/primitives/sheet-drag.tsx +305 -0
  84. package/src/primitives/sheet-gesture.tsx +181 -0
  85. package/src/primitives/swipe-row-gesture.tsx +290 -0
  86. package/src/primitives/toast-drag-gesture.tsx +92 -0
  87. package/src/primitives/web-click-guard.ts +50 -0
  88. package/src/relativeTime.ts +94 -0
  89. package/src/router/RouterStackPageHeader.tsx +2 -2
  90. package/src/settings/SettingsEmptyState.tsx +20 -10
  91. package/src/settings/SettingsField.tsx +11 -5
  92. package/src/settings/SettingsSection.tsx +8 -5
  93. package/src/settings/SettingsToggleRow.tsx +55 -22
  94. package/src/settings/relaxedSizeStyle.ts +11 -0
  95. package/src/sheetDrag.ts +67 -0
  96. package/src/swipeRow.ts +169 -0
  97. package/src/tabBarRoutes.ts +18 -0
  98. package/src/tabIndicator.ts +71 -0
  99. package/src/tailwind-preset.cjs +13 -6
  100. package/src/tailwind-preset.d.cts +5 -0
  101. package/src/theme.tsx +6 -2
  102. package/src/toastDrag.ts +58 -0
  103. package/src/typography.ts +20 -3
  104. package/src/useScheme.ts +10 -1
  105. package/src/video/InlineVideo.tsx +91 -0
  106. package/src/webview/HtmlView.tsx +91 -0
  107. package/src/webview/HtmlView.web.tsx +21 -0
  108. package/src/webview/htmlView.ts +45 -0
@@ -0,0 +1,188 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Modal, ScrollView, Text, View } from "react-native";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { IconCopy, IconMaximize, IconX } from "@tabler/icons-react-native";
5
+ import { ChatIconButton } from "../ai/icon-button";
6
+ import { withAlpha } from "../palette";
7
+ import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTitle } from "../Sheet";
8
+ import { SheetRow } from "../SheetRow";
9
+ import { tokens } from "../tokens";
10
+ import { useReduceMotion } from "../useReduceMotion";
11
+ import { useScheme } from "../useScheme";
12
+ import {
13
+ markdownTableColumnWidths,
14
+ markdownTableText,
15
+ type MarkdownTableData,
16
+ type MarkdownTableFormat,
17
+ } from "./tableText";
18
+
19
+ /**
20
+ * Web `.markdown table` and its Streamdown wrapper: text at 0.93em with
21
+ * line-height 1.5, cells `0.45rem 0.75rem`, header cells semibold, rows split
22
+ * by the border at 70% (the header's in the full border), the table 0.65rem
23
+ * inside the code-block panel (padding `0.25rem 0.3rem 0.4rem`), below the
24
+ * row of table actions (`flex justify-end gap-1`, 24 buttons, then `gap-2`).
25
+ */
26
+ export const MARKDOWN_TABLE = {
27
+ fontScale: 0.93,
28
+ lineHeight: 1.5,
29
+ cellPaddingX: 12,
30
+ cellPaddingY: 7,
31
+ panelPadding: { top: 4, x: 5, bottom: 6 },
32
+ tableMargin: 10,
33
+ /** The web action row: 24 buttons in a 27 row. */
34
+ actionsHeight: 27,
35
+ /**
36
+ * The web buttons sit 4 apart; here 16, so each slops to 44 without
37
+ * taking its neighbour's taps.
38
+ */
39
+ actionsGap: 16,
40
+ } as const;
41
+
42
+ /**
43
+ * The full-screen table sizes each column to its text, as the web table does
44
+ * (`markdownTableColumnWidths`). 8.5 a character fits the 12 mono of inline
45
+ * code with its chip padding, so a code cell does not wrap; body text is narrower.
46
+ */
47
+ const FULL_SCREEN_COLUMN = { charWidth: 8.5, min: 72, max: 320 } as const;
48
+
49
+ /** The web Copy table menu, in its order. */
50
+ const COPY_FORMATS: ReadonlyArray<{ format: MarkdownTableFormat; title: string }> = [
51
+ { format: "markdown", title: "Markdown" },
52
+ { format: "csv", title: "CSV" },
53
+ { format: "tsv", title: "TSV" },
54
+ ];
55
+
56
+ export type MarkdownTableProps = {
57
+ /** Header cells, already styled. */
58
+ header: ReactNode[];
59
+ /** Body rows of cells, already styled. */
60
+ rows: ReactNode[][];
61
+ /** The same cells as plain text, for copying and for the full-screen column widths. */
62
+ data: MarkdownTableData;
63
+ /** The cell text colour. */
64
+ color: string;
65
+ /** With it, a Copy table button: a sheet of the web formats (Markdown, CSV, TSV). */
66
+ onCopy?: (text: string) => void | Promise<void>;
67
+ };
68
+
69
+ /**
70
+ * The cells as rows. `widths` gives every column its own width (the
71
+ * full-screen view, which scrolls sideways); without it the columns share the
72
+ * width equally and the cells wrap, so they line up in a chat column.
73
+ */
74
+ function TableRows({ header, rows, color, widths }: Pick<MarkdownTableProps, "header" | "rows" | "color"> & { widths?: number[] }) {
75
+ const scheme = useScheme();
76
+ const { cellPaddingX, cellPaddingY } = MARKDOWN_TABLE;
77
+ // The header row is ruled off in the border colour, body rows at 70%.
78
+ const rule = (index: number) => (index === 1 ? tokens.color.border[scheme] : withAlpha(tokens.color.border[scheme], 0.7));
79
+ const row = (cells: ReactNode[], index: number) => (
80
+ <View key={index} style={{ flexDirection: "row", borderTopWidth: index > 0 ? 1 : 0, borderTopColor: rule(index) }}>
81
+ {cells.map((cell, cellIndex) => (
82
+ <View
83
+ key={cellIndex}
84
+ style={[{ paddingHorizontal: cellPaddingX, paddingVertical: cellPaddingY }, widths ? { width: widths[cellIndex] } : { flex: 1 }]}
85
+ >
86
+ <Text style={{ color }}>{cell}</Text>
87
+ </View>
88
+ ))}
89
+ </View>
90
+ );
91
+ return (
92
+ <>
93
+ {row(header, 0)}
94
+ {rows.map((cells, index) => row(cells, index + 1))}
95
+ </>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * A markdown table on the web's panel, with the web table actions a phone
101
+ * can use: Copy table (a sheet of Markdown, CSV and TSV, as the web menu)
102
+ * and View fullscreen (the table at its own column widths, scrolling
103
+ * sideways, for a table too wide for the chat). The web's Download table is
104
+ * left out: a phone saves a file through a share sheet, which native-ui does
105
+ * not bundle, and the copy formats carry the same text.
106
+ */
107
+ export function MarkdownTable({ header, rows, data, color, onCopy }: MarkdownTableProps) {
108
+ const scheme = useScheme();
109
+ const insets = useSafeAreaInsets();
110
+ const reduceMotion = useReduceMotion();
111
+ const [copyOpen, setCopyOpen] = useState(false);
112
+ const [fullScreen, setFullScreen] = useState(false);
113
+ const { panelPadding, tableMargin, actionsHeight, actionsGap } = MARKDOWN_TABLE;
114
+ const muted = tokens.color.muted[scheme];
115
+ const panel = {
116
+ borderRadius: tokens.radius.lg,
117
+ backgroundColor: tokens.color.surfaceSecondary[scheme],
118
+ paddingTop: panelPadding.top,
119
+ paddingHorizontal: panelPadding.x,
120
+ paddingBottom: panelPadding.bottom,
121
+ };
122
+ const copy = async (format: MarkdownTableFormat) => {
123
+ setCopyOpen(false);
124
+ await onCopy?.(markdownTableText(data, format));
125
+ };
126
+ const copyButton = onCopy ? (
127
+ <ChatIconButton icon={IconCopy} label="Copy table" size="xs" iconColor={muted} onPress={() => setCopyOpen(true)} />
128
+ ) : null;
129
+ const widths = markdownTableColumnWidths(data, { ...FULL_SCREEN_COLUMN, padding: MARKDOWN_TABLE.cellPaddingX * 2 });
130
+ return (
131
+ <View style={panel}>
132
+ <View className="flex-row items-center justify-end" style={{ height: actionsHeight, gap: actionsGap }}>
133
+ {copyButton}
134
+ <ChatIconButton icon={IconMaximize} label="View fullscreen" size="xs" iconColor={muted} onPress={() => setFullScreen(true)} />
135
+ </View>
136
+ <View style={{ marginTop: tableMargin, marginBottom: tableMargin }}>
137
+ <TableRows header={header} rows={rows} color={color} />
138
+ </View>
139
+ {onCopy ? (
140
+ <Sheet open={copyOpen} onOpenChange={setCopyOpen}>
141
+ <SheetContent className="gap-3 px-3">
142
+ <SheetHeader className="px-2">
143
+ <SheetTitle>Copy table</SheetTitle>
144
+ </SheetHeader>
145
+ <SheetBody contentContainerClassName="gap-3">
146
+ {COPY_FORMATS.map(({ format, title }) => (
147
+ <SheetRow key={format} title={title} onPress={() => void copy(format)} />
148
+ ))}
149
+ </SheetBody>
150
+ </SheetContent>
151
+ </Sheet>
152
+ ) : null}
153
+ <Modal
154
+ visible={fullScreen}
155
+ animationType={reduceMotion ? "none" : "fade"}
156
+ statusBarTranslucent
157
+ onRequestClose={() => setFullScreen(false)}
158
+ >
159
+ <View
160
+ className="flex-1"
161
+ accessibilityViewIsModal
162
+ style={{
163
+ backgroundColor: tokens.color.background[scheme],
164
+ paddingTop: insets.top,
165
+ paddingBottom: insets.bottom,
166
+ paddingLeft: insets.left,
167
+ paddingRight: insets.right,
168
+ }}
169
+ >
170
+ {/* Web: the actions top right, Exit fullscreen last. */}
171
+ <View className="flex-row items-center justify-end px-2 py-1.5" style={{ gap: actionsGap }}>
172
+ {copyButton}
173
+ <ChatIconButton icon={IconX} label="Exit fullscreen" iconColor={muted} onPress={() => setFullScreen(false)} />
174
+ </View>
175
+ <ScrollView contentContainerStyle={{ padding: 16 }}>
176
+ <ScrollView horizontal contentContainerStyle={{ flexGrow: 1 }}>
177
+ <View style={panel}>
178
+ <View style={{ marginVertical: tableMargin }}>
179
+ <TableRows header={header} rows={rows} color={color} widths={widths} />
180
+ </View>
181
+ </View>
182
+ </ScrollView>
183
+ </ScrollView>
184
+ </View>
185
+ </Modal>
186
+ </View>
187
+ );
188
+ }
@@ -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
+ }