mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
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
This commit is contained in:
+2
-1
@@ -155,7 +155,8 @@ Shared files import contract helpers and types from `@getpaseo/plugin`. Server h
|
||||
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||
`<Modal.Content>`.
|
||||
`<Modal.Content>`. Body layout, sheet-aware scrolling, and clipboard actions follow the
|
||||
[host UI contract](../public-docs/plugins/v0.8/reference.md#host-ui).
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/v0.8/reference.md`.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE=
|
||||
sha256-Vw6bgh+gPdVxySdbawKawr9qZT8QEEo2+okdpuKceMA=
|
||||
|
||||
Generated
+7
@@ -38700,6 +38700,7 @@
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.6",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38708,7 +38709,13 @@
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"react": "19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"packages/plugin/node_modules/@agentclientprotocol/sdk": {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import path from "node:path";
|
||||
import { expect, test as base, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import {
|
||||
openMobileAgentSidebar,
|
||||
expectMobileAgentSidebarVisible,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
async function openExample(page: Page, example: string) {
|
||||
await page.getByRole("button", { name: `Open ${example}`, exact: true }).click();
|
||||
await expect(page.getByText(`Modal example: ${example}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function closeExample(page: Page) {
|
||||
await page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await expect(page.getByRole("button", { name: "Open Form", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function copyAndPaste(page: Page) {
|
||||
await page.getByRole("button", { name: "Copy text", exact: true }).click();
|
||||
await expect(page.getByText("Text copied", { exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("Copied from Paseo");
|
||||
await page.getByRole("textbox", { name: "Paste here" }).focus();
|
||||
await page.keyboard.press("ControlOrMeta+A");
|
||||
await page.keyboard.press("ControlOrMeta+V");
|
||||
await expect(page.getByRole("textbox", { name: "Paste here" })).toHaveValue("Copied from Paseo");
|
||||
}
|
||||
|
||||
async function scrollToLastRow(page: Page) {
|
||||
await page.getByRole("button", { name: "Row 1", exact: true }).hover();
|
||||
await page.mouse.wheel(0, 7000);
|
||||
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
|
||||
await page.getByRole("button", { name: "Row 100", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectSingleInset(page: Page, left = "24px", bottom = "24px") {
|
||||
const form = await page.getByTestId("modal-example-form").boundingBox();
|
||||
const input = await page.getByRole("textbox", { name: "Paste here" }).boundingBox();
|
||||
expect(form).not.toBeNull();
|
||||
expect(input?.x).toBe(form?.x);
|
||||
// The real content View owns the inset, not a third-party contentContainerStyle.
|
||||
const padding = await page.getByTestId("modal-example-form").evaluate((node) => {
|
||||
const content = node.parentElement!;
|
||||
return {
|
||||
left: getComputedStyle(content).paddingLeft,
|
||||
bottom: getComputedStyle(content).paddingBottom,
|
||||
};
|
||||
});
|
||||
expect(padding).toEqual({ left, bottom });
|
||||
}
|
||||
|
||||
async function expectFullBleed(page: Page) {
|
||||
const padding = await page
|
||||
.getByRole("button", { name: "Row 1", exact: true })
|
||||
.evaluate((node) => {
|
||||
const content = node.parentElement!;
|
||||
return {
|
||||
left: getComputedStyle(content).paddingLeft,
|
||||
bottom: getComputedStyle(content).paddingBottom,
|
||||
};
|
||||
});
|
||||
expect(padding).toEqual({ left: "0px", bottom: "0px" });
|
||||
}
|
||||
|
||||
async function exerciseBodies(page: Page) {
|
||||
await openExample(page, "Form");
|
||||
await expectSingleInset(page);
|
||||
await copyAndPaste(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "Custom inset");
|
||||
await expectSingleInset(page, "8px", "40px");
|
||||
await closeExample(page);
|
||||
await openExample(page, "Full bleed");
|
||||
await expectFullBleed(page);
|
||||
await scrollToLastRow(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "ScrollView");
|
||||
await scrollToLastRow(page);
|
||||
await closeExample(page);
|
||||
await openExample(page, "FlatList");
|
||||
await page.getByRole("button", { name: "Jump to last row", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Row 100", exact: true })).toBeInViewport();
|
||||
await closeExample(page);
|
||||
await openExample(page, "Horizontal");
|
||||
await page.getByRole("button", { name: "Overview", exact: true }).hover();
|
||||
await page.mouse.wheel(900, 0);
|
||||
await expect(page.getByRole("button", { name: "System logs", exact: true })).toBeInViewport();
|
||||
await page.getByRole("button", { name: "System logs", exact: true }).click();
|
||||
await closeExample(page);
|
||||
await expect(page.getByText("Selected: System logs", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
const test = base.extend<{ modalExample: Page }>({
|
||||
modalExample: async ({ page, context }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(
|
||||
path.resolve(__dirname, "../../../../plugin-examples/modal-ui"),
|
||||
);
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await provide(page);
|
||||
} finally {
|
||||
await client.removePlugin("modal-ui-example").catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function openWideExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
}
|
||||
|
||||
async function openCompactExamples(page: Page) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
await page.getByRole("button", { name: "Modal examples", exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectDeniedCopyFeedback(page: Page) {
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const { targetInfo } = await cdp.send("Target.getTargetInfo");
|
||||
await cdp.send("Browser.setPermission", {
|
||||
browserContextId: targetInfo.browserContextId,
|
||||
permission: { name: "clipboard-write" },
|
||||
setting: "denied",
|
||||
origin: new URL(page.url()).origin,
|
||||
});
|
||||
expect(
|
||||
await page.evaluate(
|
||||
async () =>
|
||||
(await navigator.permissions.query({ name: "clipboard-write" as PermissionName })).state,
|
||||
),
|
||||
).toBe("denied");
|
||||
await openExample(page, "Form");
|
||||
await page.getByRole("button", { name: "Copy text", exact: true }).click();
|
||||
await expect(
|
||||
page.getByText("Could not copy text. Select the text and use Copy.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await closeExample(page);
|
||||
} finally {
|
||||
await cdp.detach();
|
||||
}
|
||||
}
|
||||
|
||||
test("plugin bodies support padding, scroll ownership, horizontal tabs and clipboard on wide and compact layouts", async ({
|
||||
modalExample: page,
|
||||
}) => {
|
||||
await openWideExamples(page);
|
||||
await test.step("wide dialogs", () => exerciseBodies(page));
|
||||
await openCompactExamples(page);
|
||||
await test.step("compact sheets", () => exerciseBodies(page));
|
||||
await test.step("denied clipboard writes report failure", () => expectDeniedCopyFeedback(page));
|
||||
});
|
||||
@@ -5,30 +5,26 @@ import {
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
|
||||
describe("getCompactSheetSafeAreaPadding", () => {
|
||||
it("adds the bottom inset to compact sheet footers", () => {
|
||||
it("assigns safe clearance to the footer independently of decorative padding", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
isKeyboardVisible: false,
|
||||
hasFooter: true,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({ footerPaddingBottom: 46 });
|
||||
).toEqual({ footerPaddingBottom: 34 });
|
||||
});
|
||||
|
||||
it("adds the bottom inset to compact sheet content when there is no footer", () => {
|
||||
it("assigns safe clearance to the body only without a footer", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
isKeyboardVisible: false,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({ contentPaddingBottom: 58 });
|
||||
).toEqual({ contentPaddingBottom: 34 });
|
||||
});
|
||||
|
||||
it("does not add a safe-area band above the compact keyboard", () => {
|
||||
@@ -37,8 +33,6 @@ describe("getCompactSheetSafeAreaPadding", () => {
|
||||
isCompact: true,
|
||||
isKeyboardVisible: true,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({});
|
||||
@@ -50,8 +44,6 @@ describe("getCompactSheetSafeAreaPadding", () => {
|
||||
isCompact: false,
|
||||
isKeyboardVisible: false,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({});
|
||||
|
||||
@@ -2,8 +2,6 @@ export interface CompactSheetSafeAreaPaddingInput {
|
||||
isCompact: boolean;
|
||||
isKeyboardVisible: boolean;
|
||||
hasFooter: boolean;
|
||||
baseContentPadding: number;
|
||||
baseFooterPadding: number;
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
@@ -38,8 +36,6 @@ export function getCompactSheetSafeAreaPadding({
|
||||
isCompact,
|
||||
isKeyboardVisible,
|
||||
hasFooter,
|
||||
baseContentPadding,
|
||||
baseFooterPadding,
|
||||
safeAreaBottom,
|
||||
}: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding {
|
||||
if (!isCompact || isKeyboardVisible || safeAreaBottom <= 0) {
|
||||
@@ -47,8 +43,8 @@ export function getCompactSheetSafeAreaPadding({
|
||||
}
|
||||
|
||||
if (hasFooter) {
|
||||
return { footerPaddingBottom: baseFooterPadding + safeAreaBottom };
|
||||
return { footerPaddingBottom: safeAreaBottom };
|
||||
}
|
||||
|
||||
return { contentPaddingBottom: baseContentPadding + safeAreaBottom };
|
||||
return { contentPaddingBottom: safeAreaBottom };
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Modal, Platform, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
import { Modal, Platform, Pressable, Text, View } from "react-native";
|
||||
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
getBottomSheetVisibleContentHeight,
|
||||
getCompactSheetSafeAreaPadding,
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -67,6 +67,7 @@ export interface SheetHeader {
|
||||
search?: SheetHeaderSearch;
|
||||
}
|
||||
|
||||
const SCROLL_CONTENT_GROW = { flexGrow: 1 };
|
||||
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
@@ -79,6 +80,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
desktopCard: {
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
maxWidth: 520,
|
||||
maxHeight: "85%",
|
||||
@@ -445,8 +447,12 @@ export interface AdaptiveModalSheetProps {
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
desktopMaxWidth?: number;
|
||||
/** Bound an author-owned list without changing content-sized first-party dialogs. */
|
||||
desktopHeight?: DimensionValue;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/** Full body viewport below the header, including space beyond the content. */
|
||||
bodyStyle?: StyleProp<ViewStyle>;
|
||||
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** Size compact sheet content to the live snap height instead of its largest snap point. */
|
||||
@@ -466,10 +472,12 @@ export function AdaptiveModalSheet({
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
desktopHeight,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
contentStyle,
|
||||
sizeContentToCurrentSnapPoint = false,
|
||||
bodyStyle,
|
||||
sizeContentToCurrentSnapPoint = true,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -484,43 +492,22 @@ export function AdaptiveModalSheet({
|
||||
isCompact: isMobile,
|
||||
isKeyboardVisible,
|
||||
hasFooter: Boolean(footer),
|
||||
baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
baseFooterPadding: theme.spacing[3],
|
||||
safeAreaBottom: insets.bottom,
|
||||
}),
|
||||
[footer, insets.bottom, isKeyboardVisible, isMobile, theme.spacing],
|
||||
[footer, insets.bottom, isKeyboardVisible, isMobile],
|
||||
);
|
||||
const compactContentStyle = useMemo(
|
||||
() => [
|
||||
contentStyle,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.contentPaddingBottom, contentStyle],
|
||||
);
|
||||
const compactStaticContentStyle = useMemo(
|
||||
() => [styles.compactStaticContent, compactContentStyle],
|
||||
[compactContentStyle],
|
||||
);
|
||||
const desktopScrollContentStyle = useMemo(
|
||||
() => [styles.contentGrow, contentStyle],
|
||||
[contentStyle],
|
||||
);
|
||||
const desktopStaticContentStyle = useMemo(
|
||||
() => [styles.desktopStaticContent, contentStyle],
|
||||
[contentStyle],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [
|
||||
styles.footer,
|
||||
footerContainerStyle,
|
||||
compactSafeAreaPadding.footerPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.footerPaddingBottom, footerContainerStyle],
|
||||
// Safe-area clearance is a separate layer: it must not replace the caller's
|
||||
// padding (including an explicit zero), and the footer owns it when present.
|
||||
const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 };
|
||||
const footerClearanceStyle = useMemo(
|
||||
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const footerView = footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null;
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
@@ -554,8 +541,12 @@ export function AdaptiveModalSheet({
|
||||
);
|
||||
|
||||
const desktopCardStyle = useMemo(
|
||||
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
||||
[desktopMaxWidth],
|
||||
() => [
|
||||
styles.desktopCard,
|
||||
desktopHeight != null && { height: desktopHeight },
|
||||
desktopMaxWidth != null && { maxWidth: desktopMaxWidth },
|
||||
],
|
||||
[desktopMaxWidth, desktopHeight],
|
||||
);
|
||||
const desktopOverlayStyle = useMemo(
|
||||
() => [
|
||||
@@ -620,18 +611,27 @@ export function AdaptiveModalSheet({
|
||||
const sheetContent = (
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
style={sizeContentToCurrentSnapPoint ? styles.bottomSheetVisibleScroll : undefined}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<SheetContent style={compactContentStyle}>{children}</SheetContent>
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<SheetContent style={compactStaticContentStyle}>{children}</SheetContent>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.bottomSheetVisibleScroll}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.contentGrow, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{footerView}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -646,6 +646,9 @@ export function AdaptiveModalSheet({
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
// A custom scroll owner must also own body gestures. Gorhom otherwise
|
||||
// locks even imperative list offsets until the sheet reaches its top snap.
|
||||
enableContentPanningGesture={scrollable}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
keyboardBehavior="extend"
|
||||
@@ -662,24 +665,26 @@ export function AdaptiveModalSheet({
|
||||
);
|
||||
}
|
||||
|
||||
const desktopStaticStyle =
|
||||
desktopHeight == null ? styles.desktopStaticContent : styles.compactStaticContent;
|
||||
const cardInner = (
|
||||
<OverlayLayerProvider layer={modalLayer}>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{scrollable ? (
|
||||
<View style={styles.desktopScrollContainer}>
|
||||
<View style={[scrollable ? styles.desktopScrollContainer : desktopStaticStyle, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.contentGrow}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<SheetContent style={desktopScrollContentStyle}>{children}</SheetContent>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : (
|
||||
<SheetContent style={desktopStaticContentStyle}>{children}</SheetContent>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
) : (
|
||||
<SheetContent style={[desktopStaticStyle, contentStyle]}>{children}</SheetContent>
|
||||
)}
|
||||
</View>
|
||||
{footerView}
|
||||
</OverlayLayerProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { BottomSheetFlatList, BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
FlatList as SheetFlatList,
|
||||
ScrollView as SheetScrollView,
|
||||
} from "@/components/ui/scroll-view";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -1190,7 +1193,7 @@ function ModelRowList({
|
||||
|
||||
if (isCompact && isNative) {
|
||||
return (
|
||||
<BottomSheetFlatList
|
||||
<SheetFlatList
|
||||
data={rows}
|
||||
renderItem={renderItem}
|
||||
ListHeaderComponent={header}
|
||||
@@ -1488,7 +1491,7 @@ function ModelBrowserContent({
|
||||
return scrolling === "independent" ? (
|
||||
<IndependentProviderList>{allProvidersContent}</IndependentProviderList>
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
<SheetScrollView
|
||||
style={styles.virtualizedModelList}
|
||||
contentContainerStyle={[
|
||||
styles.virtualizedModelListContent,
|
||||
@@ -1500,7 +1503,7 @@ function ModelBrowserContent({
|
||||
testID="compact-provider-list"
|
||||
>
|
||||
{allProvidersContent}
|
||||
</BottomSheetScrollView>
|
||||
</SheetScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import React, { createContext, useContext, type ReactNode } from "react";
|
||||
|
||||
const BottomSheetScopeContext = createContext(false);
|
||||
|
||||
export function BottomSheetTextInputScope({ children }: { children: ReactNode }) {
|
||||
export function BottomSheetScope({ children }: { children: ReactNode }) {
|
||||
return <BottomSheetScopeContext.Provider value>{children}</BottomSheetScopeContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
});
|
||||
|
||||
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
|
||||
import { useIsInsideBottomSheet } from "@/components/ui/text-input/bottom-sheet-scope";
|
||||
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
|
||||
|
||||
/** Renders whatever the portal is currently holding, from its own place in the tree. */
|
||||
function PortalHostProbe() {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
} from "./visibility-tracker";
|
||||
import { BottomSheetTextInputScope } from "@/components/ui/text-input/bottom-sheet-scope";
|
||||
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
|
||||
|
||||
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||
|
||||
@@ -65,9 +65,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
enableDismissOnClose
|
||||
stackBehavior={presentation}
|
||||
>
|
||||
<BottomSheetTextInputScope>
|
||||
{contextBridge ? contextBridge(children) : children}
|
||||
</BottomSheetTextInputScope>
|
||||
<BottomSheetScope>{contextBridge ? contextBridge(children) : children}</BottomSheetScope>
|
||||
</GorhomBottomSheetModal>
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { forwardRef, type Ref } from "react";
|
||||
import {
|
||||
ScrollView as NativeScrollView,
|
||||
FlatList as NativeFlatList,
|
||||
type ScrollViewProps,
|
||||
type FlatListProps,
|
||||
} from "react-native";
|
||||
import { BottomSheetScrollView, BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
ScrollView as GestureScrollView,
|
||||
FlatList as GestureFlatList,
|
||||
type NativeViewGestureHandlerProps,
|
||||
} from "react-native-gesture-handler";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
|
||||
|
||||
// Both libraries forward to the RN FlatList instance. Their published ref types
|
||||
// disagree about getScrollResponder (Gorhom) and include the component function
|
||||
// in the instance intersection (RNGH); keep that type discrepancy at this boundary.
|
||||
const SheetFlatList = BottomSheetFlatList as unknown as typeof NativeFlatList;
|
||||
const HorizontalFlatList = GestureFlatList as unknown as <Item>(
|
||||
props: FlatListProps<Item> & NativeViewGestureHandlerProps & { ref?: Ref<NativeFlatList<Item>> },
|
||||
) => React.ReactElement;
|
||||
|
||||
/** The presentation owns gesture integration; callers keep the React Native API. */
|
||||
export const ScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
|
||||
function ScrollView(props, ref) {
|
||||
const inSheet = useIsInsideBottomSheet();
|
||||
if (!inSheet) return <NativeScrollView {...props} ref={ref} />;
|
||||
if (props.horizontal) {
|
||||
return isNative ? (
|
||||
<GestureScrollView {...props} ref={ref} disallowInterruption />
|
||||
) : (
|
||||
<NativeScrollView {...props} ref={ref} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BottomSheetScrollView {...props} ref={ref}>
|
||||
{props.children}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function SheetAwareFlatList<Item>(props: FlatListProps<Item>, ref: Ref<NativeFlatList<Item>>) {
|
||||
const inSheet = useIsInsideBottomSheet();
|
||||
if (!inSheet) return <NativeFlatList {...props} ref={ref} />;
|
||||
if (props.horizontal) {
|
||||
return isNative ? (
|
||||
<HorizontalFlatList {...props} ref={ref} disallowInterruption />
|
||||
) : (
|
||||
<NativeFlatList {...props} ref={ref} />
|
||||
);
|
||||
}
|
||||
return <SheetFlatList {...props} ref={ref} />;
|
||||
}
|
||||
|
||||
// forwardRef erases the item generic. All three implementations expose the RN list methods.
|
||||
export const FlatList = forwardRef(SheetAwareFlatList) as <Item>(
|
||||
props: FlatListProps<Item> & { ref?: Ref<NativeFlatList<Item>> },
|
||||
) => React.ReactElement;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Web dialogs use ordinary scrolling at every breakpoint. Keep native gesture
|
||||
// dependencies out of this module's import graph.
|
||||
export { ScrollView, FlatList } from "react-native";
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
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";
|
||||
@@ -87,7 +81,7 @@ export function ScrollableCodeSurface({
|
||||
[maxHeight],
|
||||
);
|
||||
const outerScrollStyle = useMemo(
|
||||
() => [maxHeightStyle, scrollStyle],
|
||||
() => [styles.scroll, maxHeightStyle, scrollStyle],
|
||||
[maxHeightStyle, scrollStyle],
|
||||
);
|
||||
const contentStyle = useMemo(
|
||||
@@ -108,24 +102,21 @@ export function ScrollableCodeSurface({
|
||||
<SurfaceCard
|
||||
tone={tone}
|
||||
bordered={bordered}
|
||||
style={style}
|
||||
style={[styles.scroll, style]}
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
codeSurface
|
||||
>
|
||||
<ScrollView
|
||||
style={outerScrollStyle}
|
||||
contentContainerStyle={contentStyle}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
{horizontal ? (
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
{codeContent}
|
||||
</ScrollView>
|
||||
) : (
|
||||
codeContent
|
||||
)}
|
||||
<ScrollView style={outerScrollStyle} nestedScrollEnabled showsVerticalScrollIndicator>
|
||||
<View style={contentStyle}>
|
||||
{horizontal ? (
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
{codeContent}
|
||||
</ScrollView>
|
||||
) : (
|
||||
codeContent
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SurfaceCard>
|
||||
);
|
||||
@@ -171,6 +162,10 @@ function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp<ViewStyle> {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
scroll: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
container: {
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forwardRef, useCallback } from "react";
|
||||
import { TextInput as NativeTextInput, type TextInputProps } from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsInsideBottomSheet } from "../bottom-sheet-scope";
|
||||
|
||||
/** Registers focus with the sheet so the keyboard can move the active input into view. */
|
||||
export const SheetAwareTextInput = forwardRef<NativeTextInput, TextInputProps>(
|
||||
function SheetAwareTextInput(props, ref) {
|
||||
const inSheet = useIsInsideBottomSheet();
|
||||
const setRef = useCallback(
|
||||
(input: NativeTextInput | null | undefined) => {
|
||||
if (typeof ref === "function") ref(input ?? null);
|
||||
else if (ref) ref.current = input ?? null;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
return inSheet && isNative ? (
|
||||
<BottomSheetTextInput {...props} ref={setRef} />
|
||||
) : (
|
||||
<NativeTextInput {...props} ref={setRef} />
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { act, createRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BottomSheetTextInputScope } from "./bottom-sheet-scope";
|
||||
import { BottomSheetScope } from "@/components/ui/bottom-sheet-scope";
|
||||
import { EditingTextInput } from "./text-input.native";
|
||||
import type { EditingTextInputHandle } from "./types";
|
||||
|
||||
@@ -62,9 +62,9 @@ describe("EditingTextInputNative", () => {
|
||||
root?.render(
|
||||
<>
|
||||
<EditingTextInput testID="outside" />
|
||||
<BottomSheetTextInputScope>
|
||||
<BottomSheetScope>
|
||||
<EditingTextInput testID="inside" />
|
||||
</BottomSheetTextInputScope>
|
||||
</BottomSheetScope>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import PasteInput, {
|
||||
type PastedFile,
|
||||
type PasteTextInputInstance,
|
||||
} from "@mattermost/react-native-paste-input";
|
||||
import { useIsInsideBottomSheet } from "./bottom-sheet-scope";
|
||||
import { useIsInsideBottomSheet } from "@/components/ui/bottom-sheet-scope";
|
||||
import type { EditingTextInputHandle, EditingTextInputProps } from "./types";
|
||||
|
||||
type NativeInput = (TextInput | PasteTextInputInstance) & {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function copyText(text: string): Promise<void> {
|
||||
const { setStringAsync } = require("expo-clipboard") as typeof import("expo-clipboard");
|
||||
if (!(await setStringAsync(text))) {
|
||||
throw new Error("Clipboard copy was denied or is unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Expo's web fallback reports success even when document.execCommand returns
|
||||
// false. Use the browser promise so the SDK can promise success or rejection.
|
||||
export async function copyText(text: string): Promise<void> {
|
||||
if (!navigator.clipboard) throw new Error("Clipboard copy is unavailable in this browser");
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
@@ -30,7 +30,12 @@ function useModalContext(): ModalContextValue {
|
||||
return context;
|
||||
}
|
||||
|
||||
function ModalContent({ children }: ModalContentProps) {
|
||||
function ModalContent({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
scrollable = true,
|
||||
}: ModalContentProps) {
|
||||
const modal = useModalContext();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useAppToast();
|
||||
@@ -52,6 +57,10 @@ function ModalContent({ children }: ModalContentProps) {
|
||||
onClose={modal.dismiss}
|
||||
header={modal.header}
|
||||
contextBridge={contextBridge}
|
||||
bodyStyle={style}
|
||||
contentStyle={contentContainerStyle}
|
||||
scrollable={scrollable}
|
||||
desktopHeight={scrollable ? undefined : "85%"}
|
||||
>
|
||||
{children}
|
||||
</AdaptiveModalSheet>
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Icon } from "../icons";
|
||||
import { Modal } from "./modal";
|
||||
import { ScrollView, FlatList } from "./scroll-view";
|
||||
import { TextInput } from "./text-input";
|
||||
import { copyText } from "./clipboard";
|
||||
import { useToast } from "./toast";
|
||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
|
||||
export const pluginReactNativeRuntime = { Icon, Modal, useRevealedText, useToast };
|
||||
export const pluginReactNativeRuntime = {
|
||||
Icon,
|
||||
Modal,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
TextInput,
|
||||
copyText,
|
||||
useRevealedText,
|
||||
useToast,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { forwardRef } from "react";
|
||||
import type {
|
||||
ScrollView as NativeScrollView,
|
||||
ScrollViewProps,
|
||||
FlatListProps,
|
||||
FlatList as NativeFlatList,
|
||||
} from "react-native";
|
||||
import type * as Shared from "@/components/ui/scroll-view";
|
||||
|
||||
// Plugin evaluation also runs before the native UI is ready. Load presentation
|
||||
// dependencies at render time, as Modal does, rather than evaluating them on import.
|
||||
export const ScrollView = forwardRef<NativeScrollView, ScrollViewProps>(
|
||||
function ScrollView(props, ref) {
|
||||
const { ScrollView: HostScrollView } =
|
||||
require("../../components/ui/scroll-view") as typeof Shared;
|
||||
return <HostScrollView {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
|
||||
export const FlatList = forwardRef<NativeFlatList<unknown>, FlatListProps<unknown>>(
|
||||
function FlatList(props, ref) {
|
||||
const { FlatList: HostFlatList } = require("../../components/ui/scroll-view") as typeof Shared;
|
||||
return <HostFlatList {...props} ref={ref} />;
|
||||
},
|
||||
) as typeof Shared.FlatList;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { forwardRef, type ElementRef } from "react";
|
||||
import type { TextInputProps } from "react-native";
|
||||
|
||||
import type { SheetAwareTextInput } from "@/components/ui/text-input/sheet-aware";
|
||||
|
||||
export const TextInput = forwardRef<ElementRef<typeof SheetAwareTextInput>, TextInputProps>(
|
||||
function TextInput(props, ref) {
|
||||
const { SheetAwareTextInput } =
|
||||
require("../../components/ui/text-input/sheet-aware") as typeof import("../../components/ui/text-input/sheet-aware");
|
||||
return <SheetAwareTextInput {...props} ref={ref} />;
|
||||
},
|
||||
);
|
||||
@@ -65,6 +65,7 @@
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.6",
|
||||
"zod": "^4.4.3"
|
||||
@@ -73,6 +74,12 @@
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/protocol": "0.7.2",
|
||||
"react": "19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import type { ComponentType, FunctionComponent, ReactNode } from "react";
|
||||
import type {
|
||||
ComponentType,
|
||||
FunctionComponent,
|
||||
ReactNode,
|
||||
ForwardRefExoticComponent,
|
||||
RefAttributes,
|
||||
ReactElement,
|
||||
Ref,
|
||||
} from "react";
|
||||
import type {
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
ScrollView as NativeScrollView,
|
||||
ScrollViewProps,
|
||||
FlatList as NativeFlatList,
|
||||
FlatListProps,
|
||||
TextInput as NativeTextInput,
|
||||
TextInputProps,
|
||||
} from "react-native";
|
||||
import type { PluginIconProps } from "./contracts.js";
|
||||
|
||||
export interface ModalProps {
|
||||
@@ -11,6 +29,12 @@ export interface ModalProps {
|
||||
|
||||
export interface ModalContentProps {
|
||||
children: ReactNode;
|
||||
/** Paint the full body below the header. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
/** Default true. Set false for a bounded body with your own ScrollView or FlatList. */
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
export interface ModalComponent extends FunctionComponent<ModalProps> {
|
||||
@@ -35,3 +59,18 @@ export declare function useToast(): ToastApi;
|
||||
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
|
||||
|
||||
export type { PluginIconProps } from "./contracts.js";
|
||||
|
||||
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
|
||||
export declare const ScrollView: ForwardRefExoticComponent<
|
||||
ScrollViewProps & RefAttributes<NativeScrollView>
|
||||
>;
|
||||
export declare function FlatList<Item>(
|
||||
props: FlatListProps<Item> & { ref?: Ref<NativeFlatList<Item>> },
|
||||
): ReactElement;
|
||||
/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */
|
||||
export declare function copyText(text: string): Promise<void>;
|
||||
|
||||
/** Native input focus integrated with modal keyboard positioning. */
|
||||
export declare const TextInput: ForwardRefExoticComponent<
|
||||
TextInputProps & RefAttributes<NativeTextInput>
|
||||
>;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Modal UI example
|
||||
|
||||
Install this directory as a local plugin, then open **Modal examples** from the sidebar or a workspace tab.
|
||||
|
||||
The examples demonstrate default and custom padding, a full-width body, author-owned ScrollView and
|
||||
FlatList scrolling, horizontal tabs, and clipboard actions with a keyboard-aware input. See the
|
||||
[host UI reference](../../public-docs/plugins/v0.8/reference.md#host-ui) for the API contract.
|
||||
|
||||
The browser regression installs this exact example in an isolated daemon:
|
||||
|
||||
```sh
|
||||
cd packages/app
|
||||
npx playwright test e2e/browser/plugin-modal-body.spec.ts --project=browser --workers=1
|
||||
```
|
||||
|
||||
On Android, open each example and swipe through its content. In FlatList, use **Jump to last row**
|
||||
before expanding the sheet; row 100 should be visible. Drag the handle to expand the sheet. In Form,
|
||||
press **Copy text**, long-press the input, and choose **Paste**. The input should contain
|
||||
“Copied from Paseo”. With the system keyboard enabled, focusing the input should keep it visible.
|
||||
|
||||
These captures show Android copy/paste and custom padding on browser and wide native layouts.
|
||||
Android API 35 and Chromium were exercised; iOS and Electron were not tested.
|
||||
|
||||
| Android copy/paste | Browser custom padding | Wide Android custom padding |
|
||||
| ---------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
|  |  |  |
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { PluginSurfaceProps } from "@getpaseo/plugin";
|
||||
import {
|
||||
Modal,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
TextInput,
|
||||
copyText,
|
||||
useToast,
|
||||
} from "@getpaseo/plugin/react-native";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
StyleSheet,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
type TextStyle,
|
||||
type FlatList as NativeFlatList,
|
||||
} from "react-native";
|
||||
|
||||
const modes = [
|
||||
"Form",
|
||||
"Custom inset",
|
||||
"Full bleed",
|
||||
"ScrollView",
|
||||
"FlatList",
|
||||
"Horizontal",
|
||||
] as const;
|
||||
type Mode = (typeof modes)[number];
|
||||
const rows = Array.from({ length: 100 }, (_, index) => index + 1);
|
||||
const tabs = ["Overview", "Configuration", "Activity", "Permissions", "System logs"];
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, padding: 16, gap: 12 },
|
||||
button: { padding: 12, borderWidth: 1, borderRadius: 6 },
|
||||
fill: { flex: 1, minHeight: 0 },
|
||||
customInset: { padding: 8, paddingBottom: 40, gap: 16 },
|
||||
fullBleed: { padding: 0, gap: 0 },
|
||||
row: { height: 56, padding: 16, borderBottomWidth: 1 },
|
||||
form: { gap: 16 },
|
||||
input: { borderWidth: 1, padding: 12, borderRadius: 6 },
|
||||
tabs: { flexGrow: 0, flexShrink: 0 },
|
||||
tab: { padding: 18, minWidth: 160 },
|
||||
});
|
||||
|
||||
export function ModalExamples({ theme }: Pick<PluginSurfaceProps, "theme">) {
|
||||
const [mode, setMode] = useState<Mode | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [selected, setSelected] = useState("");
|
||||
const list = useRef<NativeFlatList<number>>(null);
|
||||
const toast = useToast();
|
||||
const color = useMemo(() => ({ color: theme.colors.foreground }), [theme]);
|
||||
const body = useMemo(() => ({ backgroundColor: theme.colors.surface2 }), [theme]);
|
||||
const button = useMemo(() => [styles.button, { borderColor: theme.colors.border }], [theme]);
|
||||
const rowStyle = useMemo(() => [styles.row, { borderColor: theme.colors.border }], [theme]);
|
||||
const inputStyle = useMemo(
|
||||
() => [styles.input, color, { borderColor: theme.colors.border }],
|
||||
[color, theme],
|
||||
);
|
||||
|
||||
const copy = useCallback(async () => {
|
||||
try {
|
||||
await copyText("Copied from Paseo");
|
||||
toast.show("Text copied", { variant: "success" });
|
||||
} catch {
|
||||
toast.error("Could not copy text. Select the text and use Copy.");
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const row = useCallback(
|
||||
(item: number) => {
|
||||
return (
|
||||
<ChoiceButton
|
||||
key={item}
|
||||
value={`Row ${item}`}
|
||||
label={`Row ${item}`}
|
||||
style={rowStyle}
|
||||
textStyle={color}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[rowStyle, color],
|
||||
);
|
||||
const renderRow = useCallback(({ item }: { item: number }) => row(item), [row]);
|
||||
const close = useCallback((open: boolean) => {
|
||||
if (!open) setMode(null);
|
||||
}, []);
|
||||
const jumpToEnd = useCallback(() => list.current?.scrollToEnd({ animated: false }), []);
|
||||
const screen = useMemo(() => [styles.screen, body], [body]);
|
||||
|
||||
let contentStyle: StyleProp<ViewStyle> = styles.fullBleed;
|
||||
if (mode === "Form") contentStyle = undefined;
|
||||
if (mode === "Custom inset") contentStyle = styles.customInset;
|
||||
|
||||
const form = (
|
||||
<View style={styles.form} testID="modal-example-form">
|
||||
<Text selectable style={color}>
|
||||
Paseo clipboard example
|
||||
</Text>
|
||||
<Pressable accessibilityRole="button" style={button} onPress={copy}>
|
||||
<Text style={color}>Copy text</Text>
|
||||
</Pressable>
|
||||
<TextInput
|
||||
accessibilityLabel="Paste here"
|
||||
placeholder="Paste here"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
/>
|
||||
<Text style={color}>Input: {input || "empty"}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={screen}>
|
||||
<Text style={color}>Modal body examples</Text>
|
||||
{modes.map((example) => (
|
||||
<ChoiceButton<Mode>
|
||||
key={example}
|
||||
value={example}
|
||||
label={`Open ${example}`}
|
||||
style={button}
|
||||
textStyle={color}
|
||||
onSelect={setMode}
|
||||
/>
|
||||
))}
|
||||
<Text style={color}>Selected: {selected || "none"}</Text>
|
||||
<Modal title={`Modal example: ${mode ?? "Form"}`} open={mode !== null} onOpenChange={close}>
|
||||
<Modal.Content
|
||||
style={body}
|
||||
contentContainerStyle={contentStyle}
|
||||
scrollable={mode !== "ScrollView" && mode !== "FlatList"}
|
||||
>
|
||||
{mode === "Form" || mode === "Custom inset" ? form : null}
|
||||
{mode === "Full bleed" ? rows.map(row) : null}
|
||||
{mode === "ScrollView" ? (
|
||||
<ScrollView style={styles.fill} keyboardShouldPersistTaps="handled">
|
||||
{rows.map(row)}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{mode === "FlatList" ? (
|
||||
<>
|
||||
<Pressable accessibilityRole="button" style={button} onPress={jumpToEnd}>
|
||||
<Text style={color}>Jump to last row</Text>
|
||||
</Pressable>
|
||||
<FlatList
|
||||
ref={list}
|
||||
style={styles.fill}
|
||||
data={rows}
|
||||
keyExtractor={String}
|
||||
renderItem={renderRow}
|
||||
getItemLayout={getRowLayout}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{mode === "Horizontal" ? (
|
||||
<>
|
||||
<ScrollView horizontal style={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<ChoiceButton
|
||||
key={tab}
|
||||
value={tab}
|
||||
label={tab}
|
||||
style={styles.tab}
|
||||
textStyle={color}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
{form}
|
||||
{rows.slice(0, 20).map(row)}
|
||||
</>
|
||||
) : null}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function getRowLayout(_: unknown, index: number) {
|
||||
return { index, length: 56, offset: index * 56 };
|
||||
}
|
||||
|
||||
function ChoiceButton<Value extends string>({
|
||||
value,
|
||||
label,
|
||||
style,
|
||||
textStyle,
|
||||
onSelect,
|
||||
}: {
|
||||
value: Value;
|
||||
label: string;
|
||||
style: StyleProp<ViewStyle>;
|
||||
textStyle: StyleProp<TextStyle>;
|
||||
onSelect(value: Value): void;
|
||||
}) {
|
||||
const select = useCallback(() => onSelect(value), [onSelect, value]);
|
||||
return (
|
||||
<Pressable accessibilityRole="button" style={style} onPress={select}>
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
||||
import { ModalExamples } from "./client/examples";
|
||||
|
||||
export default function contribute(plugin: PluginClientContext) {
|
||||
plugin.addSurface("main", ModalExamples);
|
||||
plugin.addSidebarItem({
|
||||
id: "main",
|
||||
title: "Modal examples",
|
||||
icon: "PanelsTopLeft",
|
||||
surface: "main",
|
||||
});
|
||||
plugin.addWorkspacePanel({
|
||||
id: "examples",
|
||||
title: "Modal examples",
|
||||
icon: "PanelsTopLeft",
|
||||
context: "workspace",
|
||||
locations: ["workspace", "explorer"],
|
||||
Component: ModalExamples,
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id": "modal-ui-example"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -320,9 +320,23 @@ the `open` state.
|
||||
|
||||
`Modal.Content` owns the body below the host-rendered header:
|
||||
|
||||
| Prop | Type | Required | Behavior |
|
||||
| ---------- | ----------- | -------- | --------------------------------------------- |
|
||||
| `children` | `ReactNode` | Yes | Renders the plugin's React Native UI content. |
|
||||
| Prop | Type | Default | Behavior |
|
||||
| ----------------------- | ---------------------- | ------------------ | --------------------------------------------------------------------------------- |
|
||||
| `children` | `ReactNode` | Required | Body content below the header. |
|
||||
| `style` | `StyleProp<ViewStyle>` | — | Styles the full body viewport, including empty space. Use for backgrounds. |
|
||||
| `contentContainerStyle` | `StyleProp<ViewStyle>` | Padding 24, gap 16 | Overrides the content layout. Set `padding: 0, gap: 0` for edge-to-edge rows. |
|
||||
| `scrollable` | `boolean` | `true` | The host scrolls the body. Set `false` for a bounded body with your own scroller. |
|
||||
|
||||
A plain `Modal.Content` is already padded and scrollable. Avoid adding a second padded wrapper
|
||||
unless you want another inset. Body styles leave the host header, drag handle, and dismissal controls
|
||||
intact. The host reserves bottom safe-area space on compact native layouts; setting content padding
|
||||
to zero removes the decorative inset, not that space. Keyboard clearance is handled separately.
|
||||
|
||||
With `scrollable={false}`, the body fills the available sheet height, and the centered dialog uses
|
||||
85% of the available height. Use `flex: 1, minHeight: 0` on your list. In this mode the body scrolls
|
||||
at every sheet height; drag the handle to resize or dismiss the sheet. Default scrolling dialogs stay
|
||||
content-sized on wide layouts. Presentation follows window size, including narrow desktop windows
|
||||
and wide tablets.
|
||||
|
||||
The close button, backdrop, platform back action, web Escape key, and compact sheet gesture dismiss
|
||||
the modal. Dismissal calls `onOpenChange(false)`; the plugin must update `open` to close it.
|
||||
@@ -330,6 +344,72 @@ the modal. Dismissal calls `onOpenChange(false)`; the plugin must update `open`
|
||||
Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorkspace`, and
|
||||
`useAgent` work inside them.
|
||||
|
||||
### Scrolling
|
||||
|
||||
Import `ScrollView` and `FlatList` from `@getpaseo/plugin/react-native` when content can appear in a
|
||||
Paseo modal. They accept React Native props and refs and integrate with the sheet's gestures. Outside
|
||||
a sheet they use ordinary React Native scrolling. Do not import bottom-sheet libraries directly.
|
||||
|
||||
Use one vertical scroll owner: either the default modal body, or your own list with
|
||||
`scrollable={false}`. A fixed-height vertical list nested inside the default scrolling body can compete
|
||||
with the sheet for gestures on Android. Horizontal scrolling can coexist with the host's vertical body.
|
||||
|
||||
```tsx
|
||||
import { FlatList, Modal } from "@getpaseo/plugin/react-native";
|
||||
import { Text } from "react-native";
|
||||
|
||||
// Inside your controlled Modal:
|
||||
<Modal.Content
|
||||
scrollable={false}
|
||||
style={{ backgroundColor: theme.colors.surface1 }}
|
||||
contentContainerStyle={{ padding: 0, gap: 0 }}
|
||||
>
|
||||
<FlatList
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
data={items}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item }) => (
|
||||
<Text style={{ padding: 16, color: theme.colors.foreground }}>{item.title}</Text>
|
||||
)}
|
||||
/>
|
||||
</Modal.Content>;
|
||||
```
|
||||
|
||||
For horizontal tabs, place `<ScrollView horizontal style={{ flexGrow: 0 }}>…</ScrollView>` inside the
|
||||
default `Modal.Content`. Keep the content's vertical scrolling on the host.
|
||||
|
||||
### Copy and paste
|
||||
|
||||
`copyText(text): Promise<void>` writes to the clipboard on the device running the app. Call it from a
|
||||
user action and await it before reporting success. It rejects if the platform denies copying or the
|
||||
clipboard is unavailable; browser permissions and secure-context requirements still apply.
|
||||
|
||||
```tsx
|
||||
import { copyText, useToast } from "@getpaseo/plugin/react-native";
|
||||
|
||||
// Inside your component:
|
||||
const toast = useToast();
|
||||
async function copyResult() {
|
||||
try {
|
||||
await copyText(result);
|
||||
toast.show("Copied", { variant: "success" });
|
||||
} catch {
|
||||
toast.error("Could not copy. Select the text and use Copy.");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Programmatic copying and native text selection are separate interactions. Use `<Text selectable>`
|
||||
for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/react-native` for modal forms. It accepts React Native
|
||||
input props and refs, supports OS Paste, and registers focus with the native sheet so the keyboard
|
||||
can raise the form. Outside a sheet it uses the ordinary input. A plain React Native input supports
|
||||
Paste too, but does not register focus with the sheet; the keyboard can cover it. No clipboard read
|
||||
API is needed for OS Paste. Avoid DOM clipboard code in native plugins and the deprecated
|
||||
`Clipboard` export from `react-native`.
|
||||
|
||||
The runnable [modal UI example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/modal-ui)
|
||||
contains a padded form, full-width rows, a virtualized list, horizontal tabs, and a copy/paste input.
|
||||
|
||||
### Toasts
|
||||
|
||||
`useToast()` returns two methods:
|
||||
|
||||
Reference in New Issue
Block a user