Skip to content
Merged
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
80 changes: 80 additions & 0 deletions apps/www/src/content/docs/components/drawer/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,83 @@ function ControlledDrawer() {
);
}`
};

export const swipeAreaDemo = {
type: 'code',
code: `
function SwipeAreaDemo() {
const [container, setContainer] = React.useState(null);

return (
<div
ref={setContainer}
style={{ position: 'relative', width: '100%', overflow: 'hidden', contain: 'layout', border: '1px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-4)' }}
>
<Drawer side="right" modal={false}>
<Drawer.SwipeArea
style={{ backgroundColor: 'var(--rs-color-background-accent-primary)', borderLeft: '2px dashed var(--rs-color-border-accent-emphasis)' }}
/>
<Flex align="center" justify="center" style={{ minHeight: 320 }}>
<Text variant="secondary">Swipe from the right edge to open the drawer.</Text>
</Flex>
<Drawer.Content container={container}>
<Drawer.Header>
<Drawer.Title>Library</Drawer.Title>
<Drawer.Description>Swipe from the edge whenever you want to jump back into your playlists.</Drawer.Description>
</Drawer.Header>
</Drawer.Content>
</Drawer>
</div>
);
}`
};

export const keyboardDemo = {
type: 'code',
code: `
<Drawer side="bottom">
<Drawer.Trigger render={<Button />}>Edit profile</Drawer.Trigger>
<Drawer.Content side="bottom" enableVirtualKeyboard>
<Drawer.Header>
<Drawer.Title>Edit profile</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<Flex direction="column" gap={3}>
<Input placeholder="Name" />
<Input type="email" placeholder="Email" />
</Flex>
</Drawer.Body>
</Drawer.Content>
</Drawer>`
};

export const indentDemo = {
type: 'code',
code: `
function IndentDemo() {
const [container, setContainer] = React.useState(null);

return (
<Drawer.Provider>
<div
ref={setContainer}
style={{ position: 'relative', width: '100%', overflow: 'hidden', contain: 'layout', border: '1px solid var(--rs-color-border-base-primary)', borderRadius: 'var(--rs-radius-4)' }}
>
<Drawer.Indent style={{ minHeight: 400 }}>
<Flex align="center" justify="center" style={{ minHeight: 400 }}>
<Drawer side="bottom" modal={false}>
<Drawer.Trigger render={<Button />}>Open drawer</Drawer.Trigger>
<Drawer.Content side="bottom" container={container}>
<Drawer.Header>
<Drawer.Title>Notifications</Drawer.Title>
<Drawer.Description>You are all caught up. Good job!</Drawer.Description>
</Drawer.Header>
</Drawer.Content>
</Drawer>
</Flex>
</Drawer.Indent>
</div>
</Drawer.Provider>
);
}`
};
59 changes: 58 additions & 1 deletion apps/www/src/content/docs/components/drawer/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ source: packages/react/components/drawer
---

import { playground, basicDemo, positionDemo,
controlledDemo,
controlledDemo, swipeAreaDemo, keyboardDemo, indentDemo,
} from "./demo.ts";

<Demo data={playground} />
Expand All @@ -18,6 +18,8 @@ Import and assemble the component:
import { Drawer } from "@raystack/apsara";

<Drawer side="right">
<Drawer.Trigger />
<Drawer.SwipeArea />
<Drawer.Content>
<Drawer.Header>
<Drawer.Title />
Expand All @@ -29,6 +31,14 @@ import { Drawer } from "@raystack/apsara";
</Drawer>
```

The indent is optional. It wraps your app:

```tsx
<Drawer.Provider>
<Drawer.Indent>{/* your app, with its drawers */}</Drawer.Indent>
</Drawer.Provider>
```

## Usage

Pick the edge it enters from, then decide who owns its open state.
Expand All @@ -51,6 +61,32 @@ Pass `open` with `onOpenChange` to own the state. Do this when the drawer has to

<Demo data={controlledDemo} />

### 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.

<Demo data={swipeAreaDemo} />

### 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.

<Demo data={keyboardDemo} />

### 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.

<Demo data={indentDemo} />

## API Reference

The same header, body and footer shell as [Dialog](/docs/components/dialog), anchored to an edge.
Expand Down Expand Up @@ -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`.

<auto-type-table path="./props.ts" name="DrawerSwipeAreaProps" />

### Provider

Tracks whether any drawer inside it is open. It renders no element. `Drawer.Indent` needs it.

<auto-type-table path="./props.ts" name="DrawerProviderProps" />

### 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`.

<auto-type-table path="./props.ts" name="DrawerIndentProps" />

### Slots

Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot):
Expand All @@ -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

Expand Down
59 changes: 59 additions & 0 deletions apps/www/src/content/docs/components/drawer/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | ShadowRoot | null>
| 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;
}
17 changes: 17 additions & 0 deletions packages/react/components/drawer/__tests__/data-slots.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,21 @@ describe('Drawer data-slot contract', () => {
render(<OpenDrawer showCloseButton={false} />);
expect(getSlot(document.body, 'drawer-close')).toBeNull();
});

it('exposes slots for the swipe area and indent parts', () => {
render(
<Drawer.Provider>
<Drawer.Indent>
<Drawer>
<Drawer.SwipeArea />
</Drawer>
</Drawer.Indent>
</Drawer.Provider>
);
expectSlots(document.body, [
'drawer-swipe-area',
'drawer-indent',
'drawer-indent-background'
]);
});
});
Loading
Loading