Skip to content

Commit 9341f65

Browse files
Add sheet component (#162)
* Add sheet component * fix style of format macro rule * keep set_open private * use data attributes instead of different classes for the side styles for better consistency with other components --------- Co-authored-by: Evan Almloff <[email protected]>
1 parent 30fd00d commit 9341f65

File tree

11 files changed

+663
-2
lines changed

11 files changed

+663
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ We're still in the early days - Many components are still being created and stab
5757
- [x] Scroll Area
5858
- [x] Select
5959
- [x] Separator
60+
- [x] Sheet
6061
- [x] Slider
6162
- [x] Switch
6263
- [x] Tabs

component.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"preview/src/components/date_picker",
3737
"preview/src/components/textarea",
3838
"preview/src/components/skeleton",
39-
"preview/src/components/card"
39+
"preview/src/components/card",
40+
"preview/src/components/sheet"
4041
]
4142
}

playwright/sheet.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('sheet basic interactions', async ({ page }) => {
4+
await page.goto('http://127.0.0.1:8080/component/?name=sheet&', { timeout: 20 * 60 * 1000 });
5+
6+
// Open sheet from Right button
7+
await page.getByRole('button', { name: 'Right' }).click();
8+
9+
// Assert the sheet is open
10+
const sheet = page.locator('.sheet-root');
11+
await expect(sheet).toHaveAttribute('data-state', 'open');
12+
13+
// Assert the first input is focused (focus trap)
14+
const nameInput = page.locator('#sheet-demo-name');
15+
await expect(nameInput).toBeFocused();
16+
17+
// Tab through focusable elements and verify focus cycles
18+
// Tab: name input -> username input -> Save button -> Cancel button -> close button -> name input
19+
await page.keyboard.press('Tab');
20+
const usernameInput = page.locator('#sheet-demo-username');
21+
await expect(usernameInput).toBeFocused();
22+
23+
await page.keyboard.press('Tab');
24+
const saveButton = page.getByRole('button', { name: 'Save changes' });
25+
await expect(saveButton).toBeFocused();
26+
27+
await page.keyboard.press('Tab');
28+
const cancelButton = page.getByRole('button', { name: 'Cancel' });
29+
await expect(cancelButton).toBeFocused();
30+
31+
await page.keyboard.press('Tab');
32+
const closeButton = sheet.locator('.sheet-close');
33+
await expect(closeButton).toBeFocused();
34+
35+
// Tab again should cycle back to first input
36+
await page.keyboard.press('Tab');
37+
await expect(nameInput).toBeFocused();
38+
39+
// Hitting escape should close the sheet
40+
await page.keyboard.press('Escape');
41+
await expect(sheet).toHaveCount(0);
42+
43+
// Reopen the sheet
44+
await page.getByRole('button', { name: 'Right' }).click();
45+
await expect(sheet).toHaveAttribute('data-state', 'open');
46+
47+
// Click the close button
48+
await closeButton.click();
49+
await expect(sheet).toHaveCount(0);
50+
});
51+
52+
test('sheet opens from different sides', async ({ page }) => {
53+
await page.goto('http://127.0.0.1:8080/component/?name=sheet&', { timeout: 20 * 60 * 1000 });
54+
55+
const sheet = page.locator('.sheet-root');
56+
const sheetContent = page.locator('[data-slot="sheet-content"]');
57+
58+
// Test Top
59+
await page.getByRole('button', { name: 'Top' }).click();
60+
await expect(sheet).toHaveAttribute('data-state', 'open');
61+
await expect(sheetContent).toHaveAttribute('data-side', 'top');
62+
await page.keyboard.press('Escape');
63+
await expect(sheet).toHaveCount(0);
64+
65+
// Test Bottom
66+
await page.getByRole('button', { name: 'Bottom' }).click();
67+
await expect(sheet).toHaveAttribute('data-state', 'open');
68+
await expect(sheetContent).toHaveAttribute('data-side', 'bottom');
69+
await page.keyboard.press('Escape');
70+
await expect(sheet).toHaveCount(0);
71+
72+
// Test Left
73+
await page.getByRole('button', { name: 'Left' }).click();
74+
await expect(sheet).toHaveAttribute('data-state', 'open');
75+
await expect(sheetContent).toHaveAttribute('data-side', 'left');
76+
await page.keyboard.press('Escape');
77+
await expect(sheet).toHaveCount(0);
78+
});

preview/src/components/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ examples!(
8282
select,
8383
separator,
8484
skeleton,
85+
sheet,
8586
slider,
8687
switch,
8788
tabs,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "sheet",
3+
"description": "A sheet component as an edge panel that complements the main content",
4+
"authors": [
5+
"zhiyanzhaijie"
6+
],
7+
"exclude": [
8+
"variants",
9+
"docs.md",
10+
"component.json"
11+
],
12+
"cargoDependencies": [
13+
{
14+
"name": "dioxus-primitives",
15+
"git": "https://github.com/DioxusLabs/components"
16+
}
17+
],
18+
"globalAssets": [
19+
"../../../assets/dx-components-theme.css"
20+
]
21+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
use dioxus::prelude::*;
2+
use dioxus_primitives::dialog::{
3+
self, DialogCtx, DialogDescriptionProps, DialogRootProps, DialogTitleProps,
4+
};
5+
6+
#[derive(Debug, Clone, Copy, Default, PartialEq)]
7+
pub enum SheetSide {
8+
Top,
9+
#[default]
10+
Right,
11+
Bottom,
12+
Left,
13+
}
14+
15+
impl SheetSide {
16+
pub fn as_str(&self) -> &'static str {
17+
match self {
18+
SheetSide::Top => "top",
19+
SheetSide::Right => "right",
20+
SheetSide::Bottom => "bottom",
21+
SheetSide::Left => "left",
22+
}
23+
}
24+
}
25+
26+
#[component]
27+
pub fn Sheet(props: DialogRootProps) -> Element {
28+
rsx! {
29+
SheetRoot {
30+
id: props.id,
31+
is_modal: props.is_modal,
32+
open: props.open,
33+
default_open: props.default_open,
34+
on_open_change: props.on_open_change,
35+
attributes: props.attributes,
36+
{props.children}
37+
}
38+
}
39+
}
40+
41+
#[component]
42+
fn SheetRoot(props: DialogRootProps) -> Element {
43+
rsx! {
44+
document::Link { rel: "stylesheet", href: asset!("./style.css") }
45+
dialog::DialogRoot {
46+
class: "sheet-root",
47+
"data-slot": "sheet-root",
48+
id: props.id,
49+
is_modal: props.is_modal,
50+
open: props.open,
51+
default_open: props.default_open,
52+
on_open_change: props.on_open_change,
53+
attributes: props.attributes,
54+
{props.children}
55+
}
56+
}
57+
}
58+
59+
#[component]
60+
pub fn SheetContent(
61+
#[props(default = ReadSignal::new(Signal::new(None)))] id: ReadSignal<Option<String>>,
62+
#[props(default)] side: SheetSide,
63+
#[props(default)] class: Option<String>,
64+
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
65+
children: Element,
66+
) -> Element {
67+
let class = class
68+
.map(|c| format!("sheet {c}"))
69+
.unwrap_or("sheet".to_string());
70+
71+
rsx! {
72+
dialog::DialogContent {
73+
class,
74+
id,
75+
"data-slot": "sheet-content",
76+
"data-side": side.as_str(),
77+
attributes,
78+
{children}
79+
SheetClose { class: "sheet-close",
80+
svg {
81+
class: "sheet-close-icon",
82+
view_box: "0 0 24 24",
83+
xmlns: "http://www.w3.org/2000/svg",
84+
path { d: "M18 6 6 18" }
85+
path { d: "m6 6 12 12" }
86+
}
87+
}
88+
}
89+
}
90+
}
91+
92+
#[component]
93+
pub fn SheetHeader(
94+
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
95+
children: Element,
96+
) -> Element {
97+
rsx! {
98+
div { class: "sheet-header", "data-slot": "sheet-header", ..attributes, {children} }
99+
}
100+
}
101+
102+
#[component]
103+
pub fn SheetFooter(
104+
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
105+
children: Element,
106+
) -> Element {
107+
rsx! {
108+
div { class: "sheet-footer", "data-slot": "sheet-footer", ..attributes, {children} }
109+
}
110+
}
111+
112+
#[component]
113+
pub fn SheetTitle(props: DialogTitleProps) -> Element {
114+
rsx! {
115+
dialog::DialogTitle {
116+
id: props.id,
117+
class: "sheet-title",
118+
"data-slot": "sheet-title",
119+
attributes: props.attributes,
120+
{props.children}
121+
}
122+
}
123+
}
124+
125+
#[component]
126+
pub fn SheetDescription(props: DialogDescriptionProps) -> Element {
127+
rsx! {
128+
dialog::DialogDescription {
129+
id: props.id,
130+
class: "sheet-description",
131+
"data-slot": "sheet-description",
132+
attributes: props.attributes,
133+
{props.children}
134+
}
135+
}
136+
}
137+
138+
#[component]
139+
pub fn SheetClose(
140+
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
141+
r#as: Option<Callback<Vec<Attribute>, Element>>,
142+
children: Option<Element>,
143+
) -> Element {
144+
let ctx: DialogCtx = use_context();
145+
146+
let mut merged_attributes: Vec<Attribute> = vec![onclick(move |_| {
147+
ctx.set_open(false);
148+
})];
149+
merged_attributes.extend(attributes);
150+
151+
if let Some(dynamic) = r#as {
152+
dynamic.call(merged_attributes)
153+
} else {
154+
rsx! {
155+
button { ..merged_attributes, {children} }
156+
}
157+
}
158+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
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.
2+
3+
## Component Structure
4+
5+
```rust
6+
// The sheet component must wrap all sheet elements.
7+
Sheet {
8+
// The open prop determines if the sheet is currently open or closed.
9+
open: open(),
10+
// SheetContent wraps the content and defines the side from which the sheet slides in.
11+
// Available sides: Top, Right (default), Bottom, Left.
12+
SheetContent {
13+
side: SheetSide::Right,
14+
// SheetHeader groups the title and description at the top.
15+
SheetHeader {
16+
// The sheet title defines the heading of the sheet.
17+
SheetTitle {
18+
"Edit Profile"
19+
}
20+
// The sheet description provides additional information about the sheet.
21+
SheetDescription {
22+
"Make changes to your profile here."
23+
}
24+
}
25+
// Add your main content here.
26+
// SheetFooter groups actions at the bottom.
27+
SheetFooter {
28+
// SheetClose can be used to close the sheet.
29+
SheetClose {
30+
"Close"
31+
}
32+
}
33+
}
34+
}
35+
```
36+
37+
## SheetClose with `as` prop
38+
39+
The `as` prop allows you to render a custom element while preserving the close behavior, similar to shadcn/ui's `asChild` pattern.
40+
41+
```rust
42+
// Default: renders as <button>
43+
SheetClose { "Close" }
44+
45+
// Custom element: attributes include the preset onclick handler
46+
SheetClose {
47+
r#as: |attributes| rsx! {
48+
a { href: "#", ..attributes, "Go back" }
49+
}
50+
}
51+
```
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
mod component;
2+
pub use component::*;

0 commit comments

Comments
 (0)