Files
paseo/packages/app/src/components/ui/scrollable-code-surface.tsx
T
Mohamed Boudra 38c22139bb Let plugins control modal layout, scrolling and clipboard actions (#4392)
* feat(plugins): support modal body layout, scrolling and clipboard actions

* fix(app): keep native scroll dependencies out of web imports

* fix(nix): sync dependency hash for plugin native types

* test(plugins): express modal QA as user journeys
2026-09-06 13:48:29 +02:00

204 lines
5.0 KiB
TypeScript

import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ScrollView } from "./scroll-view";
import { isWeb } from "@/constants/platform";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
type CodeSurfaceTone = "surface0" | "surface1" | "surface2";
interface ScrollableCodeSurfaceProps {
children: ReactNode;
maxHeight?: number;
horizontal?: boolean;
selectable?: boolean;
tone?: CodeSurfaceTone;
bordered?: boolean;
style?: StyleProp<ViewStyle>;
scrollStyle?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
testID?: string;
accessibilityLabel?: string;
}
interface SurfaceCardProps {
children: ReactNode;
tone?: CodeSurfaceTone;
bordered?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
accessibilityLabel?: string;
codeSurface?: boolean;
}
export function SurfaceCard({
children,
tone = "surface1",
bordered = true,
style,
testID,
accessibilityLabel,
codeSurface = false,
}: SurfaceCardProps) {
const surfaceStyle = getSurfaceStyle(tone);
const containerStyle = useMemo(
() => [styles.container, surfaceStyle, bordered && styles.bordered, style],
[bordered, style, surfaceStyle],
);
return (
<View
style={containerStyle}
dataSet={codeSurface ? CODE_SURFACE_DATASET : undefined}
testID={testID}
accessible={accessibilityLabel != null}
accessibilityLabel={accessibilityLabel}
>
{children}
</View>
);
}
export function ScrollableCodeSurface({
children,
maxHeight,
horizontal = true,
selectable = true,
tone = "surface1",
bordered = true,
style,
scrollStyle,
contentContainerStyle,
textStyle,
testID,
accessibilityLabel,
}: ScrollableCodeSurfaceProps) {
const maxHeightStyle = useMemo(
() => (maxHeight == null ? null : inlineUnistylesStyle({ maxHeight })),
[maxHeight],
);
const outerScrollStyle = useMemo(
() => [styles.scroll, maxHeightStyle, scrollStyle],
[maxHeightStyle, scrollStyle],
);
const contentStyle = useMemo(
() => [styles.content, contentContainerStyle],
[contentContainerStyle],
);
const codeTextStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
const codeContent =
typeof children === "string" ? (
<PlainCodeText text={children} selectable={selectable} textStyle={codeTextStyle} />
) : (
<Text selectable={selectable} style={codeTextStyle} dataSet={CODE_SURFACE_DATASET}>
{children}
</Text>
);
return (
<SurfaceCard
tone={tone}
bordered={bordered}
style={[styles.scroll, style]}
testID={testID}
accessibilityLabel={accessibilityLabel}
codeSurface
>
<ScrollView style={outerScrollStyle} nestedScrollEnabled showsVerticalScrollIndicator>
<View style={contentStyle}>
{horizontal ? (
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
{codeContent}
</ScrollView>
) : (
codeContent
)}
</View>
</ScrollView>
</SurfaceCard>
);
}
function PlainCodeText({
text,
selectable,
textStyle,
}: {
text: string;
selectable: boolean;
textStyle: StyleProp<TextStyle>;
}) {
const lines = useMemo(() => {
let offset = 0;
return text.split("\n").map((line) => {
const key = `${offset}:${line.length}`;
offset += line.length + 1;
return { key, line };
});
}, [text]);
return (
<View dataSet={CODE_SURFACE_DATASET}>
{lines.map(({ key, line }) => (
<Text key={key} selectable={selectable} style={textStyle} dataSet={CODE_SURFACE_DATASET}>
{line.length === 0 ? "\u200B" : line}
</Text>
))}
</View>
);
}
function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp<ViewStyle> {
switch (tone) {
case "surface0":
return styles.surface0;
case "surface2":
return styles.surface2;
case "surface1":
return styles.surface1;
}
}
const styles = StyleSheet.create((theme) => ({
scroll: {
flexShrink: 1,
minHeight: 0,
},
container: {
overflow: "hidden",
},
bordered: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
surface0: {
backgroundColor: theme.colors.surface0,
},
surface1: {
backgroundColor: theme.colors.surface1,
},
surface2: {
backgroundColor: theme.colors.surface2,
},
content: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
text: {
backgroundColor: "transparent",
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
...(isWeb
? {
whiteSpace: "pre",
overflowWrap: "normal",
}
: null),
},
}));