@vvedantb/native-ui 0.8.0 → 0.9.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 +102 -6
- package/package.json +35 -8
- package/src/BulkActionBar.tsx +303 -0
- package/src/DividedList.tsx +37 -0
- package/src/DropdownMenu.tsx +439 -0
- package/src/FloatingFAB.tsx +4 -1
- package/src/ai/activity-step-detail.tsx +95 -0
- package/src/ai/choice-question.tsx +297 -0
- package/src/ai/code-block.tsx +50 -7
- package/src/ai/index.ts +10 -0
- package/src/ai/message.tsx +59 -12
- package/src/ai/prompt-input.tsx +79 -28
- package/src/ai/trace.tsx +32 -1
- package/src/configureTheme.ts +60 -0
- package/src/entries/markdown.ts +5 -0
- package/src/entries/primitives.ts +22 -0
- package/src/entries/theme.ts +2 -0
- package/src/examples/ParityGallery.tsx +56 -0
- package/src/floating.ts +2 -4
- package/src/highlight.ts +117 -0
- package/src/index.ts +24 -0
- package/src/markdown/Markdown.tsx +191 -0
- package/src/push.ts +186 -0
package/src/ai/trace.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { palette } from "../palette";
|
|
|
7
7
|
import { useLargerText } from "../theme";
|
|
8
8
|
import { tokens } from "../tokens";
|
|
9
9
|
import { useScheme } from "../useScheme";
|
|
10
|
-
import { chatTextSize } from "./chat-text";
|
|
10
|
+
import { chatTextSize, formatElapsed } from "./chat-text";
|
|
11
11
|
import {
|
|
12
12
|
Collapsible,
|
|
13
13
|
CollapsibleChevron,
|
|
@@ -232,3 +232,34 @@ export function AgentTraceCollapsibleContent({
|
|
|
232
232
|
</CollapsibleContent>
|
|
233
233
|
);
|
|
234
234
|
}
|
|
235
|
+
|
|
236
|
+
export type AgentTraceFoldProps = Omit<CollapsibleProps, "children"> & {
|
|
237
|
+
/** How long the turn ran, in seconds. The title reads "Worked for 1m 5s". */
|
|
238
|
+
seconds: number;
|
|
239
|
+
/** The settled turn's steps, shown in an `AgentTrace` when open. */
|
|
240
|
+
children: ReactNode;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A settled turn folded behind one line, as the web `ActivityTasks` in its
|
|
245
|
+
* folded (Codex-style) mode: "Worked for …" over a hairline
|
|
246
|
+
* (`border-b border-border pb-1.5`), closed by default, and the steps in an
|
|
247
|
+
* `AgentTrace` 8 below when open.
|
|
248
|
+
*/
|
|
249
|
+
export function AgentTraceFold({ seconds, children, defaultOpen = false, className, ...props }: AgentTraceFoldProps) {
|
|
250
|
+
const scheme = useScheme();
|
|
251
|
+
return (
|
|
252
|
+
<Collapsible className={cn("w-full min-w-0", className)} defaultOpen={defaultOpen} {...props}>
|
|
253
|
+
<View style={{ borderBottomWidth: 1, borderBottomColor: tokens.color.border[scheme], paddingBottom: 6 }}>
|
|
254
|
+
<AgentTraceCollapsibleTrigger>
|
|
255
|
+
<AgentTraceLine>
|
|
256
|
+
<AgentTraceTitle>{`Worked for ${formatElapsed(seconds)}`}</AgentTraceTitle>
|
|
257
|
+
</AgentTraceLine>
|
|
258
|
+
</AgentTraceCollapsibleTrigger>
|
|
259
|
+
</View>
|
|
260
|
+
<CollapsibleContent>
|
|
261
|
+
<AgentTrace className="mt-2">{children}</AgentTrace>
|
|
262
|
+
</CollapsibleContent>
|
|
263
|
+
</Collapsible>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { palette, type Palette } from "./palette";
|
|
2
|
+
import { tokens, type Tokens } from "./tokens";
|
|
3
|
+
|
|
4
|
+
/** `tokens` and `palette` value types with the literal hex widened to `string` and `readonly` dropped. */
|
|
5
|
+
type Widen<T> = T extends string ? string : { -readonly [K in keyof T]: Widen<T[K]> };
|
|
6
|
+
|
|
7
|
+
/** The `tokens.color` shape: each key a `{ light, dark }` pair, except `white` and `black`. */
|
|
8
|
+
export type ThemeColors = Widen<Tokens["color"]>;
|
|
9
|
+
|
|
10
|
+
/** The `palette` shape: each key a `{ light, dark }` pair, except `destructive`. */
|
|
11
|
+
export type ThemePalette = Widen<Palette>;
|
|
12
|
+
|
|
13
|
+
export type ThemeConfig = {
|
|
14
|
+
/** Overrides for `tokens.color` (neutrals and surfaces). Keys not given keep the package value. */
|
|
15
|
+
color?: Partial<ThemeColors>;
|
|
16
|
+
/** Overrides for `palette` (primary, danger, overlay and the other web semantic colours). */
|
|
17
|
+
palette?: Partial<ThemePalette>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ColorValue = string | { light: string; dark: string };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Copies each given key onto `target`. A `{ light, dark }` pair is copied into
|
|
24
|
+
* the existing pair object, so values that hold the pair (`floatingFill`,
|
|
25
|
+
* `floatingHairline`, `floatingSolidFill`) follow it.
|
|
26
|
+
*/
|
|
27
|
+
function assignColors(
|
|
28
|
+
target: { readonly [key: string]: ColorValue },
|
|
29
|
+
source: { readonly [key: string]: ColorValue | undefined },
|
|
30
|
+
) {
|
|
31
|
+
for (const [key, value] of Object.entries(source)) {
|
|
32
|
+
if (value === undefined) continue;
|
|
33
|
+
const current = target[key];
|
|
34
|
+
if (typeof current === "object" && typeof value === "object") Object.assign(current, value);
|
|
35
|
+
else Object.assign(target, { [key]: value });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sets the app's own colours. It writes into the shared `tokens.color` and
|
|
41
|
+
* `palette` objects, which every component reads at render, so call it once at
|
|
42
|
+
* app start, before the first render (for example at the top of the root
|
|
43
|
+
* `_layout.tsx`, outside the component). A call after the first render does not
|
|
44
|
+
* redraw mounted components. Each pair gives both schemes:
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* configureTheme({
|
|
48
|
+
* color: { background: { light: "#f4f5f6", dark: "#020202" } },
|
|
49
|
+
* palette: { primary: { light: "#09090b", dark: "#fafafa" } },
|
|
50
|
+
* });
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* Only the JS colours change. NativeWind classes keep the app's Tailwind theme
|
|
54
|
+
* (`tailwind-preset` reads the package values from source), and constants copied
|
|
55
|
+
* out before the call (`NEUTRAL_500`) keep the package value.
|
|
56
|
+
*/
|
|
57
|
+
export function configureTheme(config: ThemeConfig): void {
|
|
58
|
+
if (config.color) assignColors(tokens.color, config.color);
|
|
59
|
+
if (config.palette) assignColors(palette, config.palette);
|
|
60
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@vvedantb/native-ui/markdown`: `Markdown`, chat markdown styled as the web
|
|
2
|
+
// `.markdown` block. Not on the root entry: it imports the optional peer
|
|
3
|
+
// `react-native-marked`, and Metro does not tree-shake.
|
|
4
|
+
export { Markdown, markdownStyles, inlineCodeStyle, MARKDOWN_SCALE, MARKDOWN_LIST } from "../markdown/Markdown";
|
|
5
|
+
export type { MarkdownProps } from "../markdown/Markdown";
|
|
@@ -59,6 +59,8 @@ export type {
|
|
|
59
59
|
TabsContentProps,
|
|
60
60
|
} from "../Tabs";
|
|
61
61
|
export { ListRow } from "../ListRow";
|
|
62
|
+
export { DividedList } from "../DividedList";
|
|
63
|
+
export type { DividedListProps } from "../DividedList";
|
|
62
64
|
export type { ListRowProps } from "../ListRow";
|
|
63
65
|
export { HeaderIconButton } from "../HeaderIconButton";
|
|
64
66
|
export { ThemeToggle } from "../ThemeToggle";
|
|
@@ -100,6 +102,26 @@ export {
|
|
|
100
102
|
} from "../AlertDialog";
|
|
101
103
|
export type { AlertDialogProps, AlertDialogContentProps } from "../AlertDialog";
|
|
102
104
|
export { ConfirmDialog } from "../ConfirmDialog";
|
|
105
|
+
export { BulkActionBar, BULK_BAR_LABEL_MIN_WIDTH } from "../BulkActionBar";
|
|
106
|
+
export type { BulkActionBarProps, BulkBarAction } from "../BulkActionBar";
|
|
107
|
+
export {
|
|
108
|
+
DropdownMenu,
|
|
109
|
+
DropdownMenuTrigger,
|
|
110
|
+
DropdownMenuContent,
|
|
111
|
+
DropdownMenuLabel,
|
|
112
|
+
DropdownMenuItem,
|
|
113
|
+
DropdownMenuRadioItem,
|
|
114
|
+
DropdownMenuSeparator,
|
|
115
|
+
} from "../DropdownMenu";
|
|
116
|
+
export type {
|
|
117
|
+
DropdownMenuProps,
|
|
118
|
+
DropdownMenuTriggerProps,
|
|
119
|
+
DropdownMenuContentProps,
|
|
120
|
+
DropdownMenuAlign,
|
|
121
|
+
DropdownMenuLabelProps,
|
|
122
|
+
DropdownMenuItemProps,
|
|
123
|
+
DropdownMenuRadioItemProps,
|
|
124
|
+
} from "../DropdownMenu";
|
|
103
125
|
export type { ConfirmDialogProps } from "../ConfirmDialog";
|
|
104
126
|
export {
|
|
105
127
|
Sheet,
|
package/src/entries/theme.ts
CHANGED
|
@@ -11,5 +11,7 @@ export {
|
|
|
11
11
|
export type { NativeUiTheme } from "../theme";
|
|
12
12
|
export { palette, withAlpha } from "../palette";
|
|
13
13
|
export type { Palette } from "../palette";
|
|
14
|
+
export { configureTheme } from "../configureTheme";
|
|
15
|
+
export type { ThemeConfig, ThemeColors, ThemePalette } from "../configureTheme";
|
|
14
16
|
export { fontClass } from "../typography";
|
|
15
17
|
export { PINK_50, PINK_100, PINK_600, PINK_700, NEUTRAL_100, NEUTRAL_500, NEUTRAL_900, Colors } from "../colors";
|
|
@@ -7,7 +7,9 @@ import { useState, type ReactNode } from "react";
|
|
|
7
7
|
import { ScrollView, Text, View, type TextProps } from "react-native";
|
|
8
8
|
import {
|
|
9
9
|
IconCopy,
|
|
10
|
+
IconDots,
|
|
10
11
|
IconInbox,
|
|
12
|
+
IconPencil,
|
|
11
13
|
IconPhoto,
|
|
12
14
|
IconRefresh,
|
|
13
15
|
IconTrash,
|
|
@@ -28,6 +30,14 @@ import {
|
|
|
28
30
|
AmbientBackground,
|
|
29
31
|
AppMark,
|
|
30
32
|
AppWordmark,
|
|
33
|
+
BulkActionBar,
|
|
34
|
+
DropdownMenu,
|
|
35
|
+
DropdownMenuContent,
|
|
36
|
+
DropdownMenuItem,
|
|
37
|
+
DropdownMenuLabel,
|
|
38
|
+
DropdownMenuRadioItem,
|
|
39
|
+
DropdownMenuSeparator,
|
|
40
|
+
DropdownMenuTrigger,
|
|
31
41
|
Avatar,
|
|
32
42
|
AvatarFallback,
|
|
33
43
|
Badge,
|
|
@@ -226,6 +236,8 @@ function GalleryScreen() {
|
|
|
226
236
|
const [muted, setMuted] = useState(false);
|
|
227
237
|
const [sort, setSort] = useState<"newest" | "name">("newest");
|
|
228
238
|
const [page, setPage] = useState(0);
|
|
239
|
+
const [menuSort, setMenuSort] = useState<"newest" | "oldest">("newest");
|
|
240
|
+
const [picked, setPicked] = useState(2);
|
|
229
241
|
const scheme = useScheme();
|
|
230
242
|
|
|
231
243
|
return (
|
|
@@ -563,6 +575,50 @@ function GalleryScreen() {
|
|
|
563
575
|
</View>
|
|
564
576
|
</Section>
|
|
565
577
|
|
|
578
|
+
<Section title="DropdownMenu and BulkActionBar">
|
|
579
|
+
<View className="flex-row justify-end">
|
|
580
|
+
<DropdownMenu>
|
|
581
|
+
<DropdownMenuTrigger
|
|
582
|
+
accessibilityLabel="List options"
|
|
583
|
+
hitSlop={6}
|
|
584
|
+
className="h-8 w-8 items-center justify-center"
|
|
585
|
+
>
|
|
586
|
+
<IconDots size={18} color={tokens.color.muted[scheme]} />
|
|
587
|
+
</DropdownMenuTrigger>
|
|
588
|
+
<DropdownMenuContent align="end">
|
|
589
|
+
<DropdownMenuLabel>Sort</DropdownMenuLabel>
|
|
590
|
+
<DropdownMenuRadioItem checked={menuSort === "newest"} onSelect={() => setMenuSort("newest")}>
|
|
591
|
+
Newest first
|
|
592
|
+
</DropdownMenuRadioItem>
|
|
593
|
+
<DropdownMenuRadioItem checked={menuSort === "oldest"} onSelect={() => setMenuSort("oldest")}>
|
|
594
|
+
Oldest first
|
|
595
|
+
</DropdownMenuRadioItem>
|
|
596
|
+
<DropdownMenuSeparator />
|
|
597
|
+
<DropdownMenuItem icon={IconPencil} onSelect={() => toast("Rename")}>
|
|
598
|
+
Rename
|
|
599
|
+
</DropdownMenuItem>
|
|
600
|
+
<DropdownMenuItem icon={IconTrash} destructive onSelect={() => toast.error("Deleted")}>
|
|
601
|
+
Delete
|
|
602
|
+
</DropdownMenuItem>
|
|
603
|
+
</DropdownMenuContent>
|
|
604
|
+
</DropdownMenu>
|
|
605
|
+
</View>
|
|
606
|
+
<View style={{ height: 88 }}>
|
|
607
|
+
<BulkActionBar
|
|
608
|
+
isSelecting
|
|
609
|
+
selectedCount={picked}
|
|
610
|
+
totalCount={5}
|
|
611
|
+
bottomOffset={0}
|
|
612
|
+
onExitSelect={() => setPicked(0)}
|
|
613
|
+
onSelectAll={() => setPicked(5)}
|
|
614
|
+
onClearSelection={() => setPicked(0)}
|
|
615
|
+
primaryActions={[{ key: "copy", label: "Copy items", shortLabel: "Copy", icon: IconCopy, onClick: () => toast("Copied") }]}
|
|
616
|
+
moreActions={[{ key: "rename", label: "Rename", icon: IconPencil, onClick: () => toast("Rename") }]}
|
|
617
|
+
destructiveAction={{ key: "delete", label: "Delete items", shortLabel: "Delete", icon: IconTrash, destructive: true, onClick: () => toast.error("Deleted") }}
|
|
618
|
+
/>
|
|
619
|
+
</View>
|
|
620
|
+
</Section>
|
|
621
|
+
|
|
566
622
|
<Section title="EmptyState">
|
|
567
623
|
<EmptyState title="Nothing yet" description="Items you add show here." compact />
|
|
568
624
|
<EmptyState.Root variant="panel">
|
package/src/floating.ts
CHANGED
|
@@ -45,10 +45,8 @@ export const floatingHairline = tokens.color.hairline;
|
|
|
45
45
|
|
|
46
46
|
export const floatingFill = tokens.color.fill;
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
dark: tokens.color.surface.dark,
|
|
51
|
-
} as const;
|
|
48
|
+
/** The `tokens.color.surface` pair itself, so `configureTheme` reaches it. */
|
|
49
|
+
export const floatingSolidFill = tokens.color.surface;
|
|
52
50
|
|
|
53
51
|
export function tabBarReserve(largerText: boolean, bottomInset: number) {
|
|
54
52
|
const bar = largerText
|
package/src/highlight.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type * as PrismReactRenderer from "prism-react-renderer";
|
|
2
|
+
|
|
3
|
+
export type PrismReactRendererModule = typeof PrismReactRenderer;
|
|
4
|
+
|
|
5
|
+
let prismReactRenderer: PrismReactRendererModule | null | undefined;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `prism-react-renderer`, or `null` when the optional peer is not installed.
|
|
9
|
+
* `CodeBlock` highlights with it. Metro bundles a `require` placed directly in
|
|
10
|
+
* a `try` block as an optional dependency, so a missing peer does not break
|
|
11
|
+
* the bundle.
|
|
12
|
+
*/
|
|
13
|
+
export function loadPrismReactRenderer(): PrismReactRendererModule | null {
|
|
14
|
+
if (prismReactRenderer === undefined) {
|
|
15
|
+
try {
|
|
16
|
+
prismReactRenderer = require("prism-react-renderer");
|
|
17
|
+
} catch {
|
|
18
|
+
prismReactRenderer = null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// `?? null`: with the real peer types installed, TypeScript keeps `undefined`
|
|
22
|
+
// in the type after the `try` (the package typecheck only sees a stub).
|
|
23
|
+
return prismReactRenderer ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CodeColorRole =
|
|
27
|
+
| "plain"
|
|
28
|
+
| "comment"
|
|
29
|
+
| "keyword"
|
|
30
|
+
| "string"
|
|
31
|
+
| "constant"
|
|
32
|
+
| "function"
|
|
33
|
+
| "tag"
|
|
34
|
+
| "variable"
|
|
35
|
+
| "inserted"
|
|
36
|
+
| "deleted";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The shiki `github-light` and `github-dark` colours the web `CodeBlock`
|
|
40
|
+
* uses, by role. Plain text is the theme foreground.
|
|
41
|
+
*/
|
|
42
|
+
export const GITHUB_CODE_COLORS: Record<"light" | "dark", Record<CodeColorRole, string>> = {
|
|
43
|
+
light: {
|
|
44
|
+
plain: "#24292e",
|
|
45
|
+
comment: "#6a737d",
|
|
46
|
+
keyword: "#d73a49",
|
|
47
|
+
string: "#032f62",
|
|
48
|
+
constant: "#005cc5",
|
|
49
|
+
function: "#6f42c1",
|
|
50
|
+
tag: "#22863a",
|
|
51
|
+
variable: "#e36209",
|
|
52
|
+
inserted: "#22863a",
|
|
53
|
+
deleted: "#b31d28",
|
|
54
|
+
},
|
|
55
|
+
dark: {
|
|
56
|
+
plain: "#e1e4e8",
|
|
57
|
+
comment: "#6a737d",
|
|
58
|
+
keyword: "#f97583",
|
|
59
|
+
string: "#9ecbff",
|
|
60
|
+
constant: "#79b8ff",
|
|
61
|
+
function: "#b392f0",
|
|
62
|
+
tag: "#85e89d",
|
|
63
|
+
variable: "#ffab70",
|
|
64
|
+
inserted: "#85e89d",
|
|
65
|
+
deleted: "#fdaeb7",
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Prism token types to their GitHub role. Types not listed are plain. */
|
|
70
|
+
const PRISM_ROLE: Record<string, CodeColorRole> = {
|
|
71
|
+
comment: "comment",
|
|
72
|
+
prolog: "comment",
|
|
73
|
+
doctype: "comment",
|
|
74
|
+
cdata: "comment",
|
|
75
|
+
keyword: "keyword",
|
|
76
|
+
operator: "keyword",
|
|
77
|
+
atrule: "keyword",
|
|
78
|
+
important: "keyword",
|
|
79
|
+
rule: "keyword",
|
|
80
|
+
string: "string",
|
|
81
|
+
char: "string",
|
|
82
|
+
"attr-value": "string",
|
|
83
|
+
regex: "string",
|
|
84
|
+
url: "string",
|
|
85
|
+
"template-string": "string",
|
|
86
|
+
number: "constant",
|
|
87
|
+
boolean: "constant",
|
|
88
|
+
constant: "constant",
|
|
89
|
+
property: "constant",
|
|
90
|
+
"attr-name": "constant",
|
|
91
|
+
symbol: "constant",
|
|
92
|
+
builtin: "constant",
|
|
93
|
+
entity: "constant",
|
|
94
|
+
function: "function",
|
|
95
|
+
"function-variable": "function",
|
|
96
|
+
"class-name": "function",
|
|
97
|
+
maybe_class_name: "function",
|
|
98
|
+
tag: "tag",
|
|
99
|
+
selector: "tag",
|
|
100
|
+
variable: "variable",
|
|
101
|
+
parameter: "variable",
|
|
102
|
+
inserted: "inserted",
|
|
103
|
+
deleted: "deleted",
|
|
104
|
+
punctuation: "plain",
|
|
105
|
+
plain: "plain",
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The colour of a Prism token from its types. Later types win, as in
|
|
110
|
+
* `prism-react-renderer`'s theme lookup: the `<` of a tag (`["tag",
|
|
111
|
+
* "punctuation"]`) is plain.
|
|
112
|
+
*/
|
|
113
|
+
export function codeTokenColor(types: readonly string[], scheme: "light" | "dark"): string {
|
|
114
|
+
let role: CodeColorRole = "plain";
|
|
115
|
+
for (const type of types) role = PRISM_ROLE[type] ?? role;
|
|
116
|
+
return GITHUB_CODE_COLORS[scheme][role];
|
|
117
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,8 @@ export {
|
|
|
10
10
|
export type { NativeUiTheme } from "./theme";
|
|
11
11
|
export { palette, withAlpha } from "./palette";
|
|
12
12
|
export type { Palette } from "./palette";
|
|
13
|
+
export { configureTheme } from "./configureTheme";
|
|
14
|
+
export type { ThemeConfig, ThemeColors, ThemePalette } from "./configureTheme";
|
|
13
15
|
export { fontClass } from "./typography";
|
|
14
16
|
export {
|
|
15
17
|
FLOATING_INSET,
|
|
@@ -93,6 +95,8 @@ export type {
|
|
|
93
95
|
TabsContentProps,
|
|
94
96
|
} from "./Tabs";
|
|
95
97
|
export { ListRow } from "./ListRow";
|
|
98
|
+
export { DividedList } from "./DividedList";
|
|
99
|
+
export type { DividedListProps } from "./DividedList";
|
|
96
100
|
export type { ListRowProps } from "./ListRow";
|
|
97
101
|
export { HeaderIconButton } from "./HeaderIconButton";
|
|
98
102
|
export { ThemeToggle } from "./ThemeToggle";
|
|
@@ -134,6 +138,26 @@ export {
|
|
|
134
138
|
} from "./AlertDialog";
|
|
135
139
|
export type { AlertDialogProps, AlertDialogContentProps } from "./AlertDialog";
|
|
136
140
|
export { ConfirmDialog } from "./ConfirmDialog";
|
|
141
|
+
export { BulkActionBar, BULK_BAR_LABEL_MIN_WIDTH } from "./BulkActionBar";
|
|
142
|
+
export type { BulkActionBarProps, BulkBarAction } from "./BulkActionBar";
|
|
143
|
+
export {
|
|
144
|
+
DropdownMenu,
|
|
145
|
+
DropdownMenuTrigger,
|
|
146
|
+
DropdownMenuContent,
|
|
147
|
+
DropdownMenuLabel,
|
|
148
|
+
DropdownMenuItem,
|
|
149
|
+
DropdownMenuRadioItem,
|
|
150
|
+
DropdownMenuSeparator,
|
|
151
|
+
} from "./DropdownMenu";
|
|
152
|
+
export type {
|
|
153
|
+
DropdownMenuProps,
|
|
154
|
+
DropdownMenuTriggerProps,
|
|
155
|
+
DropdownMenuContentProps,
|
|
156
|
+
DropdownMenuAlign,
|
|
157
|
+
DropdownMenuLabelProps,
|
|
158
|
+
DropdownMenuItemProps,
|
|
159
|
+
DropdownMenuRadioItemProps,
|
|
160
|
+
} from "./DropdownMenu";
|
|
137
161
|
export type { ConfirmDialogProps } from "./ConfirmDialog";
|
|
138
162
|
export {
|
|
139
163
|
Sheet,
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useContext, type ReactNode } from "react";
|
|
2
|
+
import { Text, View, type TextStyle, type ViewStyle } from "react-native";
|
|
3
|
+
import { Renderer, useMarkdown, type MarkedStyles } from "react-native-marked";
|
|
4
|
+
import { CodeBlock } from "../ai/code-block";
|
|
5
|
+
import { MONO_FONT, chatTextSize } from "../ai/chat-text";
|
|
6
|
+
import { ChatTextColorContext } from "../ai/response-text";
|
|
7
|
+
import { sizeStyle } from "../controlStyles";
|
|
8
|
+
import { palette, withAlpha } from "../palette";
|
|
9
|
+
import { useLargerText } from "../theme";
|
|
10
|
+
import { tokens } from "../tokens";
|
|
11
|
+
import { useScheme } from "../useScheme";
|
|
12
|
+
|
|
13
|
+
/** Instrument Sans faces, as the app loads them (see `INSTRUMENT_SANS` in tailwind-preset.cjs). */
|
|
14
|
+
const FONT = {
|
|
15
|
+
regular: "InstrumentSans_400Regular",
|
|
16
|
+
semibold: "InstrumentSans_600SemiBold",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sizes from the web `markdown.css` (`@vvedantb/ui/tokens/css/markdown.css`),
|
|
21
|
+
* in em of the body size: h1 1.43, h2 1.29, h3 1.14, h4-h6 1; inline code
|
|
22
|
+
* 0.86. Headings line-height 1.3; blocks 0.65rem apart.
|
|
23
|
+
*/
|
|
24
|
+
export const MARKDOWN_SCALE = {
|
|
25
|
+
h1: 1.43,
|
|
26
|
+
h2: 1.29,
|
|
27
|
+
h3: 1.14,
|
|
28
|
+
h4: 1,
|
|
29
|
+
inlineCode: 0.86,
|
|
30
|
+
headingLineHeight: 1.3,
|
|
31
|
+
blockGap: 10,
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
export type MarkdownProps = {
|
|
35
|
+
/** The markdown source. */
|
|
36
|
+
children: string;
|
|
37
|
+
/** With it, fenced code blocks get a copy button (native-ui adds no clipboard package). */
|
|
38
|
+
onCopyCode?: (code: string) => void | Promise<void>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Markdown at chat size, styled as the web `.markdown` (Streamdown) block:
|
|
43
|
+
* compact headings, primary links underlined at 30%, inline code on a 6%
|
|
44
|
+
* foreground wash at radius 6, and fenced code as `CodeBlock` (the flat
|
|
45
|
+
* `surface-secondary` panel with a language and copy header). Text takes the
|
|
46
|
+
* enclosing `MessageContent` colour, like `ResponseText`.
|
|
47
|
+
*
|
|
48
|
+
* Needs the optional peer `react-native-marked` (it renders through
|
|
49
|
+
* `useMarkdown`, so it nests in a `ScrollView` without a `FlatList`).
|
|
50
|
+
*/
|
|
51
|
+
export function Markdown({ children, onCopyCode }: MarkdownProps) {
|
|
52
|
+
const scheme = useScheme();
|
|
53
|
+
const largerText = useLargerText();
|
|
54
|
+
const inherited = useContext(ChatTextColorContext);
|
|
55
|
+
const body = sizeStyle(chatTextSize("body", largerText));
|
|
56
|
+
const color = inherited ?? tokens.color.foreground[scheme];
|
|
57
|
+
const renderer = new NativeUiMarkdownRenderer(scheme, body.fontSize, onCopyCode);
|
|
58
|
+
const elements = useMarkdown(children, {
|
|
59
|
+
colorScheme: scheme,
|
|
60
|
+
renderer,
|
|
61
|
+
theme: {
|
|
62
|
+
colors: {
|
|
63
|
+
text: color,
|
|
64
|
+
link: palette.primary[scheme],
|
|
65
|
+
code: tokens.color.surfaceSecondary[scheme],
|
|
66
|
+
border: tokens.color.border[scheme],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
styles: markdownStyles(scheme, body, color),
|
|
70
|
+
});
|
|
71
|
+
return <>{elements}</>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The style map `useMarkdown` takes, from the web scale. Exported for tests. */
|
|
75
|
+
export function markdownStyles(
|
|
76
|
+
scheme: "light" | "dark",
|
|
77
|
+
body: { fontSize: number; lineHeight: number },
|
|
78
|
+
color: string,
|
|
79
|
+
): MarkedStyles {
|
|
80
|
+
const heading = (scale: number): TextStyle => ({
|
|
81
|
+
fontFamily: FONT.semibold,
|
|
82
|
+
fontWeight: "600",
|
|
83
|
+
fontSize: Math.round(body.fontSize * scale),
|
|
84
|
+
lineHeight: Math.round(body.fontSize * scale * MARKDOWN_SCALE.headingLineHeight),
|
|
85
|
+
letterSpacing: -0.01 * body.fontSize * scale,
|
|
86
|
+
color,
|
|
87
|
+
marginTop: 20,
|
|
88
|
+
marginBottom: 8,
|
|
89
|
+
});
|
|
90
|
+
const gap = { marginVertical: MARKDOWN_SCALE.blockGap / 2 };
|
|
91
|
+
return {
|
|
92
|
+
text: { fontFamily: FONT.regular, fontSize: body.fontSize, lineHeight: body.lineHeight, color },
|
|
93
|
+
paragraph: { ...gap, paddingVertical: 0 },
|
|
94
|
+
strong: { fontFamily: FONT.semibold, fontWeight: "600" },
|
|
95
|
+
em: { fontStyle: "italic" },
|
|
96
|
+
link: {
|
|
97
|
+
color: palette.primary[scheme],
|
|
98
|
+
textDecorationLine: "underline",
|
|
99
|
+
textDecorationColor: withAlpha(palette.primary[scheme], 0.3),
|
|
100
|
+
},
|
|
101
|
+
h1: heading(MARKDOWN_SCALE.h1),
|
|
102
|
+
h2: heading(MARKDOWN_SCALE.h2),
|
|
103
|
+
h3: heading(MARKDOWN_SCALE.h3),
|
|
104
|
+
h4: heading(MARKDOWN_SCALE.h4),
|
|
105
|
+
h5: heading(MARKDOWN_SCALE.h4),
|
|
106
|
+
h6: { ...heading(MARKDOWN_SCALE.h4), color: tokens.color.muted[scheme] },
|
|
107
|
+
li: { fontFamily: FONT.regular, fontSize: body.fontSize, lineHeight: body.lineHeight, color },
|
|
108
|
+
blockquote: {
|
|
109
|
+
...gap,
|
|
110
|
+
paddingLeft: 13,
|
|
111
|
+
borderLeftWidth: 2,
|
|
112
|
+
borderLeftColor: tokens.color.border[scheme],
|
|
113
|
+
backgroundColor: "transparent",
|
|
114
|
+
},
|
|
115
|
+
hr: { marginVertical: 20, height: 1, backgroundColor: tokens.color.border[scheme] },
|
|
116
|
+
codespan: inlineCodeStyle(scheme, body.fontSize),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Web `.markdown-inline-code`: 0.86em mono on a 6% foreground wash, radius 6. */
|
|
121
|
+
export function inlineCodeStyle(scheme: "light" | "dark", fontSize: number): TextStyle {
|
|
122
|
+
return {
|
|
123
|
+
fontFamily: MONO_FONT,
|
|
124
|
+
fontStyle: "normal",
|
|
125
|
+
fontSize: Math.round(fontSize * MARKDOWN_SCALE.inlineCode),
|
|
126
|
+
backgroundColor: withAlpha(tokens.color.foreground[scheme], 0.06),
|
|
127
|
+
borderRadius: 6,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Web `.markdown` lists: 1.25rem (20) indent, 0.25rem (4) between items, muted markers (a 5-wide disc). */
|
|
132
|
+
export const MARKDOWN_LIST = { indent: 20, itemGap: 4, disc: 5 } as const;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `react-native-marked`'s renderer with native-ui code and lists: `CodeBlock`
|
|
136
|
+
* panels, inline-code chips, and lists drawn as the web `.markdown` lists
|
|
137
|
+
* (the library's own markers apply its marker style to every item).
|
|
138
|
+
*/
|
|
139
|
+
class NativeUiMarkdownRenderer extends Renderer {
|
|
140
|
+
constructor(
|
|
141
|
+
private readonly scheme: "light" | "dark",
|
|
142
|
+
private readonly fontSize: number,
|
|
143
|
+
private readonly onCopyCode: MarkdownProps["onCopyCode"],
|
|
144
|
+
) {
|
|
145
|
+
super();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
override code(text: string, language?: string): ReactNode {
|
|
149
|
+
return <CodeBlock key={this.getKey()} code={text} language={language || undefined} onCopy={this.onCopyCode} />;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
override codespan(text: string): ReactNode {
|
|
153
|
+
// Text in Text takes no padding: no-break spaces pad the chip and keep it on one line.
|
|
154
|
+
return (
|
|
155
|
+
<Text key={this.getKey()} selectable style={inlineCodeStyle(this.scheme, this.fontSize)}>
|
|
156
|
+
{`\u00A0${text}\u00A0`}
|
|
157
|
+
</Text>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
override list(
|
|
162
|
+
ordered: boolean,
|
|
163
|
+
li: ReactNode[],
|
|
164
|
+
_listStyle?: ViewStyle,
|
|
165
|
+
textStyle?: TextStyle,
|
|
166
|
+
startIndex = 1,
|
|
167
|
+
): ReactNode {
|
|
168
|
+
const muted = tokens.color.muted[this.scheme];
|
|
169
|
+
const lineHeight = typeof textStyle?.lineHeight === "number" ? textStyle.lineHeight : 24;
|
|
170
|
+
// A drawn disc: the font's bullet glyph is far smaller than the web `disc` marker.
|
|
171
|
+
const disc = MARKDOWN_LIST.disc;
|
|
172
|
+
return (
|
|
173
|
+
<View key={this.getKey()} style={{ marginVertical: MARKDOWN_SCALE.blockGap / 2, gap: MARKDOWN_LIST.itemGap }}>
|
|
174
|
+
{li.map((item, index) => (
|
|
175
|
+
<View key={index} style={{ flexDirection: "row" }}>
|
|
176
|
+
{ordered ? (
|
|
177
|
+
<Text style={[textStyle, { width: MARKDOWN_LIST.indent, color: muted, fontVariant: ["tabular-nums"] }]}>
|
|
178
|
+
{`${startIndex + index}.`}
|
|
179
|
+
</Text>
|
|
180
|
+
) : (
|
|
181
|
+
<View style={{ width: MARKDOWN_LIST.indent, paddingTop: (lineHeight - disc) / 2 }}>
|
|
182
|
+
<View style={{ width: disc, height: disc, borderRadius: disc / 2, backgroundColor: muted }} />
|
|
183
|
+
</View>
|
|
184
|
+
)}
|
|
185
|
+
<View style={{ flex: 1 }}>{item}</View>
|
|
186
|
+
</View>
|
|
187
|
+
))}
|
|
188
|
+
</View>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|