From 5eede8f030c86a5696e712d597ce9bc14d051b38 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 19:47:25 -0500 Subject: [PATCH 1/2] feat: [drawer] add swipe area, keyboard handling, and indent --- .../content/docs/components/drawer/demo.ts | 54 +++++++++++ .../content/docs/components/drawer/index.mdx | 58 +++++++++++- .../content/docs/components/drawer/props.ts | 53 +++++++++++ .../drawer/__tests__/data-slots.test.tsx | 18 ++++ .../drawer/__tests__/drawer.test.tsx | 92 +++++++++++++++++++ .../components/drawer/drawer-content.tsx | 80 ++++++++-------- .../react/components/drawer/drawer-indent.tsx | 35 +++++++ .../components/drawer/drawer-swipe-area.tsx | 18 ++++ .../react/components/drawer/drawer.module.css | 87 ++++++++++++++++++ packages/react/components/drawer/drawer.tsx | 11 +++ 10 files changed, 466 insertions(+), 40 deletions(-) create mode 100644 packages/react/components/drawer/drawer-indent.tsx create mode 100644 packages/react/components/drawer/drawer-swipe-area.tsx diff --git a/apps/www/src/content/docs/components/drawer/demo.ts b/apps/www/src/content/docs/components/drawer/demo.ts index 360fd17c8..91c24760c 100644 --- a/apps/www/src/content/docs/components/drawer/demo.ts +++ b/apps/www/src/content/docs/components/drawer/demo.ts @@ -120,3 +120,57 @@ function ControlledDrawer() { ); }` }; + +export const swipeAreaDemo = { + type: 'code', + code: ` + + }>Open Drawer + + + + Library + You can also swipe in from the right edge of the screen. + + Content here + + ` +}; + +export const keyboardDemo = { + type: 'code', + code: ` + + }>Edit profile + + + Edit profile + + + + + + + + + ` +}; + +export const indentDemo = { + type: 'code', + code: ` + + + + + }>Open Drawer + + + Notifications + The content behind this drawer scales down. + + + + + ` +}; diff --git a/apps/www/src/content/docs/components/drawer/index.mdx b/apps/www/src/content/docs/components/drawer/index.mdx index 4c11cf1c6..62f10ed00 100644 --- a/apps/www/src/content/docs/components/drawer/index.mdx +++ b/apps/www/src/content/docs/components/drawer/index.mdx @@ -5,7 +5,7 @@ source: packages/react/components/drawer --- import { playground, basicDemo, positionDemo, - controlledDemo, + controlledDemo, swipeAreaDemo, keyboardDemo, indentDemo, } from "./demo.ts"; @@ -18,6 +18,8 @@ Import and assemble the component: import { Drawer } from "@raystack/apsara"; + + @@ -29,6 +31,15 @@ import { Drawer } from "@raystack/apsara"; ``` +The indent parts are optional. They wrap your app: + +```tsx + + + {/* your app, with its drawers */} + +``` + ## Usage Pick the edge it enters from, then decide who owns its open state. @@ -51,6 +62,24 @@ Pass `open` with `onOpenChange` to own the state. Do this when the drawer has to +### Swipe to open + +`Drawer.SwipeArea` is an invisible strip along a screen edge. Users drag from it to open the drawer. It sits on the edge named by `side`. Pass `swipeDirection` to change the drag direction that opens it. The swipe area ignores pointer events while the drawer is open. + + + +### Forms and the phone keyboard + +`Drawer.Content` handles the on-screen keyboard on phones. When an input inside the drawer gets focus and the keyboard opens, the drawer scrolls to keep the input visible. You do not need to set anything up. Base UI also sets `--drawer-keyboard-inset` on the `drawer-viewport` slot while the keyboard is open. Use `var(--drawer-keyboard-inset, 0px)` to lift a pinned footer above the keyboard. + + + +### Indent + +Wrap your app in `Drawer.Provider`, then put `Drawer.IndentBackground` and `Drawer.Indent` at the top of the tree. While any drawer inside the provider is open, the indent scales down and rounds its top corners, and the background shows behind it. Both parts get a `data-active` attribute while a drawer is open. The effect follows the swipe as users drag the drawer closed. With reduced motion, the indent does not move. + + + ## API Reference The same header, body and footer shell as [Dialog](/docs/components/dialog), anchored to an edge. @@ -88,6 +117,30 @@ Renders the drawer panel that slides in from a screen edge. - Inherits all HTML div element props +### SwipeArea + +An invisible edge zone that opens the drawer on swipe. Place it inside `Drawer`. + + + +### Provider + +Tracks whether any drawer inside it is open. It renders no element. You only need it for the indent parts. + + + +### Indent + +Wraps your app content. It scales down while a drawer is open. + + + +### IndentBackground + +The layer behind `Drawer.Indent`. It fades in while a drawer is open. + + + ### Slots Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): @@ -104,6 +157,9 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `drawer-description` | The `Drawer.Description` element | | `drawer-body` | The `Drawer.Body` container | | `drawer-footer` | The `Drawer.Footer` container | +| `drawer-swipe-area` | The `Drawer.SwipeArea` edge zone | +| `drawer-indent` | The `Drawer.Indent` wrapper | +| `drawer-indent-background` | The `Drawer.IndentBackground` layer | ## Accessibility diff --git a/apps/www/src/content/docs/components/drawer/props.ts b/apps/www/src/content/docs/components/drawer/props.ts index bff6148de..a60861608 100644 --- a/apps/www/src/content/docs/components/drawer/props.ts +++ b/apps/www/src/content/docs/components/drawer/props.ts @@ -61,3 +61,56 @@ export interface DrawerContentProps { */ radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; } + +export interface DrawerSwipeAreaProps { + /** The swipe direction that opens the drawer. Defaults to the opposite of the root's `swipeDirection`. */ + swipeDirection?: 'up' | 'down' | 'left' | 'right'; + + /** + * Turns off swipe-to-open. + * @default false + */ + disabled?: boolean; + + /** + * Allows rendering as a different element. + * Accepts a React element or a function that receives props and returns an element. + * + * @remarks `ReactElement | function` + */ + render?: React.ReactElement; + + /** Additional CSS class name. */ + className?: string; +} + +export interface DrawerProviderProps { + /** The app content. A drawer inside it activates the indent parts while open. */ + children?: React.ReactNode; +} + +export interface DrawerIndentProps { + /** + * Allows rendering as a different element. + * Accepts a React element or a function that receives props and returns an element. + * + * @remarks `ReactElement | function` + */ + render?: React.ReactElement; + + /** Additional CSS class name. */ + className?: string; +} + +export interface DrawerIndentBackgroundProps { + /** + * Allows rendering as a different element. + * Accepts a React element or a function that receives props and returns an element. + * + * @remarks `ReactElement | function` + */ + render?: React.ReactElement; + + /** Additional CSS class name. */ + className?: string; +} diff --git a/packages/react/components/drawer/__tests__/data-slots.test.tsx b/packages/react/components/drawer/__tests__/data-slots.test.tsx index ffd61c696..262d60b2d 100644 --- a/packages/react/components/drawer/__tests__/data-slots.test.tsx +++ b/packages/react/components/drawer/__tests__/data-slots.test.tsx @@ -38,4 +38,22 @@ describe('Drawer data-slot contract', () => { render(); expect(getSlot(document.body, 'drawer-close')).toBeNull(); }); + + it('exposes slots for the swipe area and indent parts', () => { + render( + + + + + + + + + ); + expectSlots(document.body, [ + 'drawer-swipe-area', + 'drawer-indent', + 'drawer-indent-background' + ]); + }); }); diff --git a/packages/react/components/drawer/__tests__/drawer.test.tsx b/packages/react/components/drawer/__tests__/drawer.test.tsx index ccd025528..3a931805d 100644 --- a/packages/react/components/drawer/__tests__/drawer.test.tsx +++ b/packages/react/components/drawer/__tests__/drawer.test.tsx @@ -249,4 +249,96 @@ describe('Drawer', () => { }); }); }); + + describe('Swipe Area', () => { + const renderSwipeArea = (side?: 'top' | 'right' | 'bottom' | 'left') => + render( + + + {DRAWER_CONTENT} + + ).getByTestId('swipe-area'); + + it('applies the swipe area class', () => { + expect(renderSwipeArea()).toHaveClass(styles.swipeArea); + }); + + it.each([ + ['right', 'left'], + ['left', 'right'], + ['top', 'down'], + ['bottom', 'up'] + ] as const)('opens a %s drawer with a %s swipe', (side, direction) => { + expect(renderSwipeArea(side)).toHaveAttribute( + 'data-swipe-direction', + direction + ); + }); + + it('follows a swipeDirection set on the root', () => { + const swipeArea = render( + + + + ).getByTestId('swipe-area'); + expect(swipeArea).toHaveAttribute('data-swipe-direction', 'up'); + }); + + it('accepts its own swipeDirection', () => { + const swipeArea = render( + + + + ).getByTestId('swipe-area'); + expect(swipeArea).toHaveAttribute('data-swipe-direction', 'up'); + }); + + it('marks the swipe area as disabled', () => { + const swipeArea = render( + + + + ).getByTestId('swipe-area'); + expect(swipeArea).toHaveAttribute('data-disabled'); + }); + }); + + describe('Indent', () => { + const IndentedDrawer = ({ open }: { open: boolean }) => ( + + + + + {DRAWER_CONTENT} + + + + ); + + it('applies the indent classes', () => { + render(); + expect(screen.getByTestId('indent')).toHaveClass(styles.indent); + expect(screen.getByTestId('indent-background')).toHaveClass( + styles.indentBackground + ); + }); + + it('is inactive while every drawer is closed', () => { + render(); + expect(screen.getByTestId('indent')).not.toHaveAttribute('data-active'); + expect(screen.getByTestId('indent-background')).not.toHaveAttribute( + 'data-active' + ); + }); + + it('becomes active when a drawer opens', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('indent')).toHaveAttribute('data-active'); + expect(screen.getByTestId('indent-background')).toHaveAttribute( + 'data-active' + ); + }); + }); + }); }); diff --git a/packages/react/components/drawer/drawer-content.tsx b/packages/react/components/drawer/drawer-content.tsx index e07342b7e..9c2966744 100644 --- a/packages/react/components/drawer/drawer-content.tsx +++ b/packages/react/components/drawer/drawer-content.tsx @@ -50,47 +50,49 @@ export function DrawerContent({ ariaLabel ?? (ariaLabelledBy ? undefined : 'Drawer'); const theme = useThemeInjection(); return ( - - - - + + + - - {children} - {showCloseButton && ( - } - data-slot='drawer-close' - > - - )} - - - - + + {children} + {showCloseButton && ( + } + data-slot='drawer-close' + > + + )} + + + + + ); } DrawerContent.displayName = 'Drawer.Content'; diff --git a/packages/react/components/drawer/drawer-indent.tsx b/packages/react/components/drawer/drawer-indent.tsx new file mode 100644 index 000000000..e0a2878d1 --- /dev/null +++ b/packages/react/components/drawer/drawer-indent.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'; +import { cx } from 'class-variance-authority'; +import styles from './drawer.module.css'; + +export type DrawerIndentProps = DrawerPrimitive.Indent.Props; + +export function DrawerIndent({ className, ...props }: DrawerIndentProps) { + return ( + + ); +} +DrawerIndent.displayName = 'Drawer.Indent'; + +export type DrawerIndentBackgroundProps = + DrawerPrimitive.IndentBackground.Props; + +export function DrawerIndentBackground({ + className, + ...props +}: DrawerIndentBackgroundProps) { + return ( + + ); +} +DrawerIndentBackground.displayName = 'Drawer.IndentBackground'; diff --git a/packages/react/components/drawer/drawer-swipe-area.tsx b/packages/react/components/drawer/drawer-swipe-area.tsx new file mode 100644 index 000000000..46eb80f0d --- /dev/null +++ b/packages/react/components/drawer/drawer-swipe-area.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'; +import { cx } from 'class-variance-authority'; +import styles from './drawer.module.css'; + +export type DrawerSwipeAreaProps = DrawerPrimitive.SwipeArea.Props; + +export function DrawerSwipeArea({ className, ...props }: DrawerSwipeAreaProps) { + return ( + + ); +} +DrawerSwipeArea.displayName = 'Drawer.SwipeArea'; diff --git a/packages/react/components/drawer/drawer.module.css b/packages/react/components/drawer/drawer.module.css index 73c3efe65..6a6e17016 100644 --- a/packages/react/components/drawer/drawer.module.css +++ b/packages/react/components/drawer/drawer.module.css @@ -211,6 +211,62 @@ border-top: 1px solid var(--rs-color-border-base-primary); } +/* Base UI sets `data-swipe-direction` to the direction that opens the + drawer, which is the opposite of `side`. Swiping left opens a right drawer, + so that swipe area sits on the right edge. */ +.swipeArea { + position: fixed; + z-index: var(--rs-z-index-portal); +} + +.swipeArea[data-swipe-direction="left"], +.swipeArea[data-swipe-direction="right"] { + top: 0; + bottom: 0; + width: var(--rs-space-7); +} + +.swipeArea[data-swipe-direction="up"], +.swipeArea[data-swipe-direction="down"] { + left: 0; + right: 0; + height: var(--rs-space-7); +} + +.swipeArea[data-swipe-direction="left"] { + right: 0; +} + +.swipeArea[data-swipe-direction="right"] { + left: 0; +} + +.swipeArea[data-swipe-direction="up"] { + bottom: 0; +} + +.swipeArea[data-swipe-direction="down"] { + top: 0; +} + +.indentBackground { + position: fixed; + inset: 0; + z-index: -1; + background-color: var(--rs-color-background-neutral-emphasis); + opacity: 0; + transition: opacity var(--rs-duration-drawer) var(--rs-ease-drawer); +} + +.indentBackground[data-active] { + opacity: 1; +} + +.indent { + position: relative; + background-color: var(--rs-color-background-base-primary); +} + @media (prefers-reduced-motion: no-preference) { .drawerPopup-right, .drawerPopup-left, @@ -245,4 +301,35 @@ .drawerPopup-bottom[data-ending-style] { transform: translateY(100%); } + + /* Base UI writes `--drawer-swipe-progress` on the indent. The transition + turns off while it is above 0, so the indent follows the finger. */ + .indent { + --drawer-indent-settled: calc( + 1 - + clamp(0, calc(var(--drawer-swipe-progress, 0) * 100000), 1) + ); + transform-origin: center top; + transition: + transform var(--rs-duration-drawer) var(--rs-ease-drawer), + border-radius var(--rs-duration-drawer) var(--rs-ease-drawer); + transition-duration: calc( + var(--rs-duration-drawer) * + var(--drawer-indent-settled) + ); + } + + .indent[data-active] { + --drawer-indent-rest: calc(1 - var(--drawer-swipe-progress, 0)); + transform: scale(calc(1 - 0.02 * var(--drawer-indent-rest))) + translateY(calc(var(--rs-space-3) * var(--drawer-indent-rest))); + border-top-left-radius: calc( + var(--rs-radius-4) * + var(--drawer-indent-rest) + ); + border-top-right-radius: calc( + var(--rs-radius-4) * + var(--drawer-indent-rest) + ); + } } diff --git a/packages/react/components/drawer/drawer.tsx b/packages/react/components/drawer/drawer.tsx index d31c499ce..bc965203e 100644 --- a/packages/react/components/drawer/drawer.tsx +++ b/packages/react/components/drawer/drawer.tsx @@ -1,5 +1,6 @@ import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'; import { DrawerContent } from './drawer-content'; +import { DrawerIndent, DrawerIndentBackground } from './drawer-indent'; import { DrawerBody, DrawerDescription, @@ -8,9 +9,15 @@ import { DrawerTitle } from './drawer-misc'; import { DrawerRoot } from './drawer-root'; +import { DrawerSwipeArea } from './drawer-swipe-area'; export type { DrawerContentProps } from './drawer-content'; +export type { + DrawerIndentBackgroundProps, + DrawerIndentProps +} from './drawer-indent'; export type { DrawerRootProps } from './drawer-root'; +export type { DrawerSwipeAreaProps } from './drawer-swipe-area'; export const Drawer = Object.assign(DrawerRoot, { Trigger: DrawerPrimitive.Trigger, @@ -21,5 +28,9 @@ export const Drawer = Object.assign(DrawerRoot, { Body: DrawerBody, Footer: DrawerFooter, Close: DrawerPrimitive.Close, + SwipeArea: DrawerSwipeArea, + Provider: DrawerPrimitive.Provider, + Indent: DrawerIndent, + IndentBackground: DrawerIndentBackground, createHandle: DrawerPrimitive.createHandle }); From 8b4d480e36e73877dc16880e39b7a096363a79e2 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Oct 2026 00:59:31 +0530 Subject: [PATCH 2/2] feat: [drawer] make keyboard handling opt-in and merge indent parts - Add enableVirtualKeyboard to Drawer.Content. The bottom drawer moves up by --drawer-keyboard-inset while the keyboard is open. - Add a container prop to Drawer.Content. - Drawer.Indent renders the indent background. Remove Drawer.IndentBackground. - Give the indent a min-height of 100dvh. - Export DrawerIndentProps and DrawerSwipeAreaProps. - Keep the swipe area and indent docs examples inside their own box. --- .../content/docs/components/drawer/demo.ts | 78 ++++++++++----- .../content/docs/components/drawer/index.mdx | 29 +++--- .../content/docs/components/drawer/props.ts | 32 ++++--- .../drawer/__tests__/data-slots.test.tsx | 1 - .../drawer/__tests__/drawer.test.tsx | 76 ++++++++++++--- .../components/drawer/drawer-content.tsx | 96 +++++++++++-------- .../react/components/drawer/drawer-indent.tsx | 33 +++---- .../react/components/drawer/drawer.module.css | 9 +- packages/react/components/drawer/drawer.tsx | 8 +- packages/react/components/drawer/index.tsx | 2 + packages/react/index.tsx | 4 +- 11 files changed, 230 insertions(+), 138 deletions(-) diff --git a/apps/www/src/content/docs/components/drawer/demo.ts b/apps/www/src/content/docs/components/drawer/demo.ts index 91c24760c..66e78a6a9 100644 --- a/apps/www/src/content/docs/components/drawer/demo.ts +++ b/apps/www/src/content/docs/components/drawer/demo.ts @@ -124,17 +124,31 @@ function ControlledDrawer() { export const swipeAreaDemo = { type: 'code', code: ` - - }>Open Drawer - - - - Library - You can also swipe in from the right edge of the screen. - - Content here - - ` +function SwipeAreaDemo() { + const [container, setContainer] = React.useState(null); + + return ( +
+ + + + Swipe from the right edge to open the drawer. + + + + Library + Swipe from the edge whenever you want to jump back into your playlists. + + + +
+ ); +}` }; export const keyboardDemo = { @@ -142,7 +156,7 @@ export const keyboardDemo = { code: ` }>Edit profile - + Edit profile @@ -159,18 +173,30 @@ export const keyboardDemo = { export const indentDemo = { type: 'code', code: ` - - - - - }>Open Drawer - - - Notifications - The content behind this drawer scales down. - - - - - ` +function IndentDemo() { + const [container, setContainer] = React.useState(null); + + return ( + +
+ + + + }>Open drawer + + + Notifications + You are all caught up. Good job! + + + + + +
+
+ ); +}` }; diff --git a/apps/www/src/content/docs/components/drawer/index.mdx b/apps/www/src/content/docs/components/drawer/index.mdx index 62f10ed00..aa0fd6c7c 100644 --- a/apps/www/src/content/docs/components/drawer/index.mdx +++ b/apps/www/src/content/docs/components/drawer/index.mdx @@ -31,11 +31,10 @@ import { Drawer } from "@raystack/apsara";
``` -The indent parts are optional. They wrap your app: +The indent is optional. It wraps your app: ```tsx - {/* your app, with its drawers */} ``` @@ -64,19 +63,27 @@ Pass `open` with `onOpenChange` to own the state. Do this when the drawer has to ### Swipe to open -`Drawer.SwipeArea` is an invisible strip along a screen edge. Users drag from it to open the drawer. It sits on the edge named by `side`. Pass `swipeDirection` to change the drag direction that opens it. The swipe area ignores pointer events while the drawer is open. +`Drawer.SwipeArea` is an invisible strip along a screen edge. Users drag from it to open the drawer. It sits on the edge named by `side`. Pass `swipeDirection` to change the drag direction that opens it. While the drawer is closed, the strip sits above the page and takes clicks along that edge. It ignores pointer events while the drawer is open. + +The example keeps the drawer inside its box. `Drawer.Content` renders into the box through `container`, and `contain: layout` on the box positions the fixed parts inside it. `modal={false}` keeps the rest of the page usable. ### Forms and the phone keyboard -`Drawer.Content` handles the on-screen keyboard on phones. When an input inside the drawer gets focus and the keyboard opens, the drawer scrolls to keep the input visible. You do not need to set anything up. Base UI also sets `--drawer-keyboard-inset` on the `drawer-viewport` slot while the keyboard is open. Use `var(--drawer-keyboard-inset, 0px)` to lift a pinned footer above the keyboard. +Set `enableVirtualKeyboard` on `Drawer.Content` when the drawer has form fields. When an input gets focus and the on-screen keyboard opens, the drawer scrolls to keep the input visible, and a bottom drawer moves up above the keyboard. On touch devices, the drawer then handles taps on its inputs itself, so the page does not jump. + +Base UI sets `--drawer-keyboard-inset` on the `drawer-viewport` slot while the keyboard is open. Use `var(--drawer-keyboard-inset, 0px)` to lift a pinned footer above the keyboard. ### Indent -Wrap your app in `Drawer.Provider`, then put `Drawer.IndentBackground` and `Drawer.Indent` at the top of the tree. While any drawer inside the provider is open, the indent scales down and rounds its top corners, and the background shows behind it. Both parts get a `data-active` attribute while a drawer is open. The effect follows the swipe as users drag the drawer closed. With reduced motion, the indent does not move. +Wrap your app in `Drawer.Provider` and `Drawer.Indent`. While any drawer inside the provider is open, the indent scales down and rounds its top corners, and a background layer shows behind it. The indent and its background (the `drawer-indent-background` slot) get a `data-active` attribute while a drawer is open. The effect follows the swipe as users drag the drawer closed. When the operating system asks for reduced motion, the indent does not move. + +`Drawer.Indent` is at least as tall as the screen. The effect scales from the top of the indent, so keep your app's scroll container inside it. On a page that scrolls the whole document, the visible content shifts up instead of down. While a drawer is open, `position: fixed` elements inside the indent scale with it. + +The example uses the same box as the swipe area example. @@ -125,22 +132,16 @@ An invisible edge zone that opens the drawer on swipe. Place it inside `Drawer`. ### Provider -Tracks whether any drawer inside it is open. It renders no element. You only need it for the indent parts. +Tracks whether any drawer inside it is open. It renders no element. `Drawer.Indent` needs it. ### Indent -Wraps your app content. It scales down while a drawer is open. +Wraps your app content and renders the background layer before it. It scales down while a drawer is open. Place it inside `Drawer.Provider`. -### IndentBackground - -The layer behind `Drawer.Indent`. It fades in while a drawer is open. - - - ### Slots Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): @@ -159,7 +160,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `drawer-footer` | The `Drawer.Footer` container | | `drawer-swipe-area` | The `Drawer.SwipeArea` edge zone | | `drawer-indent` | The `Drawer.Indent` wrapper | -| `drawer-indent-background` | The `Drawer.IndentBackground` layer | +| `drawer-indent-background` | The background layer that `Drawer.Indent` renders | ## Accessibility diff --git a/apps/www/src/content/docs/components/drawer/props.ts b/apps/www/src/content/docs/components/drawer/props.ts index a60861608..498a8aca6 100644 --- a/apps/www/src/content/docs/components/drawer/props.ts +++ b/apps/www/src/content/docs/components/drawer/props.ts @@ -60,6 +60,22 @@ export interface DrawerContentProps { * @defaultValue The theme's `radius` */ radius?: 'none' | 'small' | 'medium' | 'large' | 'full'; + + /** + * Keeps a focused input above the on-screen keyboard on phones. + * @default false + */ + enableVirtualKeyboard?: boolean; + + /** + * Element to render the drawer into. + * @defaultValue `document.body` + */ + container?: + | HTMLElement + | ShadowRoot + | React.RefObject + | null; } export interface DrawerSwipeAreaProps { @@ -85,24 +101,14 @@ export interface DrawerSwipeAreaProps { } export interface DrawerProviderProps { - /** The app content. A drawer inside it activates the indent parts while open. */ + /** The app content. An open drawer inside it activates `Drawer.Indent`. */ children?: React.ReactNode; } export interface DrawerIndentProps { - /** - * Allows rendering as a different element. - * Accepts a React element or a function that receives props and returns an element. - * - * @remarks `ReactElement | function` - */ - render?: React.ReactElement; - - /** Additional CSS class name. */ - className?: string; -} + /** Your app content. */ + children?: React.ReactNode; -export interface DrawerIndentBackgroundProps { /** * Allows rendering as a different element. * Accepts a React element or a function that receives props and returns an element. diff --git a/packages/react/components/drawer/__tests__/data-slots.test.tsx b/packages/react/components/drawer/__tests__/data-slots.test.tsx index 262d60b2d..7c6221f9b 100644 --- a/packages/react/components/drawer/__tests__/data-slots.test.tsx +++ b/packages/react/components/drawer/__tests__/data-slots.test.tsx @@ -42,7 +42,6 @@ describe('Drawer data-slot contract', () => { it('exposes slots for the swipe area and indent parts', () => { render( - diff --git a/packages/react/components/drawer/__tests__/drawer.test.tsx b/packages/react/components/drawer/__tests__/drawer.test.tsx index 3a931805d..b2501c02b 100644 --- a/packages/react/components/drawer/__tests__/drawer.test.tsx +++ b/packages/react/components/drawer/__tests__/drawer.test.tsx @@ -1,9 +1,16 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { type ReactElement } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { Button } from '~/components/button'; +import { getSlot } from '~/test-utils/data-slots'; import { Drawer } from '../drawer'; import styles from '../drawer.module.css'; @@ -69,6 +76,23 @@ describe('Drawer', () => { expect(drawer.closest('body')).toBe(document.body); }); }); + + it('renders into the given container', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + render( + + + {DRAWER_CONTENT} + + + ); + + await waitFor(() => { + expect(container).toContainElement(screen.getByRole('dialog')); + }); + container.remove(); + }); }); describe('Overlay', () => { @@ -306,7 +330,6 @@ describe('Drawer', () => { describe('Indent', () => { const IndentedDrawer = ({ open }: { open: boolean }) => ( - {DRAWER_CONTENT} @@ -314,31 +337,58 @@ describe('Drawer', () => { ); + const getBackground = () => + getSlot(document.body, 'drawer-indent-background'); - it('applies the indent classes', () => { + it('renders the background outside the indent', () => { render(); - expect(screen.getByTestId('indent')).toHaveClass(styles.indent); - expect(screen.getByTestId('indent-background')).toHaveClass( - styles.indentBackground - ); + const indent = screen.getByTestId('indent'); + expect(indent).toHaveClass(styles.indent); + expect(getBackground()).toHaveClass(styles.indentBackground); + expect(indent).not.toContainElement(getBackground()); }); it('is inactive while every drawer is closed', () => { render(); expect(screen.getByTestId('indent')).not.toHaveAttribute('data-active'); - expect(screen.getByTestId('indent-background')).not.toHaveAttribute( - 'data-active' - ); + expect(getBackground()).not.toHaveAttribute('data-active'); }); it('becomes active when a drawer opens', async () => { render(); await waitFor(() => { expect(screen.getByTestId('indent')).toHaveAttribute('data-active'); - expect(screen.getByTestId('indent-background')).toHaveAttribute( - 'data-active' - ); + expect(getBackground()).toHaveAttribute('data-active'); }); }); }); + + describe('Virtual Keyboard', () => { + const KeyboardDrawer = (props: { enableVirtualKeyboard?: boolean }) => ( + + + + + + ); + const getInset = () => + getSlot(document.body, 'drawer-viewport')?.style.getPropertyValue( + '--drawer-keyboard-inset' + ); + + it('tracks the keyboard inset when enableVirtualKeyboard is set', async () => { + render(); + const input = await screen.findByLabelText('Name'); + act(() => input.focus()); + await waitFor(() => expect(getInset()).toBe('0px')); + }); + + it('does not track the keyboard inset by default', async () => { + render(); + const input = await screen.findByLabelText('Name'); + act(() => input.focus()); + await act(() => new Promise(resolve => requestAnimationFrame(resolve))); + expect(getInset()).toBe(''); + }); + }); }); diff --git a/packages/react/components/drawer/drawer-content.tsx b/packages/react/components/drawer/drawer-content.tsx index 64d28786f..b4726c31a 100644 --- a/packages/react/components/drawer/drawer-content.tsx +++ b/packages/react/components/drawer/drawer-content.tsx @@ -37,6 +37,13 @@ export interface DrawerContentProps * @default 'Close' */ closeLabel?: string; + /** + * Keeps a focused input above the on-screen keyboard on phones. + * @default false + */ + enableVirtualKeyboard?: boolean; + /** Element to render the drawer into. Defaults to `document.body`. */ + container?: DrawerPrimitive.Portal.Props['container']; } export function DrawerContent({ @@ -49,55 +56,62 @@ export function DrawerContent({ 'aria-labelledby': ariaLabelledBy, closeLabel = 'Close', radius, + enableVirtualKeyboard = false, + container, ...props }: DrawerContentProps) { const resolvedAriaLabel = ariaLabel ?? (ariaLabelledBy ? undefined : 'Drawer'); const theme = useThemeInjection(); - return ( - - - - + + + - - - {children} - {showCloseButton && ( - } - data-slot='drawer-close' - > - - )} - - - - + {children} + {showCloseButton && ( + } + data-slot='drawer-close' + > + + )} + + + + + ); + return enableVirtualKeyboard ? ( + + {portal} + ) : ( + portal ); } DrawerContent.displayName = 'Drawer.Content'; diff --git a/packages/react/components/drawer/drawer-indent.tsx b/packages/react/components/drawer/drawer-indent.tsx index e0a2878d1..2e2f69355 100644 --- a/packages/react/components/drawer/drawer-indent.tsx +++ b/packages/react/components/drawer/drawer-indent.tsx @@ -8,28 +8,17 @@ export type DrawerIndentProps = DrawerPrimitive.Indent.Props; export function DrawerIndent({ className, ...props }: DrawerIndentProps) { return ( - + <> + + + ); } DrawerIndent.displayName = 'Drawer.Indent'; - -export type DrawerIndentBackgroundProps = - DrawerPrimitive.IndentBackground.Props; - -export function DrawerIndentBackground({ - className, - ...props -}: DrawerIndentBackgroundProps) { - return ( - - ); -} -DrawerIndentBackground.displayName = 'Drawer.IndentBackground'; diff --git a/packages/react/components/drawer/drawer.module.css b/packages/react/components/drawer/drawer.module.css index 6a6e17016..fee6c36c0 100644 --- a/packages/react/components/drawer/drawer.module.css +++ b/packages/react/components/drawer/drawer.module.css @@ -125,10 +125,14 @@ ); } -/* Bottom side drawer */ +/* Bottom side drawer. With `enableVirtualKeyboard`, Base UI sets + `--drawer-keyboard-inset` while the keyboard is open. The drawer is shorter + than most phone keyboards, so it moves up instead of only scrolling. */ .drawerPopup-bottom { width: 100%; height: 300px; + max-height: calc(100% - var(--drawer-keyboard-inset, 0px)); + margin-bottom: var(--drawer-keyboard-inset, 0px); border-radius: var(--rs-radius-2) var(--rs-radius-2) 0 0; transform: translateY(var(--drawer-swipe-movement-y)); } @@ -262,8 +266,11 @@ opacity: 1; } +/* Fills the screen so the background shows only around the scaled indent, + not below short pages. */ .indent { position: relative; + min-height: 100dvh; background-color: var(--rs-color-background-base-primary); } diff --git a/packages/react/components/drawer/drawer.tsx b/packages/react/components/drawer/drawer.tsx index bc965203e..238e8b07e 100644 --- a/packages/react/components/drawer/drawer.tsx +++ b/packages/react/components/drawer/drawer.tsx @@ -1,6 +1,6 @@ import { Drawer as DrawerPrimitive } from '@base-ui/react/drawer'; import { DrawerContent } from './drawer-content'; -import { DrawerIndent, DrawerIndentBackground } from './drawer-indent'; +import { DrawerIndent } from './drawer-indent'; import { DrawerBody, DrawerDescription, @@ -12,10 +12,7 @@ import { DrawerRoot } from './drawer-root'; import { DrawerSwipeArea } from './drawer-swipe-area'; export type { DrawerContentProps } from './drawer-content'; -export type { - DrawerIndentBackgroundProps, - DrawerIndentProps -} from './drawer-indent'; +export type { DrawerIndentProps } from './drawer-indent'; export type { DrawerRootProps } from './drawer-root'; export type { DrawerSwipeAreaProps } from './drawer-swipe-area'; @@ -31,6 +28,5 @@ export const Drawer = Object.assign(DrawerRoot, { SwipeArea: DrawerSwipeArea, Provider: DrawerPrimitive.Provider, Indent: DrawerIndent, - IndentBackground: DrawerIndentBackground, createHandle: DrawerPrimitive.createHandle }); diff --git a/packages/react/components/drawer/index.tsx b/packages/react/components/drawer/index.tsx index 855852bb6..1fb1f1adf 100644 --- a/packages/react/components/drawer/index.tsx +++ b/packages/react/components/drawer/index.tsx @@ -1,3 +1,5 @@ export { Drawer } from './drawer'; export type { DrawerContentProps } from './drawer-content'; +export type { DrawerIndentProps } from './drawer-indent'; export type { DrawerRootProps as DrawerProps } from './drawer-root'; +export type { DrawerSwipeAreaProps } from './drawer-swipe-area'; diff --git a/packages/react/index.tsx b/packages/react/index.tsx index fe8d40934..744025233 100644 --- a/packages/react/index.tsx +++ b/packages/react/index.tsx @@ -205,7 +205,9 @@ export { Dialog, type DialogContentProps } from './components/dialog'; export { Drawer, type DrawerContentProps, - type DrawerProps + type DrawerIndentProps, + type DrawerProps, + type DrawerSwipeAreaProps } from './components/drawer'; export { defaultShortcuts,