diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index 2723a9d66..5c01cad70 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -12,6 +12,12 @@ Parent `AGENTS.md` files also apply. ## Keyboard navigation +- Native non-iOS side drawers without snap points use `ui/drawer.tsx`'s live + viewport bottom inset when input repositioning is enabled. Never cache a + keyboard-shrunken drawer height or infer keyboard visibility from focus: + Android-compatible shells can resize the WebView and retain input focus on hide. + Preserve the separate iOS native keyboard offset and bottom-sheet handling. + - Each independently navigable list owns one `FocusScope` and one `useListKeyboardNavigation` call. Rows expose `data-scope-item` plus a stable `data-id`; Up/Down (and J/K) move only in the active scope, while the shell's diff --git a/packages/components/src/ui/drawer.tsx b/packages/components/src/ui/drawer.tsx index fd1461e08..ccdea4a8a 100644 --- a/packages/components/src/ui/drawer.tsx +++ b/packages/components/src/ui/drawer.tsx @@ -2,9 +2,62 @@ import * as React from 'react'; import { Drawer as DrawerPrimitive } from 'vaul'; import { cn } from '@/lib/utils'; +import { isNativeAppShell, isNativeIOSAppShell } from '@/lib/native-platform'; -function Drawer({ ...props }: React.ComponentProps) { - return ; +const DrawerViewportContext = React.createContext(false); + +function Drawer({ repositionInputs, ...props }: React.ComponentProps) { + // Android-compatible shells can resize the layout viewport along with the + // keyboard. Vaul captures that already-shrunk drawer as its initial height + // and can restore it on hide (e.g. HarmonyOS / Zhuoyi). Side drawers instead + // keep their CSS height and track only the currently occluded bottom edge. + const followViewport = + (props.direction === 'right' || props.direction === 'left') && + !props.snapPoints && + repositionInputs !== false && + isNativeAppShell() && + !isNativeIOSAppShell(); + + return ( + + + + ); +} + +function useDrawerViewportBottom() { + const enabled = React.useContext(DrawerViewportContext); + const [bottom, setBottom] = React.useState(0); + + React.useLayoutEffect(() => { + if (!enabled) return undefined; + const viewport = window.visualViewport; + const measure = () => { + // A resized WebView needs no extra inset; an overlay keyboard does. + // Never infer keyboard visibility from focus or resize-event counts: + // Android's Back button can hide the keyboard while retaining focus. + setBottom( + viewport && viewport.scale === 1 + ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) + : 0 + ); + }; + measure(); + window.addEventListener('resize', measure); + viewport?.addEventListener('resize', measure); + viewport?.addEventListener('scroll', measure); + return () => { + window.removeEventListener('resize', measure); + viewport?.removeEventListener('resize', measure); + viewport?.removeEventListener('scroll', measure); + }; + }, [enabled]); + + return enabled ? bottom : undefined; } function DrawerTrigger({ ...props }: React.ComponentProps) { @@ -41,9 +94,11 @@ function DrawerContent({ style, ...props }: React.ComponentProps) { + const viewportBottom = useDrawerViewportBottom(); const mergedStyle = { '--lody-drawer-width': '256px', ...style, + ...(viewportBottom === undefined ? {} : { bottom: viewportBottom }), } as React.CSSProperties; return ( diff --git a/packages/components/tests/drawer-keyboard-viewport.test.tsx b/packages/components/tests/drawer-keyboard-viewport.test.tsx new file mode 100644 index 000000000..c0a87792c --- /dev/null +++ b/packages/components/tests/drawer-keyboard-viewport.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Drawer, DrawerContent, DrawerTitle } from '../src/ui/drawer'; + +const runtime = vi.hoisted(() => ({ native: true, ios: false })); +vi.mock('../src/lib/native-platform', () => ({ + isNativeAppShell: () => runtime.native, + isNativeIOSAppShell: () => runtime.native && runtime.ios, +})); +vi.mock('../src/lib/utils', async () => { + const { clsx } = await import('clsx'); + const { twMerge } = await import('tailwind-merge'); + return { cn: (...inputs: Parameters) => twMerge(clsx(...inputs)) }; +}); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let root: Root; +let container: HTMLDivElement; +let viewport: EventTarget & { height: number; offsetTop: number; scale: number }; + +beforeEach(() => { + vi.useFakeTimers(); + runtime.native = true; + runtime.ios = false; + viewport = Object.assign(new EventTarget(), { height: 800, offsetTop: 0, scale: 1 }); + vi.stubGlobal('visualViewport', viewport); + vi.stubGlobal('innerHeight', 800); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function renderDrawer(repositionInputs = true) { + act(() => { + root.render( + + + Conversation +