@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.
- package/README.md +40 -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/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 +38 -1
- package/src/ai/message-action-sheet.tsx +4 -1
- package/src/ai/message.tsx +60 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +301 -75
- package/src/ai/shimmer.tsx +4 -6
- 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,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>", "&": "&", "<": "<", ">": ">" };
|
|
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-
|
|
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-
|
|
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
|
+
}
|