diff --git a/apps/www/src/content/docs/components/drawer/demo.ts b/apps/www/src/content/docs/components/drawer/demo.ts index 360fd17c8..66e78a6a9 100644 --- a/apps/www/src/content/docs/components/drawer/demo.ts +++ b/apps/www/src/content/docs/components/drawer/demo.ts @@ -120,3 +120,83 @@ function ControlledDrawer() { ); }` }; + +export const swipeAreaDemo = { + type: 'code', + code: ` +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 = { + type: 'code', + code: ` + + }>Edit profile + + + Edit profile + + + + + + + + + ` +}; + +export const indentDemo = { + type: 'code', + code: ` +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 4c11cf1c6..aa0fd6c7c 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,14 @@ import { Drawer } from "@raystack/apsara"; ``` +The indent is optional. It wraps your app: + +```tsx + + {/* your app, with its drawers */} + +``` + ## Usage Pick the edge it enters from, then decide who owns its open state. @@ -51,6 +61,32 @@ 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. 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 + +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` 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. + + + ## API Reference The same header, body and footer shell as [Dialog](/docs/components/dialog), anchored to an edge. @@ -88,6 +124,24 @@ 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. `Drawer.Indent` needs it. + + + +### Indent + +Wraps your app content and renders the background layer before it. It scales down while a drawer is open. Place it inside `Drawer.Provider`. + + + ### Slots Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): @@ -104,6 +158,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 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 bff6148de..498a8aca6 100644 --- a/apps/www/src/content/docs/components/drawer/props.ts +++ b/apps/www/src/content/docs/components/drawer/props.ts @@ -60,4 +60,63 @@ 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 { + /** 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. An open drawer inside it activates `Drawer.Indent`. */ + children?: React.ReactNode; +} + +export interface DrawerIndentProps { + /** Your app content. */ + children?: React.ReactNode; + + /** + * 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..7c6221f9b 100644 --- a/packages/react/components/drawer/__tests__/data-slots.test.tsx +++ b/packages/react/components/drawer/__tests__/data-slots.test.tsx @@ -38,4 +38,21 @@ 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..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', () => { @@ -249,4 +273,122 @@ 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} + + + + ); + const getBackground = () => + getSlot(document.body, 'drawer-indent-background'); + + it('renders the background outside the indent', () => { + render(); + 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(getBackground()).not.toHaveAttribute('data-active'); + }); + + it('becomes active when a drawer opens', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('indent')).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 d596bfac0..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,13 +56,15 @@ export function DrawerContent({ 'aria-labelledby': ariaLabelledBy, closeLabel = 'Close', radius, + enableVirtualKeyboard = false, + container, ...props }: DrawerContentProps) { const resolvedAriaLabel = ariaLabel ?? (ariaLabelledBy ? undefined : 'Drawer'); const theme = useThemeInjection(); - return ( - + const portal = ( + ); + 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 new file mode 100644 index 000000000..2e2f69355 --- /dev/null +++ b/packages/react/components/drawer/drawer-indent.tsx @@ -0,0 +1,24 @@ +'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'; 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..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)); } @@ -211,6 +215,65 @@ 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; +} + +/* 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); +} + @media (prefers-reduced-motion: no-preference) { .drawerPopup-right, .drawerPopup-left, @@ -245,4 +308,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..238e8b07e 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 } from './drawer-indent'; import { DrawerBody, DrawerDescription, @@ -8,9 +9,12 @@ import { DrawerTitle } from './drawer-misc'; import { DrawerRoot } from './drawer-root'; +import { DrawerSwipeArea } from './drawer-swipe-area'; export type { DrawerContentProps } from './drawer-content'; +export type { 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 +25,8 @@ export const Drawer = Object.assign(DrawerRoot, { Body: DrawerBody, Footer: DrawerFooter, Close: DrawerPrimitive.Close, + SwipeArea: DrawerSwipeArea, + Provider: DrawerPrimitive.Provider, + Indent: DrawerIndent, 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,