diff --git a/docs/plugins.md b/docs/plugins.md index 1a101e5c5c..905f482bc7 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 `` and body UI in -``. +``. 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`. diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index 3324b8a3a6..a713307bd5 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE= +sha256-Vw6bgh+gPdVxySdbawKawr9qZT8QEEo2+okdpuKceMA= diff --git a/package-lock.json b/package-lock.json index 1e60b61b11..37131854b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/packages/app/e2e/browser/plugin-modal-body.spec.ts b/packages/app/e2e/browser/plugin-modal-body.spec.ts new file mode 100644 index 0000000000..591a2472e8 --- /dev/null +++ b/packages/app/e2e/browser/plugin-modal-body.spec.ts @@ -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)); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts index 308193d9d8..fbd37c15e7 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts @@ -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({}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.ts b/packages/app/src/components/adaptive-modal-sheet-layout.ts index 123c0d337a..91fc62274e 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.ts @@ -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 }; } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 8cdab525a4..99c3b5117f 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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; /** Layout intent for the sheet body, composed over the sheet's own content inset. */ contentStyle?: StyleProp; /** 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 ? ( + + {footer} + + ) : 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 = ( <> - {scrollable ? ( - - {children} - - ) : ( - {children} - )} - {footer ? {footer} : null} + + {scrollable ? ( + + + {children} + + + ) : ( + + + {children} + + + )} + + {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 = ( - {scrollable ? ( - + + {scrollable ? ( - {children} + {children} - - ) : ( - {children} - )} - {footer ? {footer} : null} + ) : ( + {children} + )} + + {footerView} ); diff --git a/packages/app/src/components/model-browser.tsx b/packages/app/src/components/model-browser.tsx index ea2eeaec69..f724619c1f 100644 --- a/packages/app/src/components/model-browser.tsx +++ b/packages/app/src/components/model-browser.tsx @@ -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 ( - {allProvidersContent} ) : ( - {allProvidersContent} - + ); } diff --git a/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx b/packages/app/src/components/ui/bottom-sheet-scope.tsx similarity index 79% rename from packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx rename to packages/app/src/components/ui/bottom-sheet-scope.tsx index 1d25bf7598..cbdaedac5f 100644 --- a/packages/app/src/components/ui/text-input/bottom-sheet-scope.tsx +++ b/packages/app/src/components/ui/bottom-sheet-scope.tsx @@ -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 {children}; } diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index 7567601eca..cf820ac86c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -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() { diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 1f70a6babe..18ecff9127 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -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; @@ -65,9 +65,7 @@ export const IsolatedBottomSheetModal = forwardRef< enableDismissOnClose stackBehavior={presentation} > - - {contextBridge ? contextBridge(children) : children} - + {contextBridge ? contextBridge(children) : children} ); diff --git a/packages/app/src/components/ui/scroll-view.tsx b/packages/app/src/components/ui/scroll-view.tsx new file mode 100644 index 0000000000..d6abed39d9 --- /dev/null +++ b/packages/app/src/components/ui/scroll-view.tsx @@ -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 ( + props: FlatListProps & NativeViewGestureHandlerProps & { ref?: Ref> }, +) => React.ReactElement; + +/** The presentation owns gesture integration; callers keep the React Native API. */ +export const ScrollView = forwardRef( + function ScrollView(props, ref) { + const inSheet = useIsInsideBottomSheet(); + if (!inSheet) return ; + if (props.horizontal) { + return isNative ? ( + + ) : ( + + ); + } + return ( + + {props.children} + + ); + }, +); + +function SheetAwareFlatList(props: FlatListProps, ref: Ref>) { + const inSheet = useIsInsideBottomSheet(); + if (!inSheet) return ; + if (props.horizontal) { + return isNative ? ( + + ) : ( + + ); + } + return ; +} + +// forwardRef erases the item generic. All three implementations expose the RN list methods. +export const FlatList = forwardRef(SheetAwareFlatList) as ( + props: FlatListProps & { ref?: Ref> }, +) => React.ReactElement; diff --git a/packages/app/src/components/ui/scroll-view.web.tsx b/packages/app/src/components/ui/scroll-view.web.tsx new file mode 100644 index 0000000000..7372623b5c --- /dev/null +++ b/packages/app/src/components/ui/scroll-view.web.tsx @@ -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"; diff --git a/packages/app/src/components/ui/scrollable-code-surface.tsx b/packages/app/src/components/ui/scrollable-code-surface.tsx index e55093b9d8..3b99f8bcaf 100644 --- a/packages/app/src/components/ui/scrollable-code-surface.tsx +++ b/packages/app/src/components/ui/scrollable-code-surface.tsx @@ -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({ - - {horizontal ? ( - - {codeContent} - - ) : ( - codeContent - )} + + + {horizontal ? ( + + {codeContent} + + ) : ( + codeContent + )} + ); @@ -171,6 +162,10 @@ function getSurfaceStyle(tone: CodeSurfaceTone): StyleProp { } const styles = StyleSheet.create((theme) => ({ + scroll: { + flexShrink: 1, + minHeight: 0, + }, container: { overflow: "hidden", }, diff --git a/packages/app/src/components/ui/text-input/sheet-aware.tsx b/packages/app/src/components/ui/text-input/sheet-aware.tsx new file mode 100644 index 0000000000..33539b6cbb --- /dev/null +++ b/packages/app/src/components/ui/text-input/sheet-aware.tsx @@ -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( + 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 ? ( + + ) : ( + + ); + }, +); diff --git a/packages/app/src/components/ui/text-input/text-input.native.test.tsx b/packages/app/src/components/ui/text-input/text-input.native.test.tsx index a04bd89d50..2b04a84c63 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.test.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.test.tsx @@ -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( <> - + - + , ); }); diff --git a/packages/app/src/components/ui/text-input/text-input.native.tsx b/packages/app/src/components/ui/text-input/text-input.native.tsx index c505a9b917..074367a1c9 100644 --- a/packages/app/src/components/ui/text-input/text-input.native.tsx +++ b/packages/app/src/components/ui/text-input/text-input.native.tsx @@ -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) & { diff --git a/packages/app/src/plugins/react-native/clipboard.ts b/packages/app/src/plugins/react-native/clipboard.ts new file mode 100644 index 0000000000..4357a0baa1 --- /dev/null +++ b/packages/app/src/plugins/react-native/clipboard.ts @@ -0,0 +1,6 @@ +export async function copyText(text: string): Promise { + const { setStringAsync } = require("expo-clipboard") as typeof import("expo-clipboard"); + if (!(await setStringAsync(text))) { + throw new Error("Clipboard copy was denied or is unavailable"); + } +} diff --git a/packages/app/src/plugins/react-native/clipboard.web.ts b/packages/app/src/plugins/react-native/clipboard.web.ts new file mode 100644 index 0000000000..7207c2c689 --- /dev/null +++ b/packages/app/src/plugins/react-native/clipboard.web.ts @@ -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 { + if (!navigator.clipboard) throw new Error("Clipboard copy is unavailable in this browser"); + await navigator.clipboard.writeText(text); +} diff --git a/packages/app/src/plugins/react-native/modal.tsx b/packages/app/src/plugins/react-native/modal.tsx index 38728b56b6..2785531d55 100644 --- a/packages/app/src/plugins/react-native/modal.tsx +++ b/packages/app/src/plugins/react-native/modal.tsx @@ -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} diff --git a/packages/app/src/plugins/react-native/runtime.ts b/packages/app/src/plugins/react-native/runtime.ts index 4ac8ef3349..bdf39910a4 100644 --- a/packages/app/src/plugins/react-native/runtime.ts +++ b/packages/app/src/plugins/react-native/runtime.ts @@ -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, +}; diff --git a/packages/app/src/plugins/react-native/scroll-view.tsx b/packages/app/src/plugins/react-native/scroll-view.tsx new file mode 100644 index 0000000000..40dcc2f192 --- /dev/null +++ b/packages/app/src/plugins/react-native/scroll-view.tsx @@ -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( + function ScrollView(props, ref) { + const { ScrollView: HostScrollView } = + require("../../components/ui/scroll-view") as typeof Shared; + return ; + }, +); + +export const FlatList = forwardRef, FlatListProps>( + function FlatList(props, ref) { + const { FlatList: HostFlatList } = require("../../components/ui/scroll-view") as typeof Shared; + return ; + }, +) as typeof Shared.FlatList; diff --git a/packages/app/src/plugins/react-native/text-input.tsx b/packages/app/src/plugins/react-native/text-input.tsx new file mode 100644 index 0000000000..0330e68533 --- /dev/null +++ b/packages/app/src/plugins/react-native/text-input.tsx @@ -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, TextInputProps>( + function TextInput(props, ref) { + const { SheetAwareTextInput } = + require("../../components/ui/text-input/sheet-aware") as typeof import("../../components/ui/text-input/sheet-aware"); + return ; + }, +); diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 710bce8339..34b481ffda 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -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 + } } } diff --git a/packages/plugin/src/react-native.ts b/packages/plugin/src/react-native.ts index d582cb724a..ea196c433f 100644 --- a/packages/plugin/src/react-native.ts +++ b/packages/plugin/src/react-native.ts @@ -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; + /** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */ + contentContainerStyle?: StyleProp; + /** Default true. Set false for a bounded body with your own ScrollView or FlatList. */ + scrollable?: boolean; } export interface ModalComponent extends FunctionComponent { @@ -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 +>; +export declare function FlatList( + props: FlatListProps & { ref?: Ref> }, +): ReactElement; +/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */ +export declare function copyText(text: string): Promise; + +/** Native input focus integrated with modal keyboard positioning. */ +export declare const TextInput: ForwardRefExoticComponent< + TextInputProps & RefAttributes +>; diff --git a/plugin-examples/modal-ui/README.md b/plugin-examples/modal-ui/README.md new file mode 100644 index 0000000000..96cbbd8b78 --- /dev/null +++ b/plugin-examples/modal-ui/README.md @@ -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 | +| ---------------------------------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------- | +| ![Android copy and OS paste](screenshots/android-copy-paste.png) | ![Browser custom padding](screenshots/web-custom-inset.png) | ![Wide Android custom padding](screenshots/android-wide-custom-inset.png) | diff --git a/plugin-examples/modal-ui/client/examples.tsx b/plugin-examples/modal-ui/client/examples.tsx new file mode 100644 index 0000000000..e834983bfc --- /dev/null +++ b/plugin-examples/modal-ui/client/examples.tsx @@ -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) { + const [mode, setMode] = useState(null); + const [input, setInput] = useState(""); + const [selected, setSelected] = useState(""); + const list = useRef>(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 ( + + ); + }, + [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 = styles.fullBleed; + if (mode === "Form") contentStyle = undefined; + if (mode === "Custom inset") contentStyle = styles.customInset; + + const form = ( + + + Paseo clipboard example + + + Copy text + + + Input: {input || "empty"} + + ); + + return ( + + Modal body examples + {modes.map((example) => ( + + key={example} + value={example} + label={`Open ${example}`} + style={button} + textStyle={color} + onSelect={setMode} + /> + ))} + Selected: {selected || "none"} + + + {mode === "Form" || mode === "Custom inset" ? form : null} + {mode === "Full bleed" ? rows.map(row) : null} + {mode === "ScrollView" ? ( + + {rows.map(row)} + + ) : null} + {mode === "FlatList" ? ( + <> + + Jump to last row + + + + ) : null} + {mode === "Horizontal" ? ( + <> + + {tabs.map((tab) => ( + + ))} + + {form} + {rows.slice(0, 20).map(row)} + + ) : null} + + + + ); +} + +function getRowLayout(_: unknown, index: number) { + return { index, length: 56, offset: index * 56 }; +} + +function ChoiceButton({ + value, + label, + style, + textStyle, + onSelect, +}: { + value: Value; + label: string; + style: StyleProp; + textStyle: StyleProp; + onSelect(value: Value): void; +}) { + const select = useCallback(() => onSelect(value), [onSelect, value]); + return ( + + {label} + + ); +} diff --git a/plugin-examples/modal-ui/index.client.tsx b/plugin-examples/modal-ui/index.client.tsx new file mode 100644 index 0000000000..15bd5ae2c8 --- /dev/null +++ b/plugin-examples/modal-ui/index.client.tsx @@ -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 () => {}; +} diff --git a/plugin-examples/modal-ui/paseo-plugin.json b/plugin-examples/modal-ui/paseo-plugin.json new file mode 100644 index 0000000000..b2dc64c17a --- /dev/null +++ b/plugin-examples/modal-ui/paseo-plugin.json @@ -0,0 +1,3 @@ +{ + "id": "modal-ui-example" +} diff --git a/plugin-examples/modal-ui/screenshots/android-copy-paste.png b/plugin-examples/modal-ui/screenshots/android-copy-paste.png new file mode 100644 index 0000000000..ee5da20bf8 Binary files /dev/null and b/plugin-examples/modal-ui/screenshots/android-copy-paste.png differ diff --git a/plugin-examples/modal-ui/screenshots/android-wide-custom-inset.png b/plugin-examples/modal-ui/screenshots/android-wide-custom-inset.png new file mode 100644 index 0000000000..0346fdc9cc Binary files /dev/null and b/plugin-examples/modal-ui/screenshots/android-wide-custom-inset.png differ diff --git a/plugin-examples/modal-ui/screenshots/web-custom-inset.png b/plugin-examples/modal-ui/screenshots/web-custom-inset.png new file mode 100644 index 0000000000..513dc104c9 Binary files /dev/null and b/plugin-examples/modal-ui/screenshots/web-custom-inset.png differ diff --git a/public-docs/plugins/v0.8/reference.md b/public-docs/plugins/v0.8/reference.md index cf3bdf5040..167e574f72 100644 --- a/public-docs/plugins/v0.8/reference.md +++ b/public-docs/plugins/v0.8/reference.md @@ -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` | — | Styles the full body viewport, including empty space. Use for backgrounds. | +| `contentContainerStyle` | `StyleProp` | 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: + + item.id} + renderItem={({ item }) => ( + {item.title} + )} + /> +; +``` + +For horizontal tabs, place `…` inside the +default `Modal.Content`. Keep the content's vertical scrolling on the host. + +### Copy and paste + +`copyText(text): Promise` 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 `` +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: