Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/components/src/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 57 additions & 2 deletions packages/components/src/ui/drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,62 @@
import { Drawer as DrawerPrimitive } from 'vaul';

import { cn } from '@/lib/utils';
import { isNativeAppShell, isNativeIOSAppShell } from '@/lib/native-platform';

function Drawer({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
const DrawerViewportContext = React.createContext(false);

function Drawer({ repositionInputs, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
// 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 (
<DrawerViewportContext.Provider value={followViewport}>
<DrawerPrimitive.Root
data-slot="drawer"
{...props}
repositionInputs={followViewport ? false : repositionInputs}
/>
</DrawerViewportContext.Provider>
);
}

function useDrawerViewportBottom() {
const enabled = React.useContext(DrawerViewportContext);
const [bottom, setBottom] = React.useState(0);

React.useLayoutEffect(() => {
if (!enabled) return;
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);
};

Check failure on line 57 in packages/components/src/ui/drawer.tsx

View workflow job for this annotation

GitHub Actions / Static checks

typescript-eslint(consistent-return)

Function expected no return value.
}, [enabled]);

return enabled ? bottom : undefined;
}

function DrawerTrigger({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
Expand Down Expand Up @@ -41,9 +94,11 @@
style,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
const viewportBottom = useDrawerViewportBottom();
const mergedStyle = {
'--lody-drawer-width': '256px',
...style,
...(viewportBottom === undefined ? {} : { bottom: viewportBottom }),
} as React.CSSProperties;

return (
Expand Down
139 changes: 139 additions & 0 deletions packages/components/tests/drawer-keyboard-viewport.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof clsx>) => 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(
<Drawer direction="right" open modal={false} repositionInputs={repositionInputs}>
<DrawerContent aria-describedby={undefined}>
<DrawerTitle>Conversation</DrawerTitle>
<textarea aria-label="Message" />
</DrawerContent>
</Drawer>
);
});
const drawer = document.querySelector<HTMLElement>('[data-slot="drawer-content"]')!;
// Model the browser's automatic inset:0 layout as its viewport resizes.
vi.spyOn(drawer, 'getBoundingClientRect').mockImplementation(
() => ({ height: window.innerHeight, top: 0 }) as DOMRect
);
act(() => drawer.querySelector('textarea')!.focus());
return drawer;
}

function resize(layoutHeight: number, visualHeight: number, eventTarget: EventTarget = viewport) {
act(() => {
vi.stubGlobal('innerHeight', layoutHeight);
viewport.height = visualHeight;
eventTarget.dispatchEvent(new Event('resize'));
});
}

describe('native side-drawer keyboard layout', () => {
it('restores CSS sizing after a resizing keyboard hides with the input still focused', () => {
const drawer = renderDrawer();
resize(480, 480);
resize(800, 800);
expect(document.activeElement).toBe(drawer.querySelector('textarea'));
expect(drawer.style.height).toBe('');
expect(drawer.style.bottom).toBe('0px');
});

it('tracks overlay keyboard height through intermediate changes and repeated open/hide cycles', () => {
const drawer = renderDrawer();
for (const height of [480, 400, 460, 800, 500, 800]) {
resize(800, height);
expect(drawer.style.bottom).toBe(`${800 - height}px`);
expect(drawer.style.height).toBe('');
}
});

it('handles window-only resize notifications and switches between resize and overlay modes', () => {
const drawer = renderDrawer();
resize(800, 480);
expect(drawer.style.bottom).toBe('320px');
resize(480, 480, window);
expect(drawer.style.bottom).toBe('0px');
resize(800, 800, window);
expect(drawer.style.height).toBe('');
expect(drawer.style.bottom).toBe('0px');
});

it('measures an already-open keyboard on mount and accounts for viewport panning', () => {
viewport.height = 450;
viewport.offsetTop = 30;
const drawer = renderDrawer();
expect(drawer.style.bottom).toBe('320px');
act(() => {
viewport.offsetTop = 50;
viewport.dispatchEvent(new Event('scroll'));
});
expect(drawer.style.bottom).toBe('300px');
resize(800, 800);
expect(drawer.style.bottom).toBe('0px');
});

it('does not treat pinch zoom as keyboard occlusion', () => {
const drawer = renderDrawer();
viewport.scale = 2;
resize(800, 400);
expect(drawer.style.bottom).toBe('0px');
});

it('keeps CSS sizing when visualViewport is unavailable', () => {
vi.stubGlobal('visualViewport', undefined);
const drawer = renderDrawer();
resize(480, 480, window);
resize(800, 800, window);
expect(drawer.style.height).toBe('');
expect(drawer.style.bottom).toBe('0px');
});

it.each(['web', 'ios', 'disabled'] as const)('does not take over %s positioning', (mode) => {
runtime.native = mode !== 'web';
runtime.ios = mode === 'ios';
const drawer = renderDrawer(mode !== 'disabled');
expect(drawer.style.bottom).toBe('');
});
});
Loading