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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ We're still in the early days - Many components are still being created and stab
- [x] Scroll Area
- [x] Select
- [x] Separator
- [x] Sheet
- [x] Slider
- [x] Switch
- [x] Tabs
Expand Down
3 changes: 2 additions & 1 deletion component.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"preview/src/components/date_picker",
"preview/src/components/textarea",
"preview/src/components/skeleton",
"preview/src/components/card"
"preview/src/components/card",
"preview/src/components/sheet"
]
}
78 changes: 78 additions & 0 deletions playwright/sheet.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test';

test('sheet basic interactions', async ({ page }) => {
await page.goto('http://127.0.0.1:8080/component/?name=sheet&', { timeout: 20 * 60 * 1000 });

// Open sheet from Right button
await page.getByRole('button', { name: 'Right' }).click();

// Assert the sheet is open
const sheet = page.locator('.sheet-root');
await expect(sheet).toHaveAttribute('data-state', 'open');

// Assert the first input is focused (focus trap)
const nameInput = page.locator('#sheet-demo-name');
await expect(nameInput).toBeFocused();

// Tab through focusable elements and verify focus cycles
// Tab: name input -> username input -> Save button -> Cancel button -> close button -> name input
await page.keyboard.press('Tab');
const usernameInput = page.locator('#sheet-demo-username');
await expect(usernameInput).toBeFocused();

await page.keyboard.press('Tab');
const saveButton = page.getByRole('button', { name: 'Save changes' });
await expect(saveButton).toBeFocused();

await page.keyboard.press('Tab');
const cancelButton = page.getByRole('button', { name: 'Cancel' });
await expect(cancelButton).toBeFocused();

await page.keyboard.press('Tab');
const closeButton = sheet.locator('.sheet-close');
await expect(closeButton).toBeFocused();

// Tab again should cycle back to first input
await page.keyboard.press('Tab');
await expect(nameInput).toBeFocused();

// Hitting escape should close the sheet
await page.keyboard.press('Escape');
await expect(sheet).toHaveCount(0);

// Reopen the sheet
await page.getByRole('button', { name: 'Right' }).click();
await expect(sheet).toHaveAttribute('data-state', 'open');

// Click the close button
await closeButton.click();
await expect(sheet).toHaveCount(0);
});

test('sheet opens from different sides', async ({ page }) => {
await page.goto('http://127.0.0.1:8080/component/?name=sheet&', { timeout: 20 * 60 * 1000 });

const sheet = page.locator('.sheet-root');
const sheetContent = page.locator('[data-slot="sheet-content"]');

// Test Top
await page.getByRole('button', { name: 'Top' }).click();
await expect(sheet).toHaveAttribute('data-state', 'open');
await expect(sheetContent).toHaveAttribute('data-side', 'top');
await page.keyboard.press('Escape');
await expect(sheet).toHaveCount(0);

// Test Bottom
await page.getByRole('button', { name: 'Bottom' }).click();
await expect(sheet).toHaveAttribute('data-state', 'open');
await expect(sheetContent).toHaveAttribute('data-side', 'bottom');
await page.keyboard.press('Escape');
await expect(sheet).toHaveCount(0);

// Test Left
await page.getByRole('button', { name: 'Left' }).click();
await expect(sheet).toHaveAttribute('data-state', 'open');
await expect(sheetContent).toHaveAttribute('data-side', 'left');
await page.keyboard.press('Escape');
await expect(sheet).toHaveCount(0);
});
1 change: 1 addition & 0 deletions preview/src/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ examples!(
select,
separator,
skeleton,
sheet,
slider,
switch,
tabs,
Expand Down
21 changes: 21 additions & 0 deletions preview/src/components/sheet/component.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "sheet",
"description": "A sheet component as an edge panel that complements the main content",
"authors": [
"zhiyanzhaijie"
],
"exclude": [
"variants",
"docs.md",
"component.json"
],
"cargoDependencies": [
{
"name": "dioxus-primitives",
"git": "https://github.com/DioxusLabs/components"
}
],
"globalAssets": [
"../../../assets/dx-components-theme.css"
]
}
158 changes: 158 additions & 0 deletions preview/src/components/sheet/component.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
use dioxus::prelude::*;
use dioxus_primitives::dialog::{
self, DialogCtx, DialogDescriptionProps, DialogRootProps, DialogTitleProps,
};

#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub enum SheetSide {
Top,
#[default]
Right,
Bottom,
Left,
}

impl SheetSide {
pub fn as_str(&self) -> &'static str {
match self {
SheetSide::Top => "top",
SheetSide::Right => "right",
SheetSide::Bottom => "bottom",
SheetSide::Left => "left",
}
}
}

#[component]
pub fn Sheet(props: DialogRootProps) -> Element {
rsx! {
SheetRoot {
id: props.id,
is_modal: props.is_modal,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
attributes: props.attributes,
{props.children}
}
}
}

#[component]
fn SheetRoot(props: DialogRootProps) -> Element {
rsx! {
document::Link { rel: "stylesheet", href: asset!("./style.css") }
dialog::DialogRoot {
class: "sheet-root",
"data-slot": "sheet-root",
id: props.id,
is_modal: props.is_modal,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
attributes: props.attributes,
{props.children}
}
}
}

#[component]
pub fn SheetContent(
#[props(default = ReadSignal::new(Signal::new(None)))] id: ReadSignal<Option<String>>,
#[props(default)] side: SheetSide,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let class = class
.map(|c| format!("sheet {c}"))
.unwrap_or("sheet".to_string());

rsx! {
dialog::DialogContent {
class,
id,
"data-slot": "sheet-content",
"data-side": side.as_str(),
attributes,
{children}
SheetClose { class: "sheet-close",
svg {
class: "sheet-close-icon",
view_box: "0 0 24 24",
xmlns: "http://www.w3.org/2000/svg",
path { d: "M18 6 6 18" }
path { d: "m6 6 12 12" }
}
}
}
}
}

#[component]
pub fn SheetHeader(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div { class: "sheet-header", "data-slot": "sheet-header", ..attributes, {children} }
}
}

#[component]
pub fn SheetFooter(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
div { class: "sheet-footer", "data-slot": "sheet-footer", ..attributes, {children} }
}
}

#[component]
pub fn SheetTitle(props: DialogTitleProps) -> Element {
rsx! {
dialog::DialogTitle {
id: props.id,
class: "sheet-title",
"data-slot": "sheet-title",
attributes: props.attributes,
{props.children}
}
}
}

#[component]
pub fn SheetDescription(props: DialogDescriptionProps) -> Element {
rsx! {
dialog::DialogDescription {
id: props.id,
class: "sheet-description",
"data-slot": "sheet-description",
attributes: props.attributes,
{props.children}
}
}
}

#[component]
pub fn SheetClose(
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
r#as: Option<Callback<Vec<Attribute>, Element>>,
children: Option<Element>,
) -> Element {
let ctx: DialogCtx = use_context();

let mut merged_attributes: Vec<Attribute> = vec![onclick(move |_| {
ctx.set_open(false);
})];
merged_attributes.extend(attributes);

if let Some(dynamic) = r#as {
dynamic.call(merged_attributes)
} else {
rsx! {
button { ..merged_attributes, {children} }
}
}
}
51 changes: 51 additions & 0 deletions preview/src/components/sheet/docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
The sheet component is a panel that slides in from the edge of the screen. It can be used to display additional content, forms, or navigation menus without leaving the current page.

## Component Structure

```rust
// The sheet component must wrap all sheet elements.
Sheet {
// The open prop determines if the sheet is currently open or closed.
open: open(),
// SheetContent wraps the content and defines the side from which the sheet slides in.
// Available sides: Top, Right (default), Bottom, Left.
SheetContent {
side: SheetSide::Right,
// SheetHeader groups the title and description at the top.
SheetHeader {
// The sheet title defines the heading of the sheet.
SheetTitle {
"Edit Profile"
}
// The sheet description provides additional information about the sheet.
SheetDescription {
"Make changes to your profile here."
}
}
// Add your main content here.
// SheetFooter groups actions at the bottom.
SheetFooter {
// SheetClose can be used to close the sheet.
SheetClose {
"Close"
}
}
}
}
```

## SheetClose with `as` prop

The `as` prop allows you to render a custom element while preserving the close behavior, similar to shadcn/ui's `asChild` pattern.

```rust
// Default: renders as <button>
SheetClose { "Close" }

// Custom element: attributes include the preset onclick handler
SheetClose {
r#as: |attributes| rsx! {
a { href: "#", ..attributes, "Go back" }
}
}
```
2 changes: 2 additions & 0 deletions preview/src/components/sheet/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mod component;
pub use component::*;
Loading
Loading